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 => { host ? host = host.replace("https://", "") : null; let acct = u.username if (host) { acct = `${u.username}@${host}` } if (u.host) { acct = `${u.username}@${u.host}` } return { id: u.id, username: u.username, acct: acct, display_name: u.name, locked: false, group: null, noindex: null, suspended: null, limited: null, created_at: u.createdAt ? u.createdAt : '', followers_count: 0, following_count: 0, statuses_count: 0, note: '', url: u.host ? `https://${u.host}/@${u.username}` : host ? `https://${host}/@${u.username}` : acct, avatar: u.avatarUrl, avatar_static: u.avatarColor, header: '', header_static: '', emojis: mapEmojis(u.emojis), moved: null, fields: [], bot: null } } export const userDetail = (u: Entity.UserDetail, host: string | null = null): MegalodonEntity.Account => { host ? host = host.replace("https://", "") : null; let acct = u.username if (host) { acct = `${u.username}@${host}` } if (u.host) { acct = `${u.username}@${u.host}` } return { id: u.id, username: u.username, acct: acct, display_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 : '', url: u.host ? `https://${u.host}/@${u.username}` : host ? `https://${host}/@${u.username}` : acct, avatar: u.avatarUrl, avatar_static: u.avatarColor, header: u.bannerUrl, header_static: u.bannerColor, 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: null, 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): MegalodonEntity.Poll => { const now = dayjs() const expire = dayjs(p.expiresAt) const count = p.choices.reduce((sum, choice) => sum + choice.votes, 0) return { 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 } } 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: null, reblog: n.renote ? note(n.renote, n.user.host ? n.user.host : host ? host : null) : null, content: n.text ? n.text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/`/g, '`') .replace(/\r?\n/g, '
') : '', plain_content: n.text ? n.text : null, created_at: n.createdAt, emojis: mapEmojis(n.emojis).concat(mapReactionEmojis(n.reactionEmojis)), replies_count: n.repliesCount, reblogs_count: n.renoteCount, favourites_count: 0, reblogged: false, favourited: false, 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) : null, application: null, language: null, pinned: null, emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.myReaction) : [], bookmarked: false, quote: n.renote !== undefined && n.text !== null } } const mapEmojis = (e: Array | { [key: string]: string }): Array => { if (Array.isArray(e)) { return e.map(e => emoji(e)) } else if (e) { return mapReactionEmojis(e) } else { return [] } } export const mapReactions = (r: { [key: string]: number }, myReaction?: string): Array => { 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 => { 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): Array => { const result: Array = [] 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 = [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, replies_policy: 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.usersCount, status_count: s.notesCount, 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(path: string, params?: any, headers?: { [key: string]: string }): Promise> post(path: string, params?: any, headers?: { [key: string]: string }): Promise> 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(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise> { 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(this.baseUrl + path, options).then((resp: AxiosResponse) => { const res: Response = { 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(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise> { 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(this.baseUrl + path, bodyParams, options).then((resp: AxiosResponse) => { const res: Response = { 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