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 from "../notification"; namespace MisskeyAPI { export namespace Entity { export type App = MisskeyEntity.App; export type Announcement = MisskeyEntity.Announcement; 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 Field = MisskeyEntity.Field; 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 UserDetailMe = MisskeyEntity.UserDetailMe; export type GetAll = MisskeyEntity.GetAll; export type UserKey = MisskeyEntity.UserKey; export type Session = MisskeyEntity.Session; export type Stats = MisskeyEntity.Stats; export type State = MisskeyEntity.State; export type APIEmoji = { emojis: Emoji[] }; } export class Converter { private baseUrl: string; private instanceHost: string; private plcUrl: string; private modelOfAcct = { id: "1", username: "none", acct: "none", display_name: "none", locked: true, bot: true, discoverable: false, group: false, created_at: "1971-01-01T00:00:00.000Z", note: "", url: "plc", avatar: "plc", avatar_static: "plc", header: "plc", header_static: "plc", followers_count: -1, following_count: 0, statuses_count: 0, last_status_at: "1971-01-01T00:00:00.000Z", noindex: true, emojis: [], fields: [], moved: null, }; constructor(baseUrl: string) { this.baseUrl = baseUrl; this.instanceHost = baseUrl.substring(baseUrl.indexOf("//") + 2); this.plcUrl = `${baseUrl}/static-assets/transparent.png`; this.modelOfAcct.url = this.plcUrl; this.modelOfAcct.avatar = this.plcUrl; this.modelOfAcct.avatar_static = this.plcUrl; this.modelOfAcct.header = this.plcUrl; this.modelOfAcct.header_static = this.plcUrl; } // FIXME: Properly render MFM instead of just escaping HTML characters. escapeMFM = (text: string): string => text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/`/g, "`") .replace(/\r?\n/g, "
"); emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => { return { shortcode: e.name, static_url: e.url, url: e.url, visible_in_picker: true, category: e.category, }; }; field = (f: Entity.Field): MegalodonEntity.Field => ({ name: f.name, value: this.escapeMFM(f.value), verified_at: null, }); user = (u: Entity.User): MegalodonEntity.Account => { let acct = u.username; let acctUrl = `https://${u.host || this.instanceHost}/@${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.username, locked: false, created_at: new Date().toISOString(), followers_count: 0, following_count: 0, statuses_count: 0, note: "", url: acctUrl, avatar: u.avatarUrl, avatar_static: u.avatarUrl, header: this.plcUrl, header_static: this.plcUrl, emojis: u.emojis.map((e) => this.emoji(e)), moved: null, fields: [], bot: false, }; }; userDetail = ( u: Entity.UserDetail, host: string, ): MegalodonEntity.Account => { let acct = u.username; host = host.replace("https://", ""); let acctUrl = `https://${host || u.host || this.instanceHost}/@${ 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.username, locked: u.isLocked, created_at: u.createdAt, followers_count: u.followersCount, following_count: u.followingCount, statuses_count: u.notesCount, note: u.description?.replace(/\n|\\n/g, "
") ?? "", url: acctUrl, avatar: u.avatarUrl, avatar_static: u.avatarUrl, header: u.bannerUrl ?? this.plcUrl, header_static: u.bannerUrl ?? this.plcUrl, emojis: u.emojis && u.emojis.length > 0 ? u.emojis.map((e) => this.emoji(e)) : [], moved: null, fields: u.fields.map((f) => this.field(f)), bot: u.isBot, }; }; userPreferences = ( u: MisskeyAPI.Entity.UserDetailMe, v: "public" | "unlisted" | "private" | "direct", ): MegalodonEntity.Preferences => { return { "reading:expand:media": "default", "reading:expand:spoilers": false, "posting:default:language": u.lang, "posting:default:sensitive": u.alwaysMarkNsfw, "posting:default:visibility": v, }; }; 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"; } }; 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"; } }; 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"; }; file = (f: Entity.File): MegalodonEntity.Attachment => { return { id: f.id, type: this.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, blurhash: f.blurhash, }; }; follower = (f: Entity.Follower): MegalodonEntity.Account => { return this.user(f.follower); }; following = (f: Entity.Following): MegalodonEntity.Account => { return this.user(f.followee); }; 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, }; }; choice = (c: Entity.Choice): MegalodonEntity.PollOption => { return { title: c.text, votes_count: c.votes, }; }; 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: p.choices.map((c) => this.choice(c)), voted: p.choices.some((c) => c.isVoted), own_votes: p.choices .filter((c) => c.isVoted) .map((c) => p.choices.indexOf(c)), }; }; note = (n: Entity.Note, host: string): MegalodonEntity.Status => { host = host.replace("https://", ""); return { id: n.id, uri: n.uri ? n.uri : `https://${host}/notes/${n.id}`, url: n.uri ? n.uri : `https://${host}/notes/${n.id}`, account: this.user(n.user), in_reply_to_id: n.replyId, in_reply_to_account_id: n.reply?.userId ?? null, reblog: n.renote ? this.note(n.renote, host) : null, content: n.text ? this.escapeMFM(n.text) : "", plain_content: n.text ? n.text : null, created_at: n.createdAt, // Remove reaction emojis with names containing @ from the emojis list. emojis: n.emojis .filter((e) => e.name.indexOf("@") === -1) .map((e) => this.emoji(e)), replies_count: n.repliesCount, reblogs_count: n.renoteCount, favourites_count: this.getTotalReactions(n.reactions), reblogged: false, favourited: !!n.myReaction, muted: false, sensitive: n.files ? n.files.some((f) => f.isSensitive) : false, spoiler_text: n.cw ? n.cw : "", visibility: this.visibility(n.visibility), media_attachments: n.files ? n.files.map((f) => this.file(f)) : [], mentions: [], tags: [], card: null, poll: n.poll ? this.poll(n.poll, n.id) : null, application: null, language: null, pinned: null, // Use emojis list to provide URLs for emoji reactions. reactions: this.mapReactions(n.emojis, n.reactions, n.myReaction), bookmarked: false, quote: n.renote && n.text ? this.note(n.renote, host) : null, }; }; mapReactions = ( emojis: Array, r: { [key: string]: number }, myReaction?: string, ): Array => { // Map of emoji shortcodes to image URLs. const emojiUrls = new Map( emojis.map((e) => [e.name, e.url]), ); return Object.keys(r).map((key) => { // Strip colons from custom emoji reaction names to match emoji shortcodes. const shortcode = key.replaceAll(":", ""); // If this is a custom emoji (vs. a Unicode emoji), find its image URL. const url = emojiUrls.get(shortcode); // Finally, remove trailing @. from local custom emoji reaction names. const name = shortcode.replace("@.", ""); return { count: r[key], me: key === myReaction, name, url, // We don't actually have a static version of the asset, but clients expect one anyway. static_url: url, }; }); }; getTotalReactions = (r: { [key: string]: number }): number => { return Object.values(r).length > 0 ? Object.values(r).reduce( (previousValue, currentValue) => previousValue + currentValue, ) : 0; }; reactions = ( r: Array, ): Array => { const result: Array = []; for (const e of r) { 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; }; noteToConversation = ( n: Entity.Note, host: string, ): MegalodonEntity.Conversation => { const accounts: Array = [this.user(n.user)]; if (n.reply) { accounts.push(this.user(n.reply.user)); } return { id: n.id, accounts: accounts, last_status: this.note(n, host), unread: false, }; }; list = (l: Entity.List): MegalodonEntity.List => ({ id: l.id, title: l.name, }); encodeNotificationType = ( e: MegalodonEntity.NotificationType, ): MisskeyEntity.NotificationType => { switch (e) { case NotificationType.Follow: return MisskeyNotificationType.Follow; case NotificationType.Mention: return MisskeyNotificationType.Reply; case NotificationType.Favourite: case NotificationType.Reaction: return MisskeyNotificationType.Reaction; case NotificationType.Reblog: return MisskeyNotificationType.Renote; case NotificationType.Poll: return MisskeyNotificationType.PollEnded; case NotificationType.FollowRequest: return MisskeyNotificationType.ReceiveFollowRequest; default: return e; } }; decodeNotificationType = ( e: MisskeyEntity.NotificationType, ): MegalodonEntity.NotificationType => { 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.Reaction; case MisskeyNotificationType.PollEnded: return NotificationType.Poll; case MisskeyNotificationType.ReceiveFollowRequest: return NotificationType.FollowRequest; case MisskeyNotificationType.FollowRequestAccepted: return NotificationType.Follow; default: return e; } }; announcement = (a: Entity.Announcement): MegalodonEntity.Announcement => ({ id: a.id, content: `

${this.escapeMFM(a.title)}

${this.escapeMFM(a.text)}`, starts_at: null, ends_at: null, published: true, all_day: false, published_at: a.createdAt, updated_at: a.updatedAt, read: a.isRead, mentions: [], statuses: [], tags: [], emojis: [], reactions: [], }); notification = ( n: Entity.Notification, host: string, ): MegalodonEntity.Notification => { let notification = { id: n.id, account: n.user ? this.user(n.user) : this.modelOfAcct, created_at: n.createdAt, type: this.decodeNotificationType(n.type), }; if (n.note) { notification = Object.assign(notification, { status: this.note(n.note, host), }); if (notification.type === NotificationType.Poll) { notification = Object.assign(notification, { account: this.note(n.note, host).account, }); } if (n.reaction) { notification = Object.assign(notification, { reaction: this.mapReactions(n.note.emojis, { [n.reaction]: 1 })[0], }); } } return notification; }; stats = (s: Entity.Stats): MegalodonEntity.Stats => { return { user_count: s.usersCount, status_count: s.notesCount, domain_count: s.instances, }; }; 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: this.stats(s), languages: m.langs, contact_account: null, max_toot_chars: m.maxNoteTextLength, registrations: !m.disableRegistration, }; }; hashtag = (h: Entity.Hashtag): MegalodonEntity.Tag => { return { name: h.tag, url: h.tag, history: null, 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 { 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; private converter: Converter; /** * @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. * @param converter Converter instance. */ constructor( baseUrl: string, accessToken: string | null, userAgent: string = DEFAULT_UA, proxyConfig: ProxyConfig | false = false, converter: Converter, ) { this.accessToken = accessToken; this.baseUrl = baseUrl; this.userAgent = userAgent; this.proxyConfig = proxyConfig; this.abortController = new AbortController(); this.converter = converter; axios.defaults.signal = this.abortController.signal; } /** * POST request to mastodon 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, this.converter, ); process.nextTick(() => { streaming.start(); }); return streaming; } } } export default MisskeyAPI;