Sharkey/packages/megalodon/src/misskey/api_client.ts

668 lines
22 KiB
TypeScript

import axios, { AxiosResponse, AxiosRequestConfig } from 'axios'
import dayjs from 'dayjs'
import FormData from 'form-data'
import { DEFAULT_UA } from '../default'
import proxyAgent, { ProxyConfig } from '../proxy_config'
import Response from '../response'
import MisskeyEntity from './entity'
import MegalodonEntity from '../entity'
import WebSocket from './web_socket'
import MisskeyNotificationType from './notification'
import NotificationType, { UnknownNotificationTypeError } from '../notification'
namespace MisskeyAPI {
export namespace Entity {
export type Announcement = MisskeyEntity.Announcement
export type App = MisskeyEntity.App
export type Blocking = MisskeyEntity.Blocking
export type Choice = MisskeyEntity.Choice
export type CreatedNote = MisskeyEntity.CreatedNote
export type Emoji = MisskeyEntity.Emoji
export type Favorite = MisskeyEntity.Favorite
export type File = MisskeyEntity.File
export type Follower = MisskeyEntity.Follower
export type Following = MisskeyEntity.Following
export type FollowRequest = MisskeyEntity.FollowRequest
export type Hashtag = MisskeyEntity.Hashtag
export type List = MisskeyEntity.List
export type Meta = MisskeyEntity.Meta
export type Mute = MisskeyEntity.Mute
export type Note = MisskeyEntity.Note
export type Notification = MisskeyEntity.Notification
export type Poll = MisskeyEntity.Poll
export type Reaction = MisskeyEntity.Reaction
export type Relation = MisskeyEntity.Relation
export type User = MisskeyEntity.User
export type UserDetail = MisskeyEntity.UserDetail
export type UserKey = MisskeyEntity.UserKey
export type Session = MisskeyEntity.Session
export type Stats = MisskeyEntity.Stats
}
export namespace Converter {
export const announcement = (a: Entity.Announcement): MegalodonEntity.Announcement => ({
id: a.id,
content: a.title + '\n' + a.text,
starts_at: null,
ends_at: null,
published: true,
all_day: true,
published_at: a.createdAt,
updated_at: a.updatedAt,
read: a.isRead !== undefined ? a.isRead : null,
mentions: [],
statuses: [],
tags: [],
emojis: [],
reactions: []
})
export const emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => {
return {
shortcode: e.name,
static_url: e.url,
url: e.url,
visible_in_picker: true,
category: e.category
}
}
export const user = (u: Entity.User, host: string | null = null): MegalodonEntity.Account => {
let acct = u.username;
host ? host = host.replace("https://", "") : undefined;
let acctUrl = `https://${host || u.host || host}/@${
u.username
}`;
if (u.host) {
acct = `${u.username}@${u.host}`;
acctUrl = `https://${u.host}/@${u.username}`;
}
const fqn = `${u.username}@${u.host ?? host}`;
return {
id: u.id,
fqn: fqn,
username: u.username,
acct: acct,
display_name: u.name ? u.name : '',
locked: false,
group: null,
noindex: null,
suspended: null,
limited: null,
created_at: u.createdAt ? u.createdAt : '',
followers_count: u.followersCount ? u.followersCount : 0,
following_count: u.followingCount ? u.followingCount : 0,
statuses_count: u.notesCount ? u.notesCount : 0,
note: u.description ? u.description : '',
url: u.uri ?? acctUrl,
avatar: u.avatarUrl ? u.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png',
avatar_static: u.avatarUrl ? u.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png',
header: u.bannerUrl ? u.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png',
header_static: u.bannerUrl ? u.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png',
emojis: mapEmojis(u.emojis),
moved: null,
fields: [],
bot: null
}
}
export const userDetail = (u: Entity.UserDetail, host: string | null = null): MegalodonEntity.Account => {
let acct = u.username;
host ? host = host.replace("https://", "") : undefined;
let acctUrl = `https://${u.host || host}/@${u.username}`;
if (u.host) {
acct = `${u.username}@${u.host}`;
acctUrl = `https://${u.host}/@${u.username}`;
}
return {
id: u.id,
username: u.username,
acct: acct,
display_name: u.name ? u.name : '',
locked: u.isLocked,
group: null,
noindex: null,
suspended: null,
limited: null,
created_at: u.createdAt,
followers_count: u.followersCount,
following_count: u.followingCount,
statuses_count: u.notesCount,
note: u.description ? u.description.replace(/\n|\\n/g, "<br>") : '',
url: u.uri ?? acctUrl,
avatar: u.avatarUrl ? u.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png',
avatar_static: u.avatarUrl ? u.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png',
header: u.bannerUrl ? u.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png',
header_static: u.bannerUrl ? u.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png',
emojis: mapEmojis(u.emojis),
moved: null,
fields: [],
bot: u.isBot
}
}
export const userPreferences = (v: "public" | "unlisted" | "private" | "direct"): MegalodonEntity.Preferences => {
return {
"reading:expand:media": "default",
"reading:expand:spoilers": false,
"posting:default:language": "english",
"posting:default:sensitive": false,
"posting:default:visibility": v,
};
};
export const visibility = (v: 'public' | 'home' | 'followers' | 'specified'): 'public' | 'unlisted' | 'private' | 'direct' => {
switch (v) {
case 'public':
return v
case 'home':
return 'unlisted'
case 'followers':
return 'private'
case 'specified':
return 'direct'
}
}
export const encodeVisibility = (v: 'public' | 'unlisted' | 'private' | 'direct'): 'public' | 'home' | 'followers' | 'specified' => {
switch (v) {
case 'public':
return v
case 'unlisted':
return 'home'
case 'private':
return 'followers'
case 'direct':
return 'specified'
}
}
export const fileType = (s: string): 'unknown' | 'image' | 'gifv' | 'video' | 'audio' => {
if (s === 'image/gif') {
return 'gifv'
}
if (s.includes('image')) {
return 'image'
}
if (s.includes('video')) {
return 'video'
}
if (s.includes('audio')) {
return 'audio'
}
return 'unknown'
}
export const file = (f: Entity.File): MegalodonEntity.Attachment => {
return {
id: f.id,
type: fileType(f.type),
url: f.url,
remote_url: f.url,
preview_url: f.thumbnailUrl,
text_url: f.url,
meta: {
width: f.properties.width,
height: f.properties.height
},
description: f.comment ? f.comment : null,
blurhash: f.blurhash ? f.blurhash : null
}
}
export const follower = (f: Entity.Follower): MegalodonEntity.Account => {
return user(f.follower)
}
export const following = (f: Entity.Following): MegalodonEntity.Account => {
return user(f.followee)
}
export const relation = (r: Entity.Relation): MegalodonEntity.Relationship => {
return {
id: r.id,
following: r.isFollowing,
followed_by: r.isFollowed,
blocking: r.isBlocking,
blocked_by: r.isBlocked,
muting: r.isMuted,
muting_notifications: false,
requested: r.hasPendingFollowRequestFromYou,
domain_blocking: false,
showing_reblogs: true,
endorsed: false,
notifying: false,
note: null
}
}
export const choice = (c: Entity.Choice): MegalodonEntity.PollOption => {
return {
title: c.text,
votes_count: c.votes
}
}
export const poll = (p: Entity.Poll, id: string): MegalodonEntity.Poll => {
const now = dayjs()
const expire = dayjs(p.expiresAt)
const count = p.choices.reduce((sum, choice) => sum + choice.votes, 0)
return {
id: id,
expires_at: p.expiresAt,
expired: now.isAfter(expire),
multiple: p.multiple,
votes_count: count,
options: Array.isArray(p.choices) ? p.choices.map(c => choice(c)) : [],
voted: Array.isArray(p.choices) ? p.choices.some(c => c.isVoted) : false,
own_votes: Array.isArray(p.choices) ? p.choices.filter((c) => c.isVoted).map((c) => p.choices.indexOf(c)) : [],
emojis: [],
}
}
export const note = (n: Entity.Note, host: string | null = null): MegalodonEntity.Status => {
host ? host = host.replace("https://", "") : null;
return {
id: n.id,
uri: n.uri ? n.uri : host ? `https://${host}/notes/${n.id}` : '',
url: n.url ? n.url : host ? `https://${host}/notes/${n.id}` : '',
account: user(n.user, n.user.host ? n.user.host : host ? host : null),
in_reply_to_id: n.replyId,
in_reply_to_account_id: n.reply?.userId ?? null,
reblog: n.renote ? note(n.renote, n.user.host ? n.user.host : host ? host : null) : null,
content: n.text
? n.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/`/g, '&#x60;')
.replace(/\r?\n/g, '<br>')
: '',
plain_content: n.text ? n.text : null,
created_at: n.createdAt,
edited_at: n.updatedAt || null,
emojis: mapEmojis(n.emojis).concat(mapReactionEmojis(n.reactionEmojis)),
replies_count: n.repliesCount,
reblogs_count: n.renoteCount,
favourites_count: getTotalReactions(n.reactions),
reblogged: false,
favourited: !!n.myReaction,
muted: false,
sensitive: Array.isArray(n.files) ? n.files.some(f => f.isSensitive) : false,
spoiler_text: n.cw ? n.cw : '',
visibility: visibility(n.visibility),
media_attachments: Array.isArray(n.files) ? n.files.map(f => file(f)) : [],
mentions: [],
tags: [],
card: null,
poll: n.poll ? poll(n.poll, n.id) : null,
application: null,
language: null,
pinned: null,
emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.myReaction) : [],
bookmarked: false,
quote: n.renote && n.text ? note(n.renote, n.user.host ? n.user.host : host ? host : null) : false
}
}
export const notesource = (n: Entity.Note): MegalodonEntity.StatusSource => {
return {
id: n.id,
text: n.text ?? '',
spoiler_text: n.cw ? n.cw : ''
}
}
const mapEmojis = (e: Array<Entity.Emoji> | { [key: string]: string }): Array<MegalodonEntity.Emoji> => {
if (Array.isArray(e)) {
return e.map(e => emoji(e))
} else if (e) {
return mapReactionEmojis(e)
} else {
return []
}
}
export const getTotalReactions = (r: { [key: string]: number }): number => {
return Object.values(r).length > 0 ? Object.values(r).reduce(
(previousValue, currentValue) => previousValue + currentValue,
) : 0;
};
export const mapReactions = (r: { [key: string]: number }, myReaction?: string): Array<MegalodonEntity.Reaction> => {
return Object.keys(r).map(key => {
if (myReaction && key === myReaction) {
return {
count: r[key],
me: true,
name: key
}
}
return {
count: r[key],
me: false,
name: key
}
})
}
const mapReactionEmojis = (r: { [key: string]: string }): Array<MegalodonEntity.Emoji> => {
return Object.keys(r).map(key => ({
shortcode: key,
static_url: r[key],
url: r[key],
visible_in_picker: true,
category: ''
}))
}
export const reactions = (r: Array<Entity.Reaction>): Array<MegalodonEntity.Reaction> => {
const result: Array<MegalodonEntity.Reaction> = []
r.map(e => {
const i = result.findIndex(res => res.name === e.type)
if (i >= 0) {
result[i].count++
} else {
result.push({
count: 1,
me: false,
name: e.type
})
}
})
return result
}
export const noteToConversation = (n: Entity.Note): MegalodonEntity.Conversation => {
const accounts: Array<MegalodonEntity.Account> = [user(n.user)]
if (n.reply) {
accounts.push(user(n.reply.user))
}
return {
id: n.id,
accounts: accounts,
last_status: note(n),
unread: false
}
}
export const list = (l: Entity.List): MegalodonEntity.List => ({
id: l.id,
title: l.name,
exclusive: null
})
export const encodeNotificationType = (
e: MegalodonEntity.NotificationType
): MisskeyEntity.NotificationType | UnknownNotificationTypeError => {
switch (e) {
case NotificationType.Follow:
return MisskeyNotificationType.Follow
case NotificationType.Mention:
return MisskeyNotificationType.Reply
case NotificationType.Favourite:
case NotificationType.EmojiReaction:
return MisskeyNotificationType.Reaction
case NotificationType.Reblog:
return MisskeyNotificationType.Renote
case NotificationType.PollVote:
return MisskeyNotificationType.PollVote
case NotificationType.FollowRequest:
return MisskeyNotificationType.ReceiveFollowRequest
default:
return new UnknownNotificationTypeError()
}
}
export const decodeNotificationType = (
e: MisskeyEntity.NotificationType
): MegalodonEntity.NotificationType | UnknownNotificationTypeError => {
switch (e) {
case MisskeyNotificationType.Follow:
return NotificationType.Follow
case MisskeyNotificationType.Mention:
case MisskeyNotificationType.Reply:
return NotificationType.Mention
case MisskeyNotificationType.Renote:
case MisskeyNotificationType.Quote:
return NotificationType.Reblog
case MisskeyNotificationType.Reaction:
return NotificationType.EmojiReaction
case MisskeyNotificationType.PollVote:
return NotificationType.PollVote
case MisskeyNotificationType.ReceiveFollowRequest:
return NotificationType.FollowRequest
case MisskeyNotificationType.FollowRequestAccepted:
return NotificationType.Follow
default:
return new UnknownNotificationTypeError()
}
}
export const notification = (n: Entity.Notification): MegalodonEntity.Notification | UnknownNotificationTypeError => {
const notificationType = decodeNotificationType(n.type)
if (notificationType instanceof UnknownNotificationTypeError) {
return notificationType
}
let notification = {
id: n.id,
account: user(n.user),
created_at: n.createdAt,
type: notificationType
}
if (n.note) {
notification = Object.assign(notification, {
status: note(n.note)
})
}
if (n.reaction) {
notification = Object.assign(notification, {
emoji: n.reaction
})
}
return notification
}
export const stats = (s: Entity.Stats): MegalodonEntity.Stats => {
return {
user_count: s.originalUsersCount,
status_count: s.originalNotesCount,
domain_count: s.instances
}
}
export const meta = (m: Entity.Meta, s: Entity.Stats): MegalodonEntity.Instance => {
const wss = m.uri.replace(/^https:\/\//, 'wss://')
return {
uri: m.uri,
title: m.name,
description: m.description,
email: m.maintainerEmail,
version: m.version,
thumbnail: m.bannerUrl,
urls: {
streaming_api: `${wss}/streaming`
},
stats: stats(s),
languages: m.langs,
registrations: !m.disableRegistration,
approval_required: false,
configuration: {
statuses: {
max_characters: m.maxNoteTextLength,
max_media_attachments: m.policies.clipLimit
}
}
}
}
export const hashtag = (h: Entity.Hashtag): MegalodonEntity.Tag => {
return {
name: h.tag,
url: h.tag,
history: [],
following: false
}
}
}
export const DEFAULT_SCOPE = [
'read:account',
'write:account',
'read:blocks',
'write:blocks',
'read:drive',
'write:drive',
'read:favorites',
'write:favorites',
'read:following',
'write:following',
'read:mutes',
'write:mutes',
'write:notes',
'read:notifications',
'write:notifications',
'read:reactions',
'write:reactions',
'write:votes'
]
/**
* Interface
*/
export interface Interface {
get<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
post<T = any>(path: string, params?: any, headers?: { [key: string]: string }): Promise<Response<T>>
cancel(): void
socket(channel: 'user' | 'localTimeline' | 'hybridTimeline' | 'globalTimeline' | 'conversation' | 'list', listId?: string): WebSocket
}
/**
* Misskey API client.
*
* Usign axios for request, you will handle promises.
*/
export class Client implements Interface {
private accessToken: string | null
private baseUrl: string
private userAgent: string
private abortController: AbortController
private proxyConfig: ProxyConfig | false = false
/**
* @param baseUrl hostname or base URL
* @param accessToken access token from OAuth2 authorization
* @param userAgent UserAgent is specified in header on request.
* @param proxyConfig Proxy setting, or set false if don't use proxy.
*/
constructor(baseUrl: string, accessToken: string | null, userAgent: string = DEFAULT_UA, proxyConfig: ProxyConfig | false = false) {
this.accessToken = accessToken
this.baseUrl = baseUrl
this.userAgent = userAgent
this.proxyConfig = proxyConfig
this.abortController = new AbortController()
axios.defaults.signal = this.abortController.signal
}
/**
* GET request to misskey API.
**/
public async get<T>(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
params: params,
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
return axios.get<T>(this.baseUrl + path, options).then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* POST request to misskey REST API.
* @param path relative path from baseUrl
* @param params Form data
* @param headers Request header object
*/
public async post<T>(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
let options: AxiosRequestConfig = {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
}
if (this.proxyConfig) {
options = Object.assign(options, {
httpAgent: proxyAgent(this.proxyConfig),
httpsAgent: proxyAgent(this.proxyConfig)
})
}
let bodyParams = params
if (this.accessToken) {
if (params instanceof FormData) {
bodyParams.append('i', this.accessToken)
} else {
bodyParams = Object.assign(params, {
i: this.accessToken
})
}
}
return axios.post<T>(this.baseUrl + path, bodyParams, options).then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,
statusText: resp.statusText,
headers: resp.headers
}
return res
})
}
/**
* Cancel all requests in this instance.
* @returns void
*/
public cancel(): void {
return this.abortController.abort()
}
/**
* Get connection and receive websocket connection for Misskey API.
*
* @param channel Channel name is user, localTimeline, hybridTimeline, globalTimeline, conversation or list.
* @param listId This parameter is required only list channel.
*/
public socket(
channel: 'user' | 'localTimeline' | 'hybridTimeline' | 'globalTimeline' | 'conversation' | 'list',
listId?: string
): WebSocket {
if (!this.accessToken) {
throw new Error('accessToken is required')
}
const url = this.baseUrl + '/streaming'
const streaming = new WebSocket(url, channel, this.accessToken, listId, this.userAgent, this.proxyConfig)
process.nextTick(() => {
streaming.start()
})
return streaming
}
}
}
export default MisskeyAPI