import FormData from 'form-data' import AsyncLock from 'async-lock'; import MisskeyAPI from './misskey/api_client' import { DEFAULT_UA } from './default' import { ProxyConfig } from './proxy_config' import OAuth from './oauth' import Response from './response' import Entity from './entity' import { MegalodonInterface, WebSocketInterface, NoImplementedError, ArgumentError, UnexpectedError } from './megalodon' import MegalodonEntity from "@/entity"; import fs from "node:fs"; import MisskeyNotificationType from "./misskey/notification"; 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: string = '' 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 }) } 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(res => { return Object.assign(res, { data: res.data.map(f => this.converter.follower(f)) }) }) } /** * 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(res => { return Object.assign(res, { data: res.data.map(f => this.converter.following(f)) }) }) } 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 }) } 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>> { 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 } 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); 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); return this.addMentionsToStatus(status, cache); } 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); status.mentions = (await this.getMentions(status.plain_content!, cache)).filter(p => p != null); for (const m of status.mentions.filter((value, index, array) => array.indexOf(value) === index)) { if (m.acct == m.username) status.content = status.content.replace(`@${m.acct}@${this.baseUrlToHost(this.baseUrl)}`, `@${m.acct}`); status.content = status.content.replace(`@${m.acct}`, `@${m.acct}`); } return status; } public async getMentions(text: string, cache: AccountCache): Promise { const mentions :Entity.Mention[] = []; if (text == undefined) return mentions; const mentionMatch = text.matchAll(/(?<=^|\s)@(?.*?)(?:@(?.*?)|)(?=\s|$)/g); for (const m of mentionMatch) { try { if (m.groups == null) continue; const account = await this.getAccountByNameCached(m.groups.user, m.groups.host, 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>('/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); }); } 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())})) } // ====================================== // 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))) })) } /** * 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))) })) } /** * 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))) })) } /** * 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))) })) } /** * 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))) })) } // ====================================== // 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) }) } // ====================================== // 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 }) } 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> { 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(/^@(?.*?)(?:@(?.*?)|)$/); 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>> { 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>> { 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') } }