import AsyncLock from "async-lock"; import FormData from "form-data"; import fs from "node:fs"; import MegalodonEntity from "@/entity"; import { DEFAULT_UA } from "./default"; import Entity from "./entity"; import { ArgumentError, MegalodonInterface, NoImplementedError, UnexpectedError, WebSocketInterface, } from "./megalodon"; import MisskeyAPI from "./misskey/api_client"; import MisskeyNotificationType from "./misskey/notification"; import OAuth from "./oauth"; import { ProxyConfig } from "./proxy_config"; import Response from "./response"; type AccountCache = { locks: AsyncLock; accounts: Entity.Account[]; }; export default class Misskey implements MegalodonInterface { public client: MisskeyAPI.Interface; public converter: MisskeyAPI.Converter; public baseUrl: string; public proxyConfig: ProxyConfig | 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 = null, userAgent: string | null = DEFAULT_UA, proxyConfig: ProxyConfig | false = false, ) { let token = ""; if (accessToken) { token = accessToken; } let agent: string = DEFAULT_UA; if (userAgent) { agent = userAgent; } this.converter = new MisskeyAPI.Converter(baseUrl); this.client = new MisskeyAPI.Client( baseUrl, token, agent, proxyConfig, this.converter, ); this.baseUrl = baseUrl; this.proxyConfig = proxyConfig; } private baseUrlToHost(baseUrl: string): string { return baseUrl.replace("https://", ""); } public cancel(): void { return this.client.cancel(); } public async registerApp( client_name: string, options: Partial<{ scopes: Array; redirect_uris: string; website: string; }> = { scopes: MisskeyAPI.DEFAULT_SCOPE, redirect_uris: this.baseUrl, }, ): Promise { return this.createApp(client_name, options).then(async (appData) => { return this.generateAuthUrlAndToken(appData.client_secret).then( (session) => { appData.url = session.url; appData.session_token = session.token; return appData; }, ); }); } /** * POST /api/app/create * * Create an application. * @param client_name Your application's name. * @param options Form data. */ public async createApp( client_name: string, options: Partial<{ scopes: Array; redirect_uris: string; website: string; }> = { scopes: MisskeyAPI.DEFAULT_SCOPE, redirect_uris: this.baseUrl, }, ): Promise { const redirect_uris = options.redirect_uris || this.baseUrl; const scopes = options.scopes || MisskeyAPI.DEFAULT_SCOPE; const params: { name: string; description: string; permission: Array; callbackUrl: string; } = { name: client_name, description: "", permission: scopes, callbackUrl: redirect_uris, }; /** * The response is: { "id": "xxxxxxxxxx", "name": "string", "callbackUrl": "string", "permission": [ "string" ], "secret": "string" } */ return this.client .post("/api/app/create", params) .then((res: Response) => { const appData: OAuth.AppDataFromServer = { id: res.data.id, name: res.data.name, website: null, redirect_uri: res.data.callbackUrl, client_id: "", client_secret: res.data.secret, }; return OAuth.AppData.from(appData); }); } /** * POST /api/auth/session/generate */ public async generateAuthUrlAndToken( clientSecret: string, ): Promise { return this.client .post("/api/auth/session/generate", { appSecret: clientSecret, }) .then((res: Response) => res.data); } // ====================================== // apps // ====================================== public async verifyAppCredentials(): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // apps/oauth // ====================================== /** * POST /api/auth/session/userkey * * @param _client_id This parameter is not used in this method. * @param client_secret Application secret key which will be provided in createApp. * @param session_token Session token string which will be provided in generateAuthUrlAndToken. * @param _redirect_uri This parameter is not used in this method. */ public async fetchAccessToken( _client_id: string | null, client_secret: string, session_token: string, _redirect_uri?: string, ): Promise { return this.client .post("/api/auth/session/userkey", { appSecret: client_secret, token: session_token, }) .then((res) => { const token = new OAuth.TokenData( res.data.accessToken, "misskey", "", 0, null, null, ); return token; }); } public async refreshToken( _client_id: string, _client_secret: string, _refresh_token: string, ): Promise { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async revokeToken( _client_id: string, _client_secret: string, _token: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // accounts // ====================================== public async registerAccount( _username: string, _email: string, _password: string, _agreement: boolean, _locale: string, _reason?: string | null, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } /** * POST /api/i */ public async verifyAccountCredentials(): Promise> { return this.client .post("/api/i") .then((res) => { return Object.assign(res, { data: this.converter.userDetail( res.data, this.baseUrlToHost(this.baseUrl), ), }); }); } /** * POST /api/i/update */ public async updateCredentials(options?: { discoverable?: boolean; bot?: boolean; display_name?: string; note?: string; avatar?: string; header?: string; locked?: boolean; source?: { privacy?: string; sensitive?: boolean; language?: string; } | null; fields_attributes?: Array<{ name: string; value: string }>; }): Promise> { let params = {}; if (options) { if (options.bot !== undefined) { params = Object.assign(params, { isBot: options.bot, }); } if (options.display_name) { params = Object.assign(params, { name: options.display_name, }); } if (options.note) { params = Object.assign(params, { description: options.note, }); } if (options.locked !== undefined) { params = Object.assign(params, { isLocked: options.locked, }); } if (options.source) { if (options.source.language) { params = Object.assign(params, { lang: options.source.language, }); } if (options.source.sensitive) { params = Object.assign(params, { alwaysMarkNsfw: options.source.sensitive, }); } } } return this.client .post("/api/i", params) .then((res) => { return Object.assign(res, { data: this.converter.userDetail( res.data, this.baseUrlToHost(this.baseUrl), ), }); }); } /** * POST /api/users/show */ public async getAccount(id: string): Promise> { return this.client .post("/api/users/show", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.userDetail( res.data, this.baseUrlToHost(this.baseUrl), ), }); }); } public async getAccountByName( user: string, host: string | null, ): Promise> { return this.client .post("/api/users/show", { username: user, host: host ?? null, }) .then((res) => { return Object.assign(res, { data: this.converter.userDetail( res.data, this.baseUrlToHost(this.baseUrl), ), }); }); } /** * POST /api/users/notes */ public async getAccountStatuses( id: string, options?: { limit?: number; max_id?: string; since_id?: string; pinned?: boolean; exclude_replies: boolean; exclude_reblogs: boolean; only_media?: boolean; }, ): Promise>> { const accountCache = this.getFreshAccountCache(); if (options?.pinned) { return this.client .post("/api/users/show", { userId: id, }) .then(async (res) => { if (res.data.pinnedNotes) { return { ...res, data: await Promise.all( res.data.pinnedNotes.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ), }; } return { ...res, data: [] }; }); } let params = { userId: id, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } if (options.exclude_replies) { params = Object.assign(params, { includeReplies: false, }); } if (options.exclude_reblogs) { params = Object.assign(params, { includeMyRenotes: false, }); } if (options.only_media) { params = Object.assign(params, { withFiles: options.only_media, }); } } else { params = Object.assign(params, { limit: 20, }); } return this.client .post>("/api/users/notes", params) .then(async (res) => { const statuses: Array = await Promise.all( res.data.map((note) => this.noteWithDetails( note, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ); return Object.assign(res, { data: statuses, }); }); } public async getAccountFavourites( id: string, options?: { limit?: number; max_id?: string; since_id?: string; }, ): Promise>> { const accountCache = this.getFreshAccountCache(); let params = { userId: id, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit <= 100 ? options.limit : 100, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } } return this.client .post>("/api/users/reactions", params) .then(async (res) => { return Object.assign(res, { data: await Promise.all( res.data.map((fav) => this.noteWithDetails( fav.note, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ), }); }); } public async subscribeAccount( _id: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async unsubscribeAccount( _id: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } /** * POST /api/users/followers */ public async getAccountFollowers( id: string, options?: { limit?: number; max_id?: string; since_id?: string; }, ): Promise>> { let params = { userId: id, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit <= 100 ? options.limit : 100, }); } else { params = Object.assign(params, { limit: 40, }); } } else { params = Object.assign(params, { limit: 40, }); } return this.client .post>("/api/users/followers", params) .then(async (res) => { return Object.assign(res, { data: await Promise.all( res.data.map(async (f) => this.getAccount(f.followerId).then((p) => p.data), ), ), }); }); } /** * POST /api/users/following */ public async getAccountFollowing( id: string, options?: { limit?: number; max_id?: string; since_id?: string; }, ): Promise>> { let params = { userId: id, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit <= 100 ? options.limit : 100, }); } } return this.client .post>("/api/users/following", params) .then(async (res) => { return Object.assign(res, { data: await Promise.all( res.data.map(async (f) => this.getAccount(f.followeeId).then((p) => p.data), ), ), }); }); } public async getAccountLists( _id: string, ): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async getIdentityProof( _id: string, ): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } /** * POST /api/following/create */ public async followAccount( id: string, _options?: { reblog?: boolean }, ): Promise> { await this.client.post<{}>("/api/following/create", { userId: id, }); return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } /** * POST /api/following/delete */ public async unfollowAccount( id: string, ): Promise> { await this.client.post<{}>("/api/following/delete", { userId: id, }); return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } /** * POST /api/blocking/create */ public async blockAccount( id: string, ): Promise> { await this.client.post<{}>("/api/blocking/create", { userId: id, }); return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } /** * POST /api/blocking/delete */ public async unblockAccount( id: string, ): Promise> { await this.client.post<{}>("/api/blocking/delete", { userId: id, }); return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } /** * POST /api/mute/create */ public async muteAccount( id: string, _notifications: boolean, ): Promise> { await this.client.post<{}>("/api/mute/create", { userId: id, }); return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } /** * POST /api/mute/delete */ public async unmuteAccount( id: string, ): Promise> { await this.client.post<{}>("/api/mute/delete", { userId: id, }); return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } public async pinAccount(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async unpinAccount( _id: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } /** * POST /api/users/relation * * @param id The accountID, for example `'1sdfag'` */ public async getRelationship( id: string, ): Promise> { return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } /** * POST /api/users/relation * * @param id Array of account ID, for example `['1sdfag', 'ds12aa']`. */ public async getRelationships( ids: Array, ): Promise>> { return Promise.all(ids.map((id) => this.getRelationship(id))).then( (results) => ({ ...results[0], data: results.map((r) => r.data), }), ); } /** * POST /api/users/search */ public async searchAccount( q: string, options?: { following?: boolean; resolve?: boolean; limit?: number; max_id?: string; since_id?: string; }, ): Promise>> { let params = { query: q, detail: true, }; if (options) { if (options.resolve !== undefined) { params = Object.assign(params, { localOnly: options.resolve, }); } if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 40, }); } } else { params = Object.assign(params, { limit: 40, }); } return this.client .post>("/api/users/search", params) .then((res) => { return Object.assign(res, { data: res.data.map((u) => this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), ), }); }); } // ====================================== // accounts/bookmarks // ====================================== /** * POST /api/i/favorites */ public async getBookmarks(options?: { limit?: number; max_id?: string; since_id?: string; min_id?: string; }): Promise>> { const accountCache = this.getFreshAccountCache(); let params = {}; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit <= 100 ? options.limit : 100, }); } else { params = Object.assign(params, { limit: 40, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 40, }); } return this.client .post>("/api/i/favorites", params) .then(async (res) => { return Object.assign(res, { data: await Promise.all( res.data.map((s) => this.noteWithDetails( s.note, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ), }); }); } // ====================================== // accounts/favourites // ====================================== public async getFavourites(options?: { limit?: number; max_id?: string; min_id?: string; }): Promise>> { const userId = await this.client .post("/api/i") .then((res) => res.data.id); return this.getAccountFavourites(userId, options); } // ====================================== // accounts/mutes // ====================================== /** * POST /api/mute/list */ public async getMutes(options?: { limit?: number; max_id?: string; min_id?: string; }): Promise>> { let params = {}; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 40, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 40, }); } return this.client .post>("/api/mute/list", params) .then((res) => { return Object.assign(res, { data: res.data.map((mute) => this.converter.userDetail( mute.mutee, this.baseUrlToHost(this.baseUrl), ), ), }); }); } // ====================================== // accounts/blocks // ====================================== /** * POST /api/blocking/list */ public async getBlocks(options?: { limit?: number; max_id?: string; min_id?: string; }): Promise>> { let params = {}; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 40, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 40, }); } return this.client .post>("/api/blocking/list", params) .then((res) => { return Object.assign(res, { data: res.data.map((blocking) => this.converter.userDetail( blocking.blockee, this.baseUrlToHost(this.baseUrl), ), ), }); }); } // ====================================== // accounts/domain_blocks // ====================================== public async getDomainBlocks(_options?: { limit?: number; max_id?: string; min_id?: string; }): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async blockDomain(_domain: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async unblockDomain(_domain: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // accounts/filters // ====================================== public async getFilters(): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async getFilter(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async createFilter( _phrase: string, _context: Array, _options?: { irreversible?: boolean; whole_word?: boolean; expires_in?: string; }, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async updateFilter( _id: string, _phrase: string, _context: Array, _options?: { irreversible?: boolean; whole_word?: boolean; expires_in?: string; }, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async deleteFilter(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // accounts/reports // ====================================== /** * POST /api/users/report-abuse */ public async report( account_id: string, comment: string, _options?: { status_ids?: Array; forward?: boolean; }, ): Promise> { return this.client .post<{}>("/api/users/report-abuse", { userId: account_id, comment: comment, }) .then((res) => { return Object.assign(res, { data: { id: "", action_taken: "", comment: comment, account_id: account_id, status_ids: [], }, }); }); } // ====================================== // accounts/follow_requests // ====================================== /** * POST /api/following/requests/list */ public async getFollowRequests( _limit?: number, ): Promise>> { return this.client .post>( "/api/following/requests/list", ) .then((res) => { return Object.assign(res, { data: res.data.map((r) => this.converter.user(r.follower)), }); }); } /** * POST /api/following/requests/accept */ public async acceptFollowRequest( id: string, ): Promise> { await this.client.post<{}>("/api/following/requests/accept", { userId: id, }); return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } /** * POST /api/following/requests/reject */ public async rejectFollowRequest( id: string, ): Promise> { await this.client.post<{}>("/api/following/requests/reject", { userId: id, }); return this.client .post("/api/users/relation", { userId: id, }) .then((res) => { return Object.assign(res, { data: this.converter.relation(res.data), }); }); } // ====================================== // accounts/endorsements // ====================================== public async getEndorsements(_options?: { limit?: number; max_id?: string; since_id?: string; }): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // accounts/featured_tags // ====================================== public async getFeaturedTags(): Promise>> { return this.getAccountFeaturedTags(); } public async getAccountFeaturedTags(): Promise< Response> > { const tags: Entity.FeaturedTag[] = []; const res: Response = { headers: undefined, statusText: "", status: 200, data: tags, }; return new Promise((resolve) => resolve(res)); } public async createFeaturedTag( _name: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async deleteFeaturedTag(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async getSuggestedTags(): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // accounts/preferences // ====================================== public async getPreferences(): Promise> { return this.client .post("/api/i") .then(async (res) => { return Object.assign(res, { data: this.converter.userPreferences( res.data, await this.getDefaultPostPrivacy(), ), }); }); } // ====================================== // accounts/suggestions // ====================================== /** * POST /api/users/recommendation */ public async getSuggestions( limit?: number, ): Promise>> { let params = {}; if (limit) { params = Object.assign(params, { limit: limit, }); } return this.client .post>( "/api/users/recommendation", params, ) .then((res) => ({ ...res, data: res.data.map((u) => this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), ), })); } // ====================================== // accounts/tags // ====================================== public async getFollowedTags(): Promise>> { const tags: Entity.Tag[] = []; const res: Response = { headers: undefined, statusText: "", status: 200, data: tags, }; return new Promise((resolve) => resolve(res)); } public async getTag(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async followTag(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async unfollowTag(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // statuses // ====================================== public async postStatus( status: string, options?: { media_ids?: Array; poll?: { options: Array; expires_in: number; multiple?: boolean; hide_totals?: boolean; }; in_reply_to_id?: string; sensitive?: boolean; spoiler_text?: string; visibility?: "public" | "unlisted" | "private" | "direct"; scheduled_at?: string; language?: string; quote_id?: string; }, ): Promise> { let params = { text: status, }; if (options) { if (options.media_ids) { params = Object.assign(params, { fileIds: options.media_ids, }); } if (options.poll) { let pollParam = { choices: options.poll.options, expiresAt: null, expiredAfter: options.poll.expires_in * 1000, }; if (options.poll.multiple !== undefined) { pollParam = Object.assign(pollParam, { multiple: options.poll.multiple, }); } params = Object.assign(params, { poll: pollParam, }); } if (options.in_reply_to_id) { params = Object.assign(params, { replyId: options.in_reply_to_id, }); } if (options.sensitive) { params = Object.assign(params, { cw: "", }); } if (options.spoiler_text) { params = Object.assign(params, { cw: options.spoiler_text, }); } if (options.visibility) { params = Object.assign(params, { visibility: this.converter.encodeVisibility(options.visibility), }); } if (options.quote_id) { params = Object.assign(params, { renoteId: options.quote_id, }); } } return this.client .post("/api/notes/create", params) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data.createdNote, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } /** * POST /api/notes/show */ public async getStatus(id: string): Promise> { return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } private getFreshAccountCache(): AccountCache { return { locks: new AsyncLock(), accounts: [], }; } public async notificationWithDetails( n: MisskeyAPI.Entity.Notification, host: string, cache: AccountCache, ): Promise { const notification = this.converter.notification(n, host); if (n.note) notification.status = await this.noteWithDetails(n.note, host, cache); if (notification.account) notification.account = ( await this.getAccount(notification.account.id) ).data; return notification; } public async noteWithDetails( n: MisskeyAPI.Entity.Note, host: string, cache: AccountCache, ): Promise { const status = await this.addUserDetailsToStatus( this.converter.note(n, host), cache, ); status.bookmarked = await this.isStatusBookmarked(n.id); return this.addMentionsToStatus(status, cache); } public async isStatusBookmarked(id: string): Promise { return this.client .post("/api/notes/state", { noteId: id, }) .then((p) => p.data.isFavorited ?? false); } public async addUserDetailsToStatus( status: Entity.Status, cache: AccountCache, ): Promise { if ( status.account.followers_count === 0 && status.account.followers_count === 0 && status.account.statuses_count === 0 ) status.account = (await this.getAccountCached( status.account.id, status.account.acct, cache, )) ?? status.account; if (status.reblog != null) status.reblog = await this.addUserDetailsToStatus(status.reblog, cache); if (status.quote != null) status.quote = await this.addUserDetailsToStatus(status.quote, cache); return status; } public async addMentionsToStatus( status: Entity.Status, cache: AccountCache, ): Promise { if (status.mentions.length > 0) return status; if (status.reblog != null) status.reblog = await this.addMentionsToStatus(status.reblog, cache); if (status.quote != null) status.quote = await this.addMentionsToStatus(status.quote, cache); const idx = status.account.acct.indexOf("@"); const origin = idx < 0 ? null : status.account.acct.substring(idx + 1); status.mentions = ( await this.getMentions(status.plain_content!, origin, cache) ).filter((p) => p != null); for (const m of status.mentions.filter( (value, index, array) => array.indexOf(value) === index, )) { const regexFull = new RegExp( `(?<=^|\\s|>)@${m.acct}(?=[^a-zA-Z0-9]|$)`, "gi", ); const regexLocalUser = new RegExp( `(?<=^|\\s|>)@${m.acct}@${this.baseUrlToHost( this.baseUrl, )}(?=[^a-zA-Z0-9]|$)`, "gi", ); const regexRemoteUser = new RegExp( `(?<=^|\\s|>)@${m.username}(?=[^a-zA-Z0-9@]|$)`, "gi", ); if (m.acct == m.username) { status.content = status.content.replace(regexLocalUser, `@${m.acct}`); } else if (!status.content.match(regexFull)) { status.content = status.content.replace(regexRemoteUser, `@${m.acct}`); } status.content = status.content.replace( regexFull, `@${m.acct}`, ); } return status; } public async getMentions( text: string, origin: string | null, cache: AccountCache, ): Promise { const mentions: Entity.Mention[] = []; if (text == undefined) return mentions; const mentionMatch = text.matchAll( /(?<=^|\s)@(?[a-zA-Z0-9_]+)(?:@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)|)(?=[^a-zA-Z0-9]|$)/g, ); for (const m of mentionMatch) { try { if (m.groups == null) continue; const account = await this.getAccountByNameCached( m.groups.user, m.groups.host ?? origin, cache, ); if (account == null) continue; mentions.push({ id: account.id, url: account.url, username: account.username, acct: account.acct, }); } catch {} } return mentions; } public async getAccountByNameCached( user: string, host: string | null, cache: AccountCache, ): Promise { const acctToFind = host == null ? user : `${user}@${host}`; return await cache.locks.acquire(acctToFind, async () => { const cacheHit = cache.accounts.find((p) => p.acct === acctToFind); const account = cacheHit ?? (await this.getAccountByName(user, host ?? null)).data; if (!account) { return null; } if (cacheHit == null) { cache.accounts.push(account); } return account; }); } public async getAccountCached( id: string, acct: string, cache: AccountCache, ): Promise { return await cache.locks.acquire(acct, async () => { const cacheHit = cache.accounts.find((p) => p.id === id); const account = cacheHit ?? (await this.getAccount(id)).data; if (!account) { return null; } if (cacheHit == null) { cache.accounts.push(account); } return account; }); } public async editStatus( _id: string, _options: { status?: string; spoiler_text?: string; sensitive?: boolean; media_ids?: Array; poll?: { options?: Array; expires_in?: number; multiple?: boolean; hide_totals?: boolean; }; }, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } /** * POST /api/notes/delete */ public async deleteStatus(id: string): Promise> { return this.client.post<{}>("/api/notes/delete", { noteId: id, }); } /** * POST /api/notes/children */ public async getStatusContext( id: string, options?: { limit?: number; max_id?: string; since_id?: string }, ): Promise> { let params = { noteId: id, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, depth: 12, }); } else { params = Object.assign(params, { limit: 30, depth: 12, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } } else { params = Object.assign(params, { limit: 30, depth: 12, }); } return this.client .post>("/api/notes/children", params) .then(async (res) => { const accountCache = this.getFreshAccountCache(); const conversation = await this.client.post< Array >("/api/notes/conversation", params); const parents = await Promise.all( conversation.data.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ); const context: Entity.Context = { ancestors: parents.reverse(), descendants: this.dfs( await Promise.all( res.data.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ), ), }; return { ...res, data: context, }; }); } private dfs(graph: Entity.Status[]) { // we don't need to run dfs if we have zero or one elements if (graph.length <= 1) { return graph; } // sort the graph first, so we can grab the correct starting point graph = graph.sort((a, b) => { if (a.id < b.id) return -1; if (a.id > b.id) return 1; return 0; }); const initialPostId = graph[0].in_reply_to_id; // populate stack with all top level replies const stack = graph .filter((reply) => reply.in_reply_to_id === initialPostId) .reverse(); const visited = new Set(); const result = []; while (stack.length) { const currentPost = stack.pop(); if (currentPost === undefined) return result; if (!visited.has(currentPost)) { visited.add(currentPost); result.push(currentPost); for (const reply of graph .filter((reply) => reply.in_reply_to_id === currentPost.id) .reverse()) { stack.push(reply); } } } return result; } public async getStatusHistory(): Promise>> { // FIXME: stub, implement once we have note edit history in the database const history: Entity.StatusEdit[] = []; const res: Response = { headers: undefined, statusText: "", status: 200, data: history, }; return new Promise((resolve) => resolve(res)); } /** * POST /api/notes/renotes */ public async getStatusRebloggedBy( id: string, ): Promise>> { return this.client .post>("/api/notes/renotes", { noteId: id, }) .then(async (res) => ({ ...res, data: ( await Promise.all(res.data.map((n) => this.getAccount(n.user.id))) ).map((p) => p.data), })); } public async getStatusFavouritedBy( id: string, ): Promise>> { return this.client .post>("/api/notes/reactions", { noteId: id, }) .then(async (res) => ({ ...res, data: ( await Promise.all(res.data.map((n) => this.getAccount(n.user.id))) ).map((p) => p.data), })); } public async favouriteStatus(id: string): Promise> { return this.createEmojiReaction(id, await this.getDefaultFavoriteEmoji()); } private async getDefaultFavoriteEmoji(): Promise { // NOTE: get-unsecure is calckey's extension. // Misskey doesn't have this endpoint and regular `/i/registry/get` won't work // unless you have a 'nativeToken', which is reserved for the frontend webapp. return await this.client .post>("/api/i/registry/get-unsecure", { key: "reactions", scope: ["client", "base"], }) .then((res) => res.data[0] ?? "⭐"); } private async getDefaultPostPrivacy(): Promise< "public" | "unlisted" | "private" | "direct" > { // NOTE: get-unsecure is calckey's extension. // Misskey doesn't have this endpoint and regular `/i/registry/get` won't work // unless you have a 'nativeToken', which is reserved for the frontend webapp. return this.client .post("/api/i/registry/get-unsecure", { key: "defaultNoteVisibility", scope: ["client", "base"], }) .then((res) => { if ( !res.data || (res.data != "public" && res.data != "home" && res.data != "followers" && res.data != "specified") ) return "public"; return this.converter.visibility(res.data); }) .catch((_) => "public"); } public async unfavouriteStatus(id: string): Promise> { // NOTE: Misskey allows only one reaction per status, so we don't need to care what that emoji was. return this.deleteEmojiReaction(id, ""); } /** * POST /api/notes/create */ public async reblogStatus(id: string): Promise> { return this.client .post("/api/notes/create", { renoteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data.createdNote, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } /** * POST /api/notes/unrenote */ public async unreblogStatus(id: string): Promise> { await this.client.post<{}>("/api/notes/unrenote", { noteId: id, }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } /** * POST /api/notes/favorites/create */ public async bookmarkStatus(id: string): Promise> { await this.client.post<{}>("/api/notes/favorites/create", { noteId: id, }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } /** * POST /api/notes/favorites/delete */ public async unbookmarkStatus(id: string): Promise> { await this.client.post<{}>("/api/notes/favorites/delete", { noteId: id, }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } public async muteStatus(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async unmuteStatus(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } /** * POST /api/i/pin */ public async pinStatus(id: string): Promise> { await this.client.post<{}>("/api/i/pin", { noteId: id, }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } /** * POST /api/i/unpin */ public async unpinStatus(id: string): Promise> { await this.client.post<{}>("/api/i/unpin", { noteId: id, }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } /** * Convert a Unicode emoji or custom emoji name to a Misskey reaction. * @see Misskey's reaction-lib.ts */ private reactionName(name: string): string { // See: https://github.com/tc39/proposal-regexp-unicode-property-escapes#matching-emoji const isUnicodeEmoji = /\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu.test( name, ); if (isUnicodeEmoji) { return name; } return `:${name}:`; } /** * POST /api/notes/reactions/create */ public async reactStatus( id: string, name: string, ): Promise> { await this.client.post<{}>("/api/notes/reactions/create", { noteId: id, reaction: this.reactionName(name), }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } /** * POST /api/notes/reactions/delete */ public async unreactStatus( id: string, name: string, ): Promise> { await this.client.post<{}>("/api/notes/reactions/delete", { noteId: id, reaction: this.reactionName(name), }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } // ====================================== // statuses/media // ====================================== /** * POST /api/drive/files/create */ public async uploadMedia( file: any, options?: { description?: string; focus?: string }, ): Promise> { const formData = new FormData(); formData.append("file", fs.createReadStream(file.path), { contentType: file.mimetype, }); if (file.originalname != null && file.originalname !== "file") formData.append("name", file.originalname); if (options?.description != null) formData.append("comment", options.description); let headers: { [key: string]: string } = {}; if (typeof formData.getHeaders === "function") { headers = formData.getHeaders(); } return this.client .post( "/api/drive/files/create", formData, headers, ) .then((res) => ({ ...res, data: this.converter.file(res.data) })); } public async getMedia(id: string): Promise> { const res = await this.client.post( "/api/drive/files/show", { fileId: id }, ); return { ...res, data: this.converter.file(res.data) }; } /** * POST /api/drive/files/update */ public async updateMedia( id: string, options?: { file?: any; description?: string; focus?: string; is_sensitive?: boolean; }, ): Promise> { let params = { fileId: id, }; if (options) { if (options.is_sensitive !== undefined) { params = Object.assign(params, { isSensitive: options.is_sensitive, }); } if (options.description !== undefined) { params = Object.assign(params, { comment: options.description, }); } } return this.client .post("/api/drive/files/update", params) .then((res) => ({ ...res, data: this.converter.file(res.data) })); } // ====================================== // statuses/polls // ====================================== public async getPoll(id: string): Promise> { const res = await this.getStatus(id); if (res.data.poll == null) throw new Error("poll not found"); return { ...res, data: res.data.poll }; } /** * POST /api/notes/polls/vote */ public async votePoll( id: string, choices: Array, ): Promise> { if (!id) { return new Promise((_, reject) => { const err = new ArgumentError("id is required"); reject(err); }); } for (const c of choices) { const params = { noteId: id, choice: +c, }; await this.client.post<{}>("/api/notes/polls/vote", params); } const res = await this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => { const note = await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ); return { ...res, data: note.poll }; }); if (!res.data) { return new Promise((_, reject) => { const err = new UnexpectedError("poll does not exist"); reject(err); }); } return { ...res, data: res.data }; } // ====================================== // statuses/scheduled_statuses // ====================================== public async getScheduledStatuses(_options?: { limit?: number; max_id?: string; since_id?: string; min_id?: string; }): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async getScheduledStatus( _id: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async scheduleStatus( _id: string, _scheduled_at?: string | null, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async cancelScheduledStatus(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // timelines // ====================================== /** * POST /api/notes/global-timeline */ public async getPublicTimeline(options?: { only_media?: boolean; limit?: number; max_id?: string; since_id?: string; min_id?: string; }): Promise>> { const accountCache = this.getFreshAccountCache(); let params = {}; if (options) { if (options.only_media !== undefined) { params = Object.assign(params, { withFiles: options.only_media, }); } if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 20, }); } return this.client .post>("/api/notes/global-timeline", params) .then(async (res) => ({ ...res, data: ( await Promise.all( res.data.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ) ).sort(this.sortByIdDesc), })); } /** * POST /api/notes/local-timeline */ public async getLocalTimeline(options?: { only_media?: boolean; limit?: number; max_id?: string; since_id?: string; min_id?: string; }): Promise>> { const accountCache = this.getFreshAccountCache(); let params = {}; if (options) { if (options.only_media !== undefined) { params = Object.assign(params, { withFiles: options.only_media, }); } if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 20, }); } return this.client .post>("/api/notes/local-timeline", params) .then(async (res) => ({ ...res, data: ( await Promise.all( res.data.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ) ).sort(this.sortByIdDesc), })); } /** * POST /api/notes/search-by-tag */ public async getTagTimeline( hashtag: string, options?: { local?: boolean; only_media?: boolean; limit?: number; max_id?: string; since_id?: string; min_id?: string; }, ): Promise>> { const accountCache = this.getFreshAccountCache(); let params = { tag: hashtag, }; if (options) { if (options.only_media !== undefined) { params = Object.assign(params, { withFiles: options.only_media, }); } if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 20, }); } return this.client .post>("/api/notes/search-by-tag", params) .then(async (res) => ({ ...res, data: ( await Promise.all( res.data.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ) ).sort(this.sortByIdDesc), })); } /** * POST /api/notes/timeline */ public async getHomeTimeline(options?: { local?: boolean; limit?: number; max_id?: string; since_id?: string; min_id?: string; }): Promise>> { const accountCache = this.getFreshAccountCache(); let params = { withFiles: false, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 20, }); } return this.client .post>("/api/notes/timeline", params) .then(async (res) => ({ ...res, data: ( await Promise.all( res.data.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ) ).sort(this.sortByIdDesc), })); } /** * POST /api/notes/user-list-timeline */ public async getListTimeline( list_id: string, options?: { limit?: number; max_id?: string; since_id?: string; min_id?: string; }, ): Promise>> { const accountCache = this.getFreshAccountCache(); let params = { listId: list_id, withFiles: false, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 20, }); } return this.client .post>( "/api/notes/user-list-timeline", params, ) .then(async (res) => ({ ...res, data: ( await Promise.all( res.data.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ) ).sort(this.sortByIdDesc), })); } // ====================================== // timelines/conversations // ====================================== /** * POST /api/notes/mentions */ public async getConversationTimeline(options?: { limit?: number; max_id?: string; since_id?: string; min_id?: string; }): Promise>> { let params = { visibility: "specified", }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } } else { params = Object.assign(params, { limit: 20, }); } return this.client .post>("/api/notes/mentions", params) .then((res) => ({ ...res, data: res.data.map((n) => this.converter.noteToConversation( n, this.baseUrlToHost(this.baseUrl), ), ), })); // FIXME: ^ this should also parse mentions } public async deleteConversation(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async readConversation( _id: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } private sortByIdDesc(a: Entity.Status, b: Entity.Status): number { if (a.id < b.id) return 1; if (a.id > b.id) return -1; return 0; } // ====================================== // timelines/lists // ====================================== /** * POST /api/users/lists/list */ public async getLists(): Promise>> { return this.client .post>("/api/users/lists/list") .then((res) => ({ ...res, data: res.data.map((l) => this.converter.list(l)), })); } /** * POST /api/users/lists/show */ public async getList(id: string): Promise> { return this.client .post("/api/users/lists/show", { listId: id, }) .then((res) => ({ ...res, data: this.converter.list(res.data) })); } /** * POST /api/users/lists/create */ public async createList(title: string): Promise> { return this.client .post("/api/users/lists/create", { name: title, }) .then((res) => ({ ...res, data: this.converter.list(res.data) })); } /** * POST /api/users/lists/update */ public async updateList( id: string, title: string, ): Promise> { return this.client .post("/api/users/lists/update", { listId: id, name: title, }) .then((res) => ({ ...res, data: this.converter.list(res.data) })); } /** * POST /api/users/lists/delete */ public async deleteList(id: string): Promise> { return this.client.post<{}>("/api/users/lists/delete", { listId: id, }); } /** * POST /api/users/lists/show */ public async getAccountsInList( id: string, _options?: { limit?: number; max_id?: string; since_id?: string; }, ): Promise>> { const res = await this.client.post( "/api/users/lists/show", { listId: id, }, ); const promise = res.data.userIds.map((userId) => this.getAccount(userId)); const accounts = await Promise.all(promise); return { ...res, data: accounts.map((r) => r.data) }; } /** * POST /api/users/lists/push */ public async addAccountsToList( id: string, account_ids: Array, ): Promise> { return this.client.post<{}>("/api/users/lists/push", { listId: id, userId: account_ids[0], }); } /** * POST /api/users/lists/pull */ public async deleteAccountsFromList( id: string, account_ids: Array, ): Promise> { return this.client.post<{}>("/api/users/lists/pull", { listId: id, userId: account_ids[0], }); } // ====================================== // timelines/markers // ====================================== public async getMarkers( _timeline: Array, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async saveMarkers(_options?: { home?: { last_read_id: string }; notifications?: { last_read_id: string }; }): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // notifications // ====================================== /** * POST /api/i/notifications */ public async getNotifications(options?: { limit?: number; max_id?: string; since_id?: string; min_id?: string; exclude_type?: Array; account_id?: string; }): Promise>> { let params = {}; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit <= 100 ? options.limit : 100, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.since_id) { params = Object.assign(params, { sinceId: options.since_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } if (options.exclude_type) { params = Object.assign(params, { excludeType: options.exclude_type.map((e) => this.converter.encodeNotificationType(e), ), }); } } else { params = Object.assign(params, { limit: 20, }); } const cache = this.getFreshAccountCache(); return this.client .post>( "/api/i/notifications", params, ) .then(async (res) => ({ ...res, data: await Promise.all( res.data .filter( (p) => p.type != MisskeyNotificationType.FollowRequestAccepted, ) // these aren't supported on mastodon .map((n) => this.notificationWithDetails( n, this.baseUrlToHost(this.baseUrl), cache, ), ), ), })); } public async getNotification( _id: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } /** * POST /api/notifications/mark-all-as-read */ public async dismissNotifications(): Promise> { return this.client.post<{}>("/api/notifications/mark-all-as-read"); } public async dismissNotification(_id: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async readNotifications(_options: { id?: string; max_id?: string; }): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("mastodon does not support"); reject(err); }); } // ====================================== // notifications/push // ====================================== public async subscribePushNotification( _subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, _data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean; }; } | null, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async getPushSubscription(): Promise< Response > { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async updatePushSubscription( _data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean; }; } | null, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } /** * DELETE /api/v1/push/subscription */ public async deletePushSubscription(): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // search // ====================================== public async search( q: string, type: "accounts" | "hashtags" | "statuses", options?: { limit?: number; max_id?: string; min_id?: string; resolve?: boolean; offset?: number; following?: boolean; account_id?: string; exclude_unreviewed?: boolean; }, ): Promise> { const accountCache = this.getFreshAccountCache(); switch (type) { case "accounts": { if (q.startsWith("http://") || q.startsWith("https://")) { return this.client .post("/api/ap/show", { uri: q }) .then(async (res) => { if (res.status != 200 || res.data.type != "User") { res.status = 200; res.statusText = "OK"; res.data = { accounts: [], statuses: [], hashtags: [], }; return res; } const account = await this.converter.userDetail( res.data.object as MisskeyAPI.Entity.UserDetail, this.baseUrlToHost(this.baseUrl), ); return { ...res, data: { accounts: options?.max_id && options?.max_id >= account.id ? [] : [account], statuses: [], hashtags: [], }, }; }); } let params = { query: q, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } else { params = Object.assign(params, { limit: 20, }); } if (options.offset) { params = Object.assign(params, { offset: options.offset, }); } if (options.resolve) { params = Object.assign(params, { localOnly: options.resolve, }); } } else { params = Object.assign(params, { limit: 20, }); } try { const match = q.match( /^@(?[a-zA-Z0-9_]+)(?:@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)|)$/, ); if (match) { const lookupQuery = { username: match.groups?.user, host: match.groups?.host, }; const result = await this.client .post( "/api/users/show", lookupQuery, ) .then((res) => ({ ...res, data: { accounts: [ this.converter.userDetail( res.data, this.baseUrlToHost(this.baseUrl), ), ], statuses: [], hashtags: [], }, })); if (result.status !== 200) { result.status = 200; result.statusText = "OK"; result.data = { accounts: [], statuses: [], hashtags: [], }; } return result; } } catch {} return this.client .post>( "/api/users/search", params, ) .then((res) => ({ ...res, data: { accounts: res.data.map((u) => this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), ), statuses: [], hashtags: [], }, })); } case "statuses": { if (q.startsWith("http://") || q.startsWith("https://")) { return this.client .post("/api/ap/show", { uri: q }) .then(async (res) => { if (res.status != 200 || res.data.type != "Note") { res.status = 200; res.statusText = "OK"; res.data = { accounts: [], statuses: [], hashtags: [], }; return res; } const post = await this.noteWithDetails( res.data.object as MisskeyAPI.Entity.Note, this.baseUrlToHost(this.baseUrl), accountCache, ); return { ...res, data: { accounts: [], statuses: options?.max_id && options.max_id >= post.id ? [] : [post], hashtags: [], }, }; }); } let params = { query: q, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } if (options.offset) { params = Object.assign(params, { offset: options.offset, }); } if (options.max_id) { params = Object.assign(params, { untilId: options.max_id, }); } if (options.min_id) { params = Object.assign(params, { sinceId: options.min_id, }); } if (options.account_id) { params = Object.assign(params, { userId: options.account_id, }); } } return this.client .post>("/api/notes/search", params) .then(async (res) => ({ ...res, data: { accounts: [], statuses: await Promise.all( res.data.map((n) => this.noteWithDetails( n, this.baseUrlToHost(this.baseUrl), accountCache, ), ), ), hashtags: [], }, })); } case "hashtags": { let params = { query: q, }; if (options) { if (options.limit) { params = Object.assign(params, { limit: options.limit, }); } if (options.offset) { params = Object.assign(params, { offset: options.offset, }); } } return this.client .post>("/api/hashtags/search", params) .then((res) => ({ ...res, data: { accounts: [], statuses: [], hashtags: res.data.map((h) => ({ name: h, url: h, history: null, following: false, })), }, })); } } } // ====================================== // instance // ====================================== /** * POST /api/meta * POST /api/stats */ public async getInstance(): Promise> { const meta = await this.client .post("/api/meta") .then((res) => res.data); return this.client .post("/api/stats") .then((res) => ({ ...res, data: this.converter.meta(meta, res.data) })); } public async getInstancePeers(): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public async getInstanceActivity(): Promise< Response> > { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // instance/trends // ====================================== /** * POST /api/hashtags/trend */ public async getInstanceTrends( _limit?: number | null, ): Promise>> { return this.client .post>("/api/hashtags/trend") .then((res) => ({ ...res, data: res.data.map((h) => this.converter.hashtag(h)), })); } // ====================================== // instance/directory // ====================================== public async getInstanceDirectory(_options?: { limit?: number; offset?: number; order?: "active" | "new"; local?: boolean; }): Promise>> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } // ====================================== // instance/custom_emojis // ====================================== /** * POST /api/meta */ public async getInstanceCustomEmojis(): Promise< Response> > { return this.client .post("/api/meta") .then((res) => ({ ...res, data: res.data.emojis.map((e) => this.converter.emoji(e)), })); } // ====================================== // instance/announcements // ====================================== public async getInstanceAnnouncements( with_dismissed?: boolean | null, ): Promise>> { let params = {}; if (with_dismissed) { params = Object.assign(params, { withUnreads: with_dismissed, }); } return this.client .post>("/api/announcements", params) .then((res) => ({ ...res, data: res.data.map((t) => this.converter.announcement(t)), })); } public async dismissInstanceAnnouncement(id: string): Promise> { return this.client.post<{}>("/api/i/read-announcement", { announcementId: id, }); } // ====================================== // Emoji reactions // ====================================== /** * POST /api/notes/reactions/create * * @param {string} id Target note ID. * @param {string} emoji Reaction emoji string. This string is raw unicode emoji. */ public async createEmojiReaction( id: string, emoji: string, ): Promise> { await this.client.post<{}>("/api/notes/reactions/create", { noteId: id, reaction: emoji, }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } /** * POST /api/notes/reactions/delete */ public async deleteEmojiReaction( id: string, _emoji: string, ): Promise> { await this.client.post<{}>("/api/notes/reactions/delete", { noteId: id, }); return this.client .post("/api/notes/show", { noteId: id, }) .then(async (res) => ({ ...res, data: await this.noteWithDetails( res.data, this.baseUrlToHost(this.baseUrl), this.getFreshAccountCache(), ), })); } public async getEmojiReactions( id: string, ): Promise>> { return this.client .post>("/api/notes/reactions", { noteId: id, }) .then((res) => ({ ...res, data: this.converter.reactions(res.data), })); } public async getEmojiReaction( _id: string, _emoji: string, ): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError("misskey does not support"); reject(err); }); } public userSocket(): WebSocketInterface { return this.client.socket("user"); } public publicSocket(): WebSocketInterface { return this.client.socket("globalTimeline"); } public localSocket(): WebSocketInterface { return this.client.socket("localTimeline"); } public tagSocket(_tag: string): WebSocketInterface { throw new NoImplementedError("TODO: implement"); } public listSocket(list_id: string): WebSocketInterface { return this.client.socket("list", list_id); } public directSocket(): WebSocketInterface { return this.client.socket("conversation"); } }