From f61d71ac8cda6455238faa976ef221525ab5ed34 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 20 Mar 2025 20:43:05 -0400 Subject: [PATCH 01/37] refactor mastodon API and preserve remote user agent for requests --- packages/backend/src/server/ServerModule.ts | 18 + .../api/mastodon/MastodonApiServerService.ts | 792 ++---------------- .../api/mastodon/MastodonClientService.ts | 69 ++ .../{timelineArgs.ts => argsUtils.ts} | 0 .../src/server/api/mastodon/converters.ts | 3 - .../src/server/api/mastodon/endpoints.ts | 22 - .../server/api/mastodon/endpoints/account.ts | 506 ++++++++--- .../src/server/api/mastodon/endpoints/apps.ts | 121 +++ .../src/server/api/mastodon/endpoints/auth.ts | 97 --- .../server/api/mastodon/endpoints/filter.ts | 149 +++- .../server/api/mastodon/endpoints/instance.ts | 110 +++ .../src/server/api/mastodon/endpoints/meta.ts | 66 -- .../api/mastodon/endpoints/notifications.ts | 109 ++- .../server/api/mastodon/endpoints/search.ts | 174 ++-- .../server/api/mastodon/endpoints/status.ts | 257 +++--- .../server/api/mastodon/endpoints/timeline.ts | 169 ++-- .../src/server/oauth/OAuth2ProviderService.ts | 104 +-- 17 files changed, 1319 insertions(+), 1447 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/MastodonClientService.ts rename packages/backend/src/server/api/mastodon/{timelineArgs.ts => argsUtils.ts} (100%) delete mode 100644 packages/backend/src/server/api/mastodon/endpoints.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/apps.ts delete mode 100644 packages/backend/src/server/api/mastodon/endpoints/auth.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/instance.ts delete mode 100644 packages/backend/src/server/api/mastodon/endpoints/meta.ts diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 2c067afe88..5af41ddd9f 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -7,6 +7,15 @@ import { Module } from '@nestjs/common'; import { EndpointsModule } from '@/server/api/EndpointsModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js'; +import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; +import { ApiFilterMastodon } from '@/server/api/mastodon/endpoints/filter.js'; +import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; +import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js'; +import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; +import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js'; +import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; import { HealthServerService } from './HealthServerService.js'; @@ -107,6 +116,15 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j MastoConverters, MastodonLogger, MastodonDataService, + MastodonClientService, + ApiAccountMastodon, + ApiAppsMastodon, + ApiFilterMastodon, + ApiInstanceMastodon, + ApiNotificationsMastodon, + ApiSearchMastodon, + ApiStatusMastodon, + ApiTimelineMastodon, ], exports: [ ServerService, diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 69799bdade..7a4611fd74 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -4,75 +4,44 @@ */ import querystring from 'querystring'; -import { megalodon, Entity, MegalodonInterface } from 'megalodon'; -import { IsNull } from 'typeorm'; import multer from 'fastify-multer'; import { Inject, Injectable } from '@nestjs/common'; -import type { AccessTokensRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; -import { DriveService } from '@/core/DriveService.js'; import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { ApiAccountMastodonRoute } from '@/server/api/mastodon/endpoints/account.js'; -import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.js'; -import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js'; -import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js'; -import { AuthenticateService } from '@/server/api/AuthenticateService.js'; -import { MiLocalUser } from '@/models/User.js'; -import { AuthMastodonRoute } from './endpoints/auth.js'; -import { toBoolean } from './timelineArgs.js'; -import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; -import { getInstance } from './endpoints/meta.js'; -import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; -import type { FastifyInstance, FastifyPluginOptions, FastifyRequest } from 'fastify'; - -export function getAccessToken(authorization: string | undefined): string | null { - const accessTokenArr = authorization?.split(' ') ?? [null]; - return accessTokenArr[accessTokenArr.length - 1]; -} - -export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessToken = getAccessToken(authorization); - return megalodon('misskey', BASE_URL, accessToken); -} +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; +import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; +import { ApiFilterMastodon } from '@/server/api/mastodon/endpoints/filter.js'; +import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js'; +import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js'; +import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js'; +import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js'; +import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js'; +import { convertAnnouncement, convertAttachment, MastoConverters, convertRelationship } from './converters.js'; +import type { Entity } from 'megalodon'; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() export class MastodonApiServerService { constructor( - @Inject(DI.meta) - private readonly serverSettings: MiMeta, - @Inject(DI.usersRepository) - private readonly usersRepository: UsersRepository, - @Inject(DI.userProfilesRepository) - private readonly userProfilesRepository: UserProfilesRepository, - @Inject(DI.accessTokensRepository) - private readonly accessTokensRepository: AccessTokensRepository, @Inject(DI.config) private readonly config: Config, - private readonly driveService: DriveService, + private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, - private readonly authenticateService: AuthenticateService, - ) { } - - @bindThis - public async getAuthClient(request: FastifyRequest): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> { - const accessToken = getAccessToken(request.headers.authorization); - const [me] = await this.authenticateService.authenticate(accessToken); - - const baseUrl = `${request.protocol}://${request.host}`; - const client = megalodon('misskey', baseUrl, accessToken); - - return { client, me }; - } - - @bindThis - public async getAuthOnly(request: FastifyRequest): Promise { - const accessToken = getAccessToken(request.headers.authorization); - const [me] = await this.authenticateService.authenticate(accessToken); - return me; - } + private readonly clientService: MastodonClientService, + private readonly apiAccountMastodon: ApiAccountMastodon, + private readonly apiAppsMastodon: ApiAppsMastodon, + private readonly apiFilterMastodon: ApiFilterMastodon, + private readonly apiInstanceMastodon: ApiInstanceMastodon, + private readonly apiNotificationsMastodon: ApiNotificationsMastodon, + private readonly apiSearchMastodon: ApiSearchMastodon, + private readonly apiStatusMastodon: ApiStatusMastodon, + private readonly apiTimelineMastodon: ApiTimelineMastodon, + ) {} @bindThis public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { @@ -107,11 +76,19 @@ export class MastodonApiServerService { fastify.register(multer.contentParser); + // External endpoints + this.apiAccountMastodon.register(fastify, upload); + this.apiAppsMastodon.register(fastify, upload); + this.apiFilterMastodon.register(fastify, upload); + this.apiInstanceMastodon.register(fastify); + this.apiNotificationsMastodon.register(fastify, upload); + this.apiSearchMastodon.register(fastify); + this.apiStatusMastodon.register(fastify); + this.apiTimelineMastodon.register(fastify); + fastify.get('/v1/custom_emojis', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const client = this.clientService.getClient(_request); const data = await client.getInstanceCustomEmojis(); reply.send(data.data); } catch (e) { @@ -121,36 +98,9 @@ export class MastodonApiServerService { } }); - fastify.get('/v1/instance', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getInstance(); - const admin = await this.usersRepository.findOne({ - where: { - host: IsNull(), - isRoot: true, - isDeleted: false, - isSuspended: false, - }, - order: { id: 'ASC' }, - }); - const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); - reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/instance', data); - reply.code(401).send(data); - } - }); - fastify.get('/v1/announcements', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const client = this.clientService.getClient(_request); const data = await client.getInstanceAnnouncements(); reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); } catch (e) { @@ -161,11 +111,9 @@ export class MastodonApiServerService { }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); + const client = this.clientService.getClient(_request); const data = await client.dismissInstanceAnnouncement(_request.body['id']); reply.send(data.data); } catch (e) { @@ -176,15 +124,13 @@ export class MastodonApiServerService { }); fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); return; } + const client = this.clientService.getClient(_request); const data = await client.uploadMedia(multipartData); reply.send(convertAttachment(data.data as Entity.Attachment)); } catch (e) { @@ -195,15 +141,13 @@ export class MastodonApiServerService { }); fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); return; } + const client = this.clientService.getClient(_request); const data = await client.uploadMedia(multipartData, _request.body); reply.send(convertAttachment(data.data as Entity.Attachment)); } catch (e) { @@ -213,27 +157,9 @@ export class MastodonApiServerService { } }); - fastify.get('/v1/filters', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await client.getFilters(); - reply.send(data.data.map((filter) => convertFilter(filter))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/filters', data); - reply.code(401).send(data); - } - }); - fastify.get('/v1/trends', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in try { + const client = this.clientService.getClient(_request); const data = await client.getInstanceTrends(); reply.send(data.data); } catch (e) { @@ -244,11 +170,8 @@ export class MastodonApiServerService { }); fastify.get('/v1/trends/tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in try { + const client = this.clientService.getClient(_request); const data = await client.getInstanceTrends(); reply.send(data.data); } catch (e) { @@ -263,26 +186,9 @@ export class MastodonApiServerService { reply.send([]); }); - fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - const data = await ApiAuthMastodon(_request, client); - reply.send(data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/apps', data); - reply.code(401).send(data); - } - }); - fastify.get('/v1/preferences', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in try { + const client = this.clientService.getClient(_request); const data = await client.getPreferences(); reply.send(data.data); } catch (e) { @@ -292,317 +198,9 @@ export class MastodonApiServerService { } }); - //#region Accounts - fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.verifyCredentials()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/verify_credentials', data); - reply.code(401).send(data); - } - }); - - fastify.patch<{ - Body: { - discoverable?: string, - bot?: string, - display_name?: string, - note?: string, - avatar?: string, - header?: string, - locked?: string, - source?: { - privacy?: string, - sensitive?: string, - language?: string, - }, - fields_attributes?: { - name: string, - value: string, - }[], - }, - }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - try { - // Check if there is an Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. - if (_request.files.length > 0 && accessTokens) { - const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const avatar = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'avatar'; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const header = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'header'; - }); - - if (tokeninfo && avatar) { - const upload = await this.driveService.addFile({ - user: { id: tokeninfo.userId, host: null }, - path: avatar.path, - name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, - sensitive: false, - }); - if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).avatar = upload.id; - } - } else if (tokeninfo && header) { - const upload = await this.driveService.addFile({ - user: { id: tokeninfo.userId, host: null }, - path: header.path, - name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, - sensitive: false, - }); - if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).header = upload.id; - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((_request.body as any).fields_attributes) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fields = (_request.body as any).fields_attributes.map((field: any) => { - if (!(field.name.trim() === '' && field.value.trim() === '')) { - if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); - if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); - } - return { - ...field, - }; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); - } - - const options = { - ..._request.body, - discoverable: toBoolean(_request.body.discoverable), - bot: toBoolean(_request.body.bot), - locked: toBoolean(_request.body.locked), - source: _request.body.source ? { - ..._request.body.source, - sensitive: toBoolean(_request.body.source.sensitive), - } : undefined, - }; - const data = await client.updateCredentials(options); - reply.send(await this.mastoConverters.convertAccount(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('PATCH /v1/accounts/update_credentials', data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in - try { - if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); - const data = await client.search(_request.query.acct, { type: 'accounts' }); - const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); - data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; - reply.send(await this.mastoConverters.convertAccount(data.data.accounts[0])); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/lookup', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; - if (typeof ids === 'string') { - ids = [ids]; - } - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getRelationships(ids)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/relationships', data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getAccount(_request.params.id); - const account = await this.mastoConverters.convertAccount(data.data); - reply.send(account); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getStatuses()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getFeaturedTags(); - reply.send(data.data.map((tag) => convertFeaturedTag(tag))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFollowers()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFollowing()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data); - reply.code(401).send(data); - } - }); - - fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const data = await client.getAccountLists(_request.params.id); - reply.send(data.data.map((list) => convertList(list))); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmFollow()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addBlock()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmBlock()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.addMute()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rmMute()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data); - reply.code(401).send(data); - } - }); - fastify.get('/v1/followed_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const client = this.clientService.getClient(_request); const data = await client.getFollowedTags(); reply.send(data.data); } catch (e) { @@ -612,11 +210,14 @@ export class MastodonApiServerService { } }); - fastify.get('/v1/bookmarks', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => { try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getBookmarks()); + const { client, me } = await this.clientService.getAuthClient(_request); + + const data = await client.getBookmarks(parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/bookmarks', data); @@ -624,11 +225,14 @@ export class MastodonApiServerService { } }); - fastify.get('/v1/favourites', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => { try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getFavourites()); + const { client, me } = await this.clientService.getAuthClient(_request); + + const data = await client.getFavourites(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/favourites', data); @@ -636,11 +240,14 @@ export class MastodonApiServerService { } }); - fastify.get('/v1/mutes', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => { try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getMutes()); + const client = this.clientService.getClient(_request); + + const data = await client.getMutes(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/mutes', data); @@ -648,11 +255,14 @@ export class MastodonApiServerService { } }); - fastify.get('/v1/blocks', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => { try { - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.getBlocks()); + const client = this.clientService.getClient(_request); + + const data = await client.getBlocks(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/blocks', data); @@ -661,10 +271,8 @@ export class MastodonApiServerService { }); fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { + const client = this.clientService.getClient(_request); const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; const data = await client.getFollowRequests(limit); reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account as Entity.Account)))); @@ -675,12 +283,15 @@ export class MastodonApiServerService { } }); - fastify.post('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.acceptFollow()); + + const client = this.clientService.getClient(_request); + const data = await client.acceptFollowRequest(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data); @@ -688,12 +299,15 @@ export class MastodonApiServerService { } }); - fastify.post('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); - reply.send(await account.rejectFollow()); + + const client = this.clientService.getClient(_request); + const data = await client.rejectFollowRequest(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data); @@ -702,227 +316,6 @@ export class MastodonApiServerService { }); //#endregion - //#region Search - fastify.get('/v1/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.SearchV1()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/search', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v2/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.SearchV2()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v2/search', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.getStatusTrends()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends/statuses', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v2/suggestions', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - try { - const { client, me } = await this.getAuthClient(_request); - const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); - reply.send(await search.getSuggestions()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v2/suggestions', data); - reply.code(401).send(data); - } - }); - //#endregion - - //#region Notifications - fastify.get('/v1/notifications', async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.getNotifications()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/notifications', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/notification/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.getNotification()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/notification/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.rmNotification()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - const { client, me } = await this.getAuthClient(_request); - const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); - reply.send(await notify.rmNotifications()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/notifications/clear', data); - reply.code(401).send(data); - } - }); - //#endregion - - //#region Filters - fastify.get('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new ApiFilterMastodon(_request, client); - _request.params.id - ? reply.send(await filter.getFilter()) - : reply.send(await filter.getFilters()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.createFilter()); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/filters', data); - reply.code(401).send(data); - } - }); - - fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.updateFilter()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - - fastify.delete('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const filter = new ApiFilterMastodon(_request, client); - reply.send(await filter.rmFilter()); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } - }); - //#endregion - - //#region Timelines - const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverters, this.logger, this); - - // GET Endpoints - TLEndpoint.getTL(); - TLEndpoint.getHomeTl(); - TLEndpoint.getListTL(); - TLEndpoint.getTagTl(); - TLEndpoint.getConversations(); - TLEndpoint.getList(); - TLEndpoint.getLists(); - TLEndpoint.getListAccounts(); - - // POST Endpoints - TLEndpoint.createList(); - TLEndpoint.addListAccount(); - - // PUT Endpoint - TLEndpoint.updateList(); - - // DELETE Endpoints - TLEndpoint.deleteList(); - TLEndpoint.rmListAccount(); - //#endregion - - //#region Status - const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverters, this.logger, this.authenticateService, this); - - // GET Endpoints - NoteEndpoint.getStatus(); - NoteEndpoint.getStatusSource(); - NoteEndpoint.getContext(); - NoteEndpoint.getHistory(); - NoteEndpoint.getReblogged(); - NoteEndpoint.getFavourites(); - NoteEndpoint.getMedia(); - NoteEndpoint.getPoll(); - - //POST Endpoints - NoteEndpoint.postStatus(); - NoteEndpoint.addFavourite(); - NoteEndpoint.rmFavourite(); - NoteEndpoint.reblogStatus(); - NoteEndpoint.unreblogStatus(); - NoteEndpoint.bookmarkStatus(); - NoteEndpoint.unbookmarkStatus(); - NoteEndpoint.pinStatus(); - NoteEndpoint.unpinStatus(); - NoteEndpoint.reactStatus(); - NoteEndpoint.unreactStatus(); - NoteEndpoint.votePoll(); - - // PUT Endpoint fastify.put<{ Params: { id?: string, @@ -934,28 +327,25 @@ export class MastodonApiServerService { is_sensitive?: string, }, }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const options = { ..._request.body, is_sensitive: toBoolean(_request.body.is_sensitive), }; + const client = this.clientService.getClient(_request); const data = await client.updateMedia(_request.params.id, options); - reply.send(convertAttachment(data.data)); + const response = convertAttachment(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`PUT /v1/media/${_request.params.id}`, data); reply.code(401).send(data); } }); - NoteEndpoint.updateStatus(); - // DELETE Endpoint - NoteEndpoint.deleteStatus(); - //#endregion done(); } } diff --git a/packages/backend/src/server/api/mastodon/MastodonClientService.ts b/packages/backend/src/server/api/mastodon/MastodonClientService.ts new file mode 100644 index 0000000000..82f9b7bfa9 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/MastodonClientService.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { megalodon, MegalodonInterface } from 'megalodon'; +import { Injectable } from '@nestjs/common'; +import { MiLocalUser } from '@/models/User.js'; +import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import type { FastifyRequest } from 'fastify'; + +@Injectable() +export class MastodonClientService { + constructor( + private readonly authenticateService: AuthenticateService, + ) {} + + /** + * Gets the authenticated user and API client for a request. + */ + public async getAuthClient(request: FastifyRequest, accessToken?: string | null): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + + const me = await this.getAuth(request, accessToken); + const client = this.getClient(request, accessToken); + + return { client, me }; + } + + /** + * Gets the authenticated client user for a request. + */ + public async getAuth(request: FastifyRequest, accessToken?: string | null): Promise { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + const [me] = await this.authenticateService.authenticate(accessToken); + return me; + } + + /** + * Creates an authenticated API client for a request. + */ + public getClient(request: FastifyRequest, accessToken?: string | null): MegalodonInterface { + const authorization = request.headers.authorization; + accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); + + // TODO pass agent? + const baseUrl = this.getBaseUrl(request); + const userAgent = request.headers['user-agent']; + return megalodon('misskey', baseUrl, accessToken, userAgent); + } + + /** + * Gets the base URL (origin) of the incoming request + */ + public getBaseUrl(request: FastifyRequest): string { + return `${request.protocol}://${request.host}`; + } +} + +/** + * Extracts the first access token from an authorization header + * Returns null if none were found. + */ +function getAccessToken(authorization: string | undefined): string | null { + const accessTokenArr = authorization?.split(' ') ?? [null]; + return accessTokenArr[accessTokenArr.length - 1]; +} diff --git a/packages/backend/src/server/api/mastodon/timelineArgs.ts b/packages/backend/src/server/api/mastodon/argsUtils.ts similarity index 100% rename from packages/backend/src/server/api/mastodon/timelineArgs.ts rename to packages/backend/src/server/api/mastodon/argsUtils.ts diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index b6ff5bc59a..d1bd92b618 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -68,7 +68,6 @@ export class MastoConverters { private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention { let acct = u.username; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let acctUrl = `https://${u.host || this.config.host}/@${u.username}`; let url: string | null = null; if (u.host) { @@ -161,7 +160,6 @@ export class MastoConverters { }); const fqn = `${user.username}@${user.host ?? this.config.hostname}`; let acct = user.username; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let acctUrl = `https://${user.host || this.config.host}/@${user.username}`; const acctUri = `https://${this.config.host}/users/${user.id}`; if (user.host) { @@ -265,7 +263,6 @@ export class MastoConverters { }); // This must mirror the usual isQuote / isPureRenote logic used elsewhere. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId); const renote: Promise | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null; diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts deleted file mode 100644 index 085314059b..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ApiAuthMastodon } from './endpoints/auth.js'; -import { ApiAccountMastodon } from './endpoints/account.js'; -import { ApiSearchMastodon } from './endpoints/search.js'; -import { ApiNotifyMastodon } from './endpoints/notifications.js'; -import { ApiFilterMastodon } from './endpoints/filter.js'; -import { ApiTimelineMastodon } from './endpoints/timeline.js'; -import { ApiStatusMastodon } from './endpoints/status.js'; - -export { - ApiAccountMastodon, - ApiAuthMastodon, - ApiSearchMastodon, - ApiNotifyMastodon, - ApiFilterMastodon, - ApiTimelineMastodon, - ApiStatusMastodon, -}; diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 79cdddcb9e..a5d7d89f7d 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -3,14 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; -import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; -import { MiLocalUser } from '@/models/User.js'; -import { MastoConverters, convertRelationship } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { Inject, Injectable } from '@nestjs/common'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '@/server/api/mastodon/argsUtils.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { DI } from '@/di-symbols.js'; +import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; +import { MastoConverters, convertRelationship, convertFeaturedTag, convertList } from '../converters.js'; +import type multer from 'fastify-multer'; +import type { FastifyInstance } from 'fastify'; -export interface ApiAccountMastodonRoute { +interface ApiAccountMastodonRoute { Params: { id?: string }, Querystring: TimelineArgs & { acct?: string }, Body: { notifications?: boolean } @@ -19,133 +23,375 @@ export interface ApiAccountMastodonRoute { @Injectable() export class ApiAccountMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, + @Inject(DI.userProfilesRepository) + private readonly userProfilesRepository: UserProfilesRepository, + + @Inject(DI.accessTokensRepository) + private readonly accessTokensRepository: AccessTokensRepository, + + private readonly clientService: MastodonClientService, private readonly mastoConverters: MastoConverters, + private readonly logger: MastodonLogger, + private readonly driveService: DriveService, ) {} - public async verifyCredentials() { - const data = await this.client.verifyAccountCredentials(); - const acct = await this.mastoConverters.convertAccount(data.data); - return Object.assign({}, acct, { - source: { - note: acct.note, - fields: acct.fields, - privacy: '', - sensitive: false, - language: '', + public register(fastify: FastifyInstance, upload: ReturnType): void { + fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { + try { + const client = await this.clientService.getClient(_request); + const data = await client.verifyAccountCredentials(); + const acct = await this.mastoConverters.convertAccount(data.data); + const response = Object.assign({}, acct, { + source: { + // TODO move these into the convertAccount logic directly + note: acct.note, + fields: acct.fields, + privacy: '', + sensitive: false, + language: '', + }, + }); + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/verify_credentials', data); + reply.code(401).send(data); + } + }); + + fastify.patch<{ + Body: { + discoverable?: string, + bot?: string, + display_name?: string, + note?: string, + avatar?: string, + header?: string, + locked?: string, + source?: { + privacy?: string, + sensitive?: string, + language?: string, + }, + fields_attributes?: { + name: string, + value: string, + }[], }, + }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { + const accessTokens = _request.headers.authorization; + try { + const client = this.clientService.getClient(_request); + // Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. + if (_request.files.length > 0 && accessTokens) { + const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const avatar = (_request.files as any).find((obj: any) => { + return obj.fieldname === 'avatar'; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const header = (_request.files as any).find((obj: any) => { + return obj.fieldname === 'header'; + }); + + if (tokeninfo && avatar) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: avatar.path, + name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).avatar = upload.id; + } + } else if (tokeninfo && header) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: header.path, + name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).header = upload.id; + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((_request.body as any).fields_attributes) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fields = (_request.body as any).fields_attributes.map((field: any) => { + if (!(field.name.trim() === '' && field.value.trim() === '')) { + if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); + if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); + } + return { + ...field, + }; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); + } + + const options = { + ..._request.body, + discoverable: toBoolean(_request.body.discoverable), + bot: toBoolean(_request.body.bot), + locked: toBoolean(_request.body.locked), + source: _request.body.source ? { + ..._request.body.source, + sensitive: toBoolean(_request.body.source.sensitive), + } : undefined, + }; + const data = await client.updateCredentials(options); + reply.send(await this.mastoConverters.convertAccount(data.data)); + } catch (e) { + const data = getErrorData(e); + this.logger.error('PATCH /v1/accounts/update_credentials', data); + reply.code(401).send(data); + } + }); + + fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { + try { + if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); + + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.acct, { type: 'accounts' }); + const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); + data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; + const response = await this.mastoConverters.convertAccount(data.data.accounts[0]); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/lookup', data); + reply.code(401).send(data); + } + }); + + fastify.get('/v1/accounts/relationships', async (_request, reply) => { + try { + let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; + if (typeof ids === 'string') { + ids = [ids]; + } + + const client = this.clientService.getClient(_request); + const data = await client.getRelationships(ids); + const response = data.data.map(relationship => convertRelationship(relationship)); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/relationships', data); + reply.code(401).send(data); + } + }); + + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccount(_request.params.id); + const account = await this.mastoConverters.convertAccount(data.data); + + reply.send(account); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); + + fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getAccountStatuses(_request.params.id, parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data); + reply.code(401).send(data); + } + }); + + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getFeaturedTags(); + const response = data.data.map((tag) => convertFeaturedTag(tag)); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data); + reply.code(401).send(data); + } + }); + + fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccountFollowers( + _request.params.id, + parseTimelineArgs(_request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data); + reply.code(401).send(data); + } + }); + + fastify.get('/v1/accounts/:id/following', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccountFollowing( + _request.params.id, + parseTimelineArgs(_request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data); + reply.code(401).send(data); + } + }); + + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.getAccountLists(_request.params.id); + const response = data.data.map((list) => convertList(list)); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.followAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = true; + + reply.send(acct); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unfollowAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = false; + + reply.send(acct); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.blockAccount(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unblockAccount(_request.params.id); + const response = convertRelationship(data.data); + + return reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.muteAccount( + _request.params.id, + _request.body.notifications ?? true, + ); + const response = convertRelationship(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.unmuteAccount(_request.params.id); + const response = convertRelationship(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data); + reply.code(401).send(data); + } }); } - - public async lookup() { - if (!this.request.query.acct) throw new Error('Missing required property "acct"'); - const data = await this.client.search(this.request.query.acct, { type: 'accounts' }); - return this.mastoConverters.convertAccount(data.data.accounts[0]); - } - - public async getRelationships(reqIds: string[]) { - const data = await this.client.getRelationships(reqIds); - return data.data.map(relationship => convertRelationship(relationship)); - } - - public async getStatuses() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountStatuses(this.request.params.id, parseTimelineArgs(this.request.query)); - return await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, this.me))); - } - - public async getFollowers() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountFollowers( - this.request.params.id, - parseTimelineArgs(this.request.query), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - } - - public async getFollowing() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getAccountFollowing( - this.request.params.id, - parseTimelineArgs(this.request.query), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - } - - public async addFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.followAccount(this.request.params.id); - const acct = convertRelationship(data.data); - acct.following = true; - return acct; - } - - public async rmFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unfollowAccount(this.request.params.id); - const acct = convertRelationship(data.data); - acct.following = false; - return acct; - } - - public async addBlock() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.blockAccount(this.request.params.id); - return convertRelationship(data.data); - } - - public async rmBlock() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unblockAccount(this.request.params.id); - return convertRelationship(data.data); - } - - public async addMute() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.muteAccount( - this.request.params.id, - this.request.body.notifications ?? true, - ); - return convertRelationship(data.data); - } - - public async rmMute() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.unmuteAccount(this.request.params.id); - return convertRelationship(data.data); - } - - public async getBookmarks() { - const data = await this.client.getBookmarks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); - } - - public async getFavourites() { - const data = await this.client.getFavourites(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); - } - - public async getMutes() { - const data = await this.client.getMutes(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - } - - public async getBlocks() { - const data = await this.client.getBlocks(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - } - - public async acceptFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.acceptFollowRequest(this.request.params.id); - return convertRelationship(data.data); - } - - public async rejectFollow() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.rejectFollowRequest(this.request.params.id); - return convertRelationship(data.data); - } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts new file mode 100644 index 0000000000..17b9ba889d --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import type { FastifyInstance } from 'fastify'; +import type multer from 'fastify-multer'; + +const readScope = [ + 'read:account', + 'read:drive', + 'read:blocks', + 'read:favorites', + 'read:following', + 'read:messaging', + 'read:mutes', + 'read:notifications', + 'read:reactions', + 'read:pages', + 'read:page-likes', + 'read:user-groups', + 'read:channels', + 'read:gallery', + 'read:gallery-likes', +]; + +const writeScope = [ + 'write:account', + 'write:drive', + 'write:blocks', + 'write:favorites', + 'write:following', + 'write:messaging', + 'write:mutes', + 'write:notes', + 'write:notifications', + 'write:reactions', + 'write:votes', + 'write:pages', + 'write:page-likes', + 'write:user-groups', + 'write:channels', + 'write:gallery', + 'write:gallery-likes', +]; + +export interface AuthPayload { + scopes?: string | string[], + redirect_uris?: string, + client_name?: string, + website?: string, +} + +// Not entirely right, but it gets TypeScript to work so *shrug* +type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; + +@Injectable() +export class ApiAppsMastodon { + constructor( + private readonly clientService: MastodonClientService, + private readonly logger: MastodonLogger, + ) {} + + public register(fastify: FastifyInstance, upload: ReturnType): void { + fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + const body = _request.body ?? _request.query; + if (!body.scopes) return reply.code(400).send({ error: 'Missing required payload "scopes"' }); + if (!body.redirect_uris) return reply.code(400).send({ error: 'Missing required payload "redirect_uris"' }); + if (!body.client_name) return reply.code(400).send({ error: 'Missing required payload "client_name"' }); + + let scope = body.scopes; + if (typeof scope === 'string') { + scope = scope.split(/[ +]/g); + } + + const pushScope = new Set(); + for (const s of scope) { + if (s.match(/^read/)) { + for (const r of readScope) { + pushScope.add(r); + } + } + if (s.match(/^write/)) { + for (const r of writeScope) { + pushScope.add(r); + } + } + } + + const red = body.redirect_uris; + + const client = this.clientService.getClient(_request); + const appData = await client.registerApp(body.client_name, { + scopes: Array.from(pushScope), + redirect_uris: red, + website: body.website, + }); + + const response = { + id: Math.floor(Math.random() * 100).toString(), + name: appData.name, + website: body.website, + redirect_uri: red, + client_id: Buffer.from(appData.url || '').toString('base64'), + client_secret: appData.clientSecret, + }; + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/apps', data); + reply.code(401).send(data); + } + }); + } +} + diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts deleted file mode 100644 index b58cc902da..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; - -const readScope = [ - 'read:account', - 'read:drive', - 'read:blocks', - 'read:favorites', - 'read:following', - 'read:messaging', - 'read:mutes', - 'read:notifications', - 'read:reactions', - 'read:pages', - 'read:page-likes', - 'read:user-groups', - 'read:channels', - 'read:gallery', - 'read:gallery-likes', -]; - -const writeScope = [ - 'write:account', - 'write:drive', - 'write:blocks', - 'write:favorites', - 'write:following', - 'write:messaging', - 'write:mutes', - 'write:notes', - 'write:notifications', - 'write:reactions', - 'write:votes', - 'write:pages', - 'write:page-likes', - 'write:user-groups', - 'write:channels', - 'write:gallery', - 'write:gallery-likes', -]; - -export interface AuthPayload { - scopes?: string | string[], - redirect_uris?: string, - client_name?: string, - website?: string, -} - -// Not entirely right, but it gets TypeScript to work so *shrug* -export type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; - -export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { - const body = request.body ?? request.query; - if (!body.scopes) throw new Error('Missing required payload "scopes"'); - if (!body.redirect_uris) throw new Error('Missing required payload "redirect_uris"'); - if (!body.client_name) throw new Error('Missing required payload "client_name"'); - - let scope = body.scopes; - if (typeof scope === 'string') { - scope = scope.split(/[ +]/g); - } - - const pushScope = new Set(); - for (const s of scope) { - if (s.match(/^read/)) { - for (const r of readScope) { - pushScope.add(r); - } - } - if (s.match(/^write/)) { - for (const r of writeScope) { - pushScope.add(r); - } - } - } - - const red = body.redirect_uris; - const appData = await client.registerApp(body.client_name, { - scopes: Array.from(pushScope), - redirect_uris: red, - website: body.website, - }); - - return { - id: Math.floor(Math.random() * 100).toString(), - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url || '').toString('base64'), // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - client_secret: appData.clientSecret, - }; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 382f0a8f1f..10353ff7af 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -3,12 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { toBoolean } from '@/server/api/mastodon/timelineArgs.js'; +import { Injectable } from '@nestjs/common'; +import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { convertFilter } from '../converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import type multer from 'fastify-multer'; -export interface ApiFilterMastodonRoute { +interface ApiFilterMastodonRoute { Params: { id?: string, }, @@ -21,55 +24,109 @@ export interface ApiFilterMastodonRoute { } } +@Injectable() export class ApiFilterMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, + private readonly clientService: MastodonClientService, + private readonly logger: MastodonLogger, ) {} - public async getFilters() { - const data = await this.client.getFilters(); - return data.data.map((filter) => convertFilter(filter)); - } + public register(fastify: FastifyInstance, upload: ReturnType): void { + fastify.get('/v1/filters', async (_request, reply) => { + try { + const client = this.clientService.getClient(_request); - public async getFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getFilter(this.request.params.id); - return convertFilter(data.data); - } + const data = await client.getFilters(); + const response = data.data.map((filter) => convertFilter(filter)); - public async createFilter() { - if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); - if (!this.request.body.context) throw new Error('Missing required payload "context"'); - const options = { - phrase: this.request.body.phrase, - context: this.request.body.context, - irreversible: toBoolean(this.request.body.irreversible), - whole_word: toBoolean(this.request.body.whole_word), - expires_in: this.request.body.expires_in, - }; - const data = await this.client.createFilter(this.request.body.phrase, this.request.body.context, options); - return convertFilter(data.data); - } + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/filters', data); + reply.code(401).send(data); + } + }); - public async updateFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); - if (!this.request.body.context) throw new Error('Missing required payload "context"'); - const options = { - phrase: this.request.body.phrase, - context: this.request.body.context, - irreversible: toBoolean(this.request.body.irreversible), - whole_word: toBoolean(this.request.body.whole_word), - expires_in: this.request.body.expires_in, - }; - const data = await this.client.updateFilter(this.request.params.id, this.request.body.phrase, this.request.body.context, options); - return convertFilter(data.data); - } + fastify.get('/v1/filters/:id', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - public async rmFilter() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.deleteFilter(this.request.params.id); - return data.data; + const client = this.clientService.getClient(_request); + const data = await client.getFilter(_request.params.id); + const response = convertFilter(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); + + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; + + const client = this.clientService.getClient(_request); + const data = await client.createFilter(_request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/filters', data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); + + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; + + const client = this.clientService.getClient(_request); + const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); + + fastify.delete('/v1/filters/:id', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.deleteFilter(_request.params.id); + + reply.send(data.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts new file mode 100644 index 0000000000..ffffb5e537 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; +import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import type { FastifyInstance } from 'fastify'; + +// TODO rename to ApiInstanceMastodon + +@Injectable() +export class ApiInstanceMastodon { + constructor( + @Inject(DI.meta) + private readonly meta: MiMeta, + + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, + + @Inject(DI.config) + private readonly config: Config, + + private readonly mastoConverters: MastoConverters, + private readonly logger: MastodonLogger, + private readonly clientService: MastodonClientService, + ) {} + + public register(fastify: FastifyInstance): void { + fastify.get('/v1/instance', async (_request, reply) => { + try { + const client = this.clientService.getClient(_request); + const data = await client.getInstance(); + const instance = data.data; + const admin = await this.usersRepository.findOne({ + where: { + host: IsNull(), + isRoot: true, + isDeleted: false, + isSuspended: false, + }, + order: { id: 'ASC' }, + }); + const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); + + const response = { + uri: this.config.url, + title: this.meta.name || 'Sharkey', + short_description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + email: instance.email || '', + version: `3.0.0 (compatible; Sharkey ${this.config.version})`, + urls: instance.urls, + stats: { + user_count: instance.stats.user_count, + status_count: instance.stats.status_count, + domain_count: instance.stats.domain_count, + }, + thumbnail: this.meta.backgroundImageUrl || '/static-assets/transparent.png', + languages: this.meta.langs, + registrations: !this.meta.disableRegistration || instance.registrations, + approval_required: this.meta.approvalRequiredForSignup, + invites_enabled: instance.registrations, + configuration: { + accounts: { + max_featured_tags: 20, + }, + statuses: { + max_characters: this.config.maxNoteLength, + max_media_attachments: 16, + characters_reserved_per_url: instance.uri.length, + }, + media_attachments: { + supported_mime_types: FILE_TYPE_BROWSERSAFE, + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_rate_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 10, + max_characters_per_option: 150, + min_expiration: 50, + max_expiration: 2629746, + }, + reactions: { + max_reactions: 1, + }, + }, + contact_account: contact, + rules: [], + }; + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/instance', data); + reply.code(401).send(data); + } + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts deleted file mode 100644 index 48a56138cf..0000000000 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Entity } from 'megalodon'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import type { Config } from '@/config.js'; -import type { MiMeta } from '@/models/Meta.js'; - -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ -export async function getInstance( - response: Entity.Instance, - contact: Entity.Account, - config: Config, - meta: MiMeta, -) { - return { - uri: config.url, - title: meta.name || 'Sharkey', - short_description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - email: response.email || '', - version: `3.0.0 (compatible; Sharkey ${config.version})`, - urls: response.urls, - stats: { - user_count: response.stats.user_count, - status_count: response.stats.status_count, - domain_count: response.stats.domain_count, - }, - thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png', - languages: meta.langs, - registrations: !meta.disableRegistration || response.registrations, - approval_required: meta.approvalRequiredForSignup, - invites_enabled: response.registrations, - configuration: { - accounts: { - max_featured_tags: 20, - }, - statuses: { - max_characters: config.maxNoteLength, - max_media_attachments: 16, - characters_reserved_per_url: response.uri.length, - }, - media_attachments: { - supported_mime_types: FILE_TYPE_BROWSERSAFE, - image_size_limit: 10485760, - image_matrix_limit: 16777216, - video_size_limit: 41943040, - video_frame_rate_limit: 60, - video_matrix_limit: 2304000, - }, - polls: { - max_options: 10, - max_characters_per_option: 150, - min_expiration: 50, - max_expiration: 2629746, - }, - reactions: { - max_reactions: 1, - }, - }, - contact_account: contact, - rules: [], - }; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 14eee8565a..0dba247e5f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -3,56 +3,95 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; -import { MiLocalUser } from '@/models/User.js'; +import { Injectable } from '@nestjs/common'; +import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '../MastodonClientService.js'; +import type { FastifyInstance } from 'fastify'; +import type multer from 'fastify-multer'; -export interface ApiNotifyMastodonRoute { +interface ApiNotifyMastodonRoute { Params: { id?: string, }, Querystring: TimelineArgs, } -export class ApiNotifyMastodon { +@Injectable() +export class ApiNotificationsMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, private readonly mastoConverters: MastoConverters, + private readonly clientService: MastodonClientService, + private readonly logger: MastodonLogger, ) {} - public async getNotifications() { - const data = await this.client.getNotifications(parseTimelineArgs(this.request.query)); - return Promise.all(data.data.map(async n => { - const converted = await this.mastoConverters.convertNotification(n, this.me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; + public register(fastify: FastifyInstance, upload: ReturnType): void { + fastify.get('/v1/notifications', async (_request, reply) => { + try { + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getNotifications(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map(async n => { + const converted = await this.mastoConverters.convertNotification(n, me); + if (converted.type === 'reaction') { + converted.type = 'favourite'; + } + return converted; + })); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/notifications', data); + reply.code(401).send(data); } - return converted; - })); - } + }); - public async getNotification() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.getNotification(this.request.params.id); - const converted = await this.mastoConverters.convertNotification(data.data, this.me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; - } - return converted; - } + fastify.get('/v1/notification/:id', async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - public async rmNotification() { - if (!this.request.params.id) throw new Error('Missing required parameter "id"'); - const data = await this.client.dismissNotification(this.request.params.id); - return data.data; - } + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getNotification(_request.params.id); + const converted = await this.mastoConverters.convertNotification(data.data, me); + if (converted.type === 'reaction') { + converted.type = 'favourite'; + } - public async rmNotifications() { - const data = await this.client.dismissNotifications(); - return data.data; + reply.send(converted); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/notification/${_request.params.id}`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); + const data = await client.dismissNotification(_request.params.id); + + reply.send(data.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data); + reply.code(401).send(data); + } + }); + + fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { + try { + const client = this.clientService.getClient(_request); + const data = await client.dismissNotifications(); + + reply.send(data.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/notifications/clear', data); + reply.code(401).send(data); + } + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 4850b4652f..3b1c984c3e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -3,92 +3,128 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { MiLocalUser } from '@/models/User.js'; +import { Injectable } from '@nestjs/common'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastoConverters } from '../converters.js'; -import { parseTimelineArgs, TimelineArgs } from '../timelineArgs.js'; +import { parseTimelineArgs, TimelineArgs } from '../argsUtils.js'; import Account = Entity.Account; import Status = Entity.Status; -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; +import type { FastifyInstance } from 'fastify'; -export interface ApiSearchMastodonRoute { +interface ApiSearchMastodonRoute { Querystring: TimelineArgs & { type?: 'accounts' | 'hashtags' | 'statuses'; q?: string; } } +@Injectable() export class ApiSearchMastodon { constructor( - private readonly request: FastifyRequest, - private readonly client: MegalodonInterface, - private readonly me: MiLocalUser | null, - private readonly BASE_URL: string, private readonly mastoConverters: MastoConverters, + private readonly clientService: MastodonClientService, + private readonly logger: MastodonLogger, ) {} - public async SearchV1() { - if (!this.request.query.q) throw new Error('Missing required property "q"'); - const query = parseTimelineArgs(this.request.query); - const data = await this.client.search(this.request.query.q, { type: this.request.query.type, ...query }); - return data.data; - } + public register(fastify: FastifyInstance): void { + fastify.get('/v1/search', async (_request, reply) => { + try { + if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); - public async SearchV2() { - if (!this.request.query.q) throw new Error('Missing required property "q"'); - const query = parseTimelineArgs(this.request.query); - const type = this.request.query.type; - const acct = !type || type === 'accounts' ? await this.client.search(this.request.query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await this.client.search(this.request.query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await this.client.search(this.request.query.q, { type: 'hashtags', ...query }) : null; - return { - accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, this.me)) ?? []), - hashtags: tags?.data.hashtags ?? [], - }; - } + const query = parseTimelineArgs(_request.query); + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); - public async getStatusTrends() { - const data = await fetch(`${this.BASE_URL}/api/notes/featured`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - i: this.request.headers.authorization?.replace('Bearer ', ''), - }), - }) - .then(res => res.json() as Promise) - .then(data => data.map(status => this.mastoConverters.convertStatus(status, this.me))); - return Promise.all(data); - } + reply.send(data.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/search', data); + reply.code(401).send(data); + } + }); - public async getSuggestions() { - const data = await fetch(`${this.BASE_URL}/api/users`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - i: this.request.headers.authorization?.replace('Bearer ', ''), - limit: parseTimelineArgs(this.request.query).limit ?? 20, - origin: 'local', - sort: '+follower', - state: 'alive', - }), - }) - .then(res => res.json() as Promise) - .then(data => data.map((entry => ({ - source: 'global', - account: entry, - })))); - return Promise.all(data.map(async suggestion => { - suggestion.account = await this.mastoConverters.convertAccount(suggestion.account); - return suggestion; - })); + fastify.get('/v2/search', async (_request, reply) => { + try { + if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + + const query = parseTimelineArgs(_request.query); + const type = _request.query.type; + const { client, me } = await this.clientService.getAuthClient(_request); + const acct = !type || type === 'accounts' ? await client.search(_request.query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await client.search(_request.query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await client.search(_request.query.q, { type: 'hashtags', ...query }) : null; + const response = { + accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, me)) ?? []), + hashtags: tags?.data.hashtags ?? [], + }; + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v2/search', data); + reply.code(401).send(data); + } + }); + + fastify.get('/v1/trends/statuses', async (_request, reply) => { + try { + const baseUrl = this.clientService.getBaseUrl(_request); + const res = await fetch(`${baseUrl}/api/notes/featured`, + { + method: 'POST', + headers: { + ..._request.headers as HeadersInit, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: '{}', + }); + const data = await res.json() as Status[]; + const me = await this.clientService.getAuth(_request); + const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/trends/statuses', data); + reply.code(401).send(data); + } + }); + + fastify.get('/v2/suggestions', async (_request, reply) => { + try { + const baseUrl = this.clientService.getBaseUrl(_request); + const res = await fetch(`${baseUrl}/api/users`, + { + method: 'POST', + headers: { + ..._request.headers as HeadersInit, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + limit: parseTimelineArgs(_request.query).limit ?? 20, + origin: 'local', + sort: '+follower', + state: 'alive', + }), + }); + const data = await res.json() as Account[]; + const response = await Promise.all(data.map(async entry => { + return { + source: 'global', + account: await this.mastoConverters.convertAccount(entry), + }; + })); + + reply.send(response); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v2/suggestions', data); + reply.code(401).send(data); + } + }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 4c49a6a293..ba61918b75 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -4,12 +4,12 @@ */ import querystring, { ParsedUrlQueryInput } from 'querystring'; +import { Injectable } from '@nestjs/common'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; -import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/timelineArgs.js'; -import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; -import { getAccessToken, getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -18,38 +18,38 @@ function normalizeQuery(data: Record) { return querystring.parse(str); } +@Injectable() export class ApiStatusMastodon { constructor( - private readonly fastify: FastifyInstance, private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, - private readonly authenticateService: AuthenticateService, - private readonly mastodon: MastodonApiServerService, + private readonly clientService: MastodonClientService, ) {} - public getStatus() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { + public register(fastify: FastifyInstance): void { + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}`, data); reply.code(_request.is404 ? 404 : 401).send(data); } }); - } - public getStatusSource() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getStatusSource(_request.params.id); + reply.send(data.data); } catch (e) { const data = getErrorData(e); @@ -57,31 +57,32 @@ export class ApiStatusMastodon { reply.code(_request.is404 ? 404 : 401).send(data); } }); - } - public getContext() { - this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); const ancestors = await Promise.all(data.ancestors.map(async status => await this.mastoConverters.convertStatus(status, me))); const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status, me))); - reply.send({ ancestors, descendants }); + const response = { ancestors, descendants }; + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/context`, data); reply.code(_request.is404 ? 404 : 401).send(data); } }); - } - public getHistory() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const [user] = await this.authenticateService.authenticate(getAccessToken(_request.headers.authorization)); + + const user = await this.clientService.getAuth(_request); const edits = await this.mastoConverters.getEdits(_request.params.id, user); + reply.send(edits); } catch (e) { const data = getErrorData(e); @@ -89,96 +90,89 @@ export class ApiStatusMastodon { reply.code(401).send(data); } }); - } - public getReblogged() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getStatusRebloggedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/reblogged_by`, data); reply.code(401).send(data); } }); - } - public getFavourites() { - this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getStatusFavouritedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/favourited_by`, data); reply.code(401).send(data); } }); - } - public getMedia() { - this.fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getMedia(_request.params.id); - reply.send(convertAttachment(data.data)); + const response = convertAttachment(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/media/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public getPoll() { - this.fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.getPoll(_request.params.id); - reply.send(convertPoll(data.data)); + const response = convertPoll(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/polls/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public votePoll() { - this.fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' }); + + const client = this.clientService.getClient(_request); const data = await client.votePoll(_request.params.id, _request.body.choices); - reply.send(convertPoll(data.data)); + const response = convertPoll(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/polls/${_request.params.id}/votes`, data); reply.code(401).send(data); } }); - } - public postStatus() { - this.fastify.post<{ + fastify.post<{ Body: { media_ids?: string[], poll?: { @@ -203,7 +197,7 @@ export class ApiStatusMastodon { }>('/v1/statuses', async (_request, reply) => { let body = _request.body; try { - const { client, me } = await this.mastodon.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) ) { body = normalizeQuery(body); @@ -248,17 +242,17 @@ export class ApiStatusMastodon { }; const data = await client.postStatus(text, options); - reply.send(await this.mastoConverters.convertStatus(data.data as Entity.Status, me)); + const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('POST /v1/statuses', data); reply.code(401).send(data); } }); - } - public updateStatus() { - this.fastify.put<{ + fastify.put<{ Params: { id: string }, Body: { status?: string, @@ -274,7 +268,7 @@ export class ApiStatusMastodon { } }>('/v1/statuses/:id', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); const body = _request.body; if (!body.media_ids || !body.media_ids.length) { @@ -293,175 +287,184 @@ export class ApiStatusMastodon { }; const data = await client.editStatus(_request.params.id, options); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public addFavourite() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/favorite`, data); reply.code(401).send(data); } }); - } - public rmFavourite() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/statuses/${_request.params.id}/unfavorite`, data); reply.code(401).send(data); } }); - } - public reblogStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.reblogStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/reblog`, data); reply.code(401).send(data); } }); - } - public unreblogStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unreblogStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unreblog`, data); reply.code(401).send(data); } }); - } - public bookmarkStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.bookmarkStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/bookmark`, data); reply.code(401).send(data); } }); - } - public unbookmarkStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unbookmarkStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unbookmark`, data); reply.code(401).send(data); } }); - } - - public pinStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.pinStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/pin`, data); reply.code(401).send(data); } }); - } - public unpinStatus() { - this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unpinStatus(_request.params.id); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unpin`, data); reply.code(401).send(data); } }); - } - public reactStatus() { - this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { + fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/react/${_request.params.name}`, data); reply.code(401).send(data); } }); - } - public unreactStatus() { - this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { + fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); + + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoConverters.convertStatus(data.data, me)); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`POST /v1/statuses/${_request.params.id}/unreact/${_request.params.name}`, data); reply.code(401).send(data); } }); - } - public deleteStatus() { - this.fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const client = this.clientService.getClient(_request); const data = await client.deleteStatus(_request.params.id); + reply.send(data.data); } catch (e) { const data = getErrorData(e); diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 1a732d62de..864fdc7691 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -3,87 +3,97 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { Injectable } from '@nestjs/common'; import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertList, MastoConverters } from '../converters.js'; -import { getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; -import { parseTimelineArgs, TimelineArgs, toBoolean } from '../timelineArgs.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; +@Injectable() export class ApiTimelineMastodon { constructor( - private readonly fastify: FastifyInstance, + private readonly clientService: MastodonClientService, private readonly mastoConverters: MastoConverters, private readonly logger: MastodonLogger, - private readonly mastodon: MastodonApiServerService, ) {} - public getTL() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { + public register(fastify: FastifyInstance): void { + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); + + const query = parseTimelineArgs(_request.query); const data = toBoolean(_request.query.local) - ? await client.getLocalTimeline(parseTimelineArgs(_request.query)) - : await client.getPublicTimeline(parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + ? await client.getLocalTimeline(query) + : await client.getPublicTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/timelines/public', data); reply.code(401).send(data); } }); - } - public getHomeTl() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getHomeTimeline(parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getHomeTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/timelines/home', data); reply.code(401).send(data); } }); - } - public getTagTl() { - this.fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { + fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { try { if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getTagTimeline(_request.params.hashtag, parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getTagTimeline(_request.params.hashtag, query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/timelines/tag/${_request.params.hashtag}`, data); reply.code(401).send(data); } }); - } - public getListTL() { - this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getListTimeline(_request.params.id, parseTimelineArgs(_request.query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getListTimeline(_request.params.id, query); + const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/timelines/list/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public getConversations() { - this.fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { + fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { try { - const { client, me } = await this.mastodon.getAuthClient(_request); - const data = await client.getConversationTimeline(parseTimelineArgs(_request.query)); - const conversations = await Promise.all(data.data.map(async (conversation: Entity.Conversation) => await this.mastoConverters.convertConversation(conversation, me))); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getConversationTimeline(query); + const conversations = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); + reply.send(conversations); } catch (e) { const data = getErrorData(e); @@ -91,50 +101,45 @@ export class ApiTimelineMastodon { reply.code(401).send(data); } }); - } - public getList() { - this.fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { + fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.getList(_request.params.id); - reply.send(convertList(data.data)); + const response = convertList(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`GET /v1/lists/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public getLists() { - this.fastify.get('/v1/lists', async (_request, reply) => { + fastify.get('/v1/lists', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + const client = this.clientService.getClient(_request); const data = await client.getLists(); - reply.send(data.data.map((list: Entity.List) => convertList(list))); + const response = data.data.map((list: Entity.List) => convertList(list)); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('GET /v1/lists', data); reply.code(401).send(data); } }); - } - public getListAccounts() { - this.fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { + fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.getAccountsInList(_request.params.id, _request.query); const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + reply.send(accounts); } catch (e) { const data = getErrorData(e); @@ -142,17 +147,15 @@ export class ApiTimelineMastodon { reply.code(401).send(data); } }); - } - public addListAccount() { - this.fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { + fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); + reply.send(data.data); } catch (e) { const data = getErrorData(e); @@ -160,17 +163,15 @@ export class ApiTimelineMastodon { reply.code(401).send(data); } }); - } - public rmListAccount() { - this.fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { + fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); + reply.send(data.data); } catch (e) { const data = getErrorData(e); @@ -178,51 +179,47 @@ export class ApiTimelineMastodon { reply.code(401).send(data); } }); - } - public createList() { - this.fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { + fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { try { if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.createList(_request.body.title); - reply.send(convertList(data.data)); + const response = convertList(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error('POST /v1/lists', data); reply.code(401).send(data); } }); - } - public updateList() { - this.fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { + fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); const data = await client.updateList(_request.params.id, _request.body.title); - reply.send(convertList(data.data)); + const response = convertList(data.data); + + reply.send(response); } catch (e) { const data = getErrorData(e); this.logger.error(`PUT /v1/lists/${_request.params.id}`, data); reply.code(401).send(data); } }); - } - public deleteList() { - this.fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { + fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { try { if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const BASE_URL = `${_request.protocol}://${_request.host}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + + const client = this.clientService.getClient(_request); await client.deleteList(_request.params.id); + reply.send({}); } catch (e) { const data = getErrorData(e); diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 6598aa9891..86d903f223 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -5,7 +5,6 @@ import querystring from 'querystring'; import { Inject, Injectable } from '@nestjs/common'; -import megalodon, { MegalodonInterface } from 'megalodon'; import { v4 as uuid } from 'uuid'; /* import { kinds } from '@/misc/api-permissions.js'; import type { Config } from '@/config.js'; @@ -14,6 +13,8 @@ import multer from 'fastify-multer'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; +import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { getErrorData } from '@/server/api/mastodon/MastodonLogger.js'; import type { FastifyInstance } from 'fastify'; const kinds = [ @@ -51,19 +52,13 @@ const kinds = [ 'write:gallery-likes', ]; -function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessTokenArr = authorization?.split(' ') ?? [null]; - const accessToken = accessTokenArr[accessTokenArr.length - 1]; - const generator = (megalodon as any).default; - const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface; - return client; -} - @Injectable() export class OAuth2ProviderService { constructor( @Inject(DI.config) private config: Config, + + private readonly mastodonClientService: MastodonClientService, ) { } // https://datatracker.ietf.org/doc/html/rfc8414.html @@ -122,8 +117,8 @@ export class OAuth2ProviderService { try { const parsed = querystring.parse(body); done(null, parsed); - } catch (e: any) { - done(e); + } catch (e: unknown) { + done(e instanceof Error ? e : new Error(String(e))); } }); payload.on('error', done); @@ -131,74 +126,53 @@ export class OAuth2ProviderService { fastify.register(multer.contentParser); - fastify.get('/authorize', async (request, reply) => { - const query: any = request.query; - let param = "mastodon=true"; - if (query.state) param += `&state=${query.state}`; - if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; - const client = query.client_id ? query.client_id : ""; - reply.redirect( - `${Buffer.from(client.toString(), 'base64').toString()}?${param}`, - ); - }); + for (const url of ['/authorize', '/authorize/']) { + fastify.get<{ Querystring: Record }>(url, async (request, reply) => { + if (typeof(request.query.client_id) !== 'string') return reply.code(400).send({ error: 'Missing required query "client_id"' }); - fastify.get('/authorize/', async (request, reply) => { - const query: any = request.query; - let param = "mastodon=true"; - if (query.state) param += `&state=${query.state}`; - if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`; - const client = query.client_id ? query.client_id : ""; - reply.redirect( - `${Buffer.from(client.toString(), 'base64').toString()}?${param}`, - ); - }); + const redirectUri = new URL(Buffer.from(request.query.client_id, 'base64').toString()); + redirectUri.searchParams.set('mastodon', 'true'); + if (request.query.state) redirectUri.searchParams.set('state', String(request.query.state)); + if (request.query.redirect_uri) redirectUri.searchParams.set('redirect_uri', String(request.query.redirect_uri)); - fastify.post('/token', { preHandler: upload.none() }, async (request, reply) => { - const body: any = request.body || request.query; - if (body.grant_type === "client_credentials") { + reply.redirect(redirectUri.toString()); + }); + } + + fastify.post<{ Body?: Record, Querystring: Record }>('/token', { preHandler: upload.none() }, async (request, reply) => { + const body = request.body ?? request.query; + + if (body.grant_type === 'client_credentials') { const ret = { access_token: uuid(), - token_type: "Bearer", - scope: "read", + token_type: 'Bearer', + scope: 'read', created_at: Math.floor(new Date().getTime() / 1000), }; reply.send(ret); } - let client_id: any = body.client_id; - const BASE_URL = `${request.protocol}://${request.hostname}`; - const client = getClient(BASE_URL, ''); - let token = null; - if (body.code) { - //m = body.code.match(/^([a-zA-Z0-9]{8})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{12})/); - //if (!m.length) { - // ctx.body = { error: "Invalid code" }; - // return; - //} - //token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` - //console.log(body.code, token); - token = body.code; - } - if (client_id instanceof Array) { - client_id = client_id.toString(); - } else if (!client_id) { - client_id = null; - } + try { - const atData = await client.fetchAccessToken( - client_id, - body.client_secret, - token ? token : "", - ); + if (!body.client_secret) return reply.code(400).send({ error: 'Missing required query "client_secret"' }); + + const clientId = body.client_id ? String(body.clientId) : null; + const secret = String(body.client_secret); + const code = body.code ? String(body.code) : ''; + + // TODO fetch the access token directly + const client = this.mastodonClientService.getClient(request); + const atData = await client.fetchAccessToken(clientId, secret, code); + const ret = { access_token: atData.accessToken, - token_type: "Bearer", - scope: body.scope || "read write follow push", + token_type: 'Bearer', + scope: body.scope || 'read write follow push', created_at: Math.floor(new Date().getTime() / 1000), }; reply.send(ret); - } catch (err: any) { - /* console.error(err); */ - reply.code(401).send(err.response.data); + } catch (e: unknown) { + const data = getErrorData(e); + reply.code(401).send(data); } }); } From 03edc334249940582aac3bdff0475d6174aec788 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 20:38:04 -0400 Subject: [PATCH 02/37] fix logger Data type --- packages/backend/src/logger.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index eb2b081220..79623768a8 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -19,7 +19,9 @@ type Context = { type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; export type Data = DataElement | DataElement[]; -export type DataElement = Record | Error | string | null; +export type DataElement = DataObject | Error | string | null; +// https://stackoverflow.com/questions/61148466/typescript-type-that-matches-any-object-but-not-arrays +export type DataObject = Record | (object & { length?: never; }); // eslint-disable-next-line import/no-default-export export default class Logger { From da25595ba306f1767883c9c3949dea446343def5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 20:38:28 -0400 Subject: [PATCH 03/37] de-duplicate mastodon API logging --- .../api/mastodon/MastodonApiServerService.ts | 266 ++++------ .../src/server/api/mastodon/MastodonLogger.ts | 124 ++++- .../server/api/mastodon/endpoints/account.ts | 426 ++++++---------- .../src/server/api/mastodon/endpoints/apps.ts | 90 ++-- .../server/api/mastodon/endpoints/filter.ts | 112 ++--- .../server/api/mastodon/endpoints/instance.ts | 130 +++-- .../api/mastodon/endpoints/notifications.ts | 78 +-- .../server/api/mastodon/endpoints/search.ts | 140 +++--- .../server/api/mastodon/endpoints/status.ts | 473 +++++++----------- .../server/api/mastodon/endpoints/timeline.ts | 217 +++----- 10 files changed, 827 insertions(+), 1229 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 7a4611fd74..17f706e617 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -9,7 +9,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { getErrorData, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; @@ -74,6 +74,15 @@ export class MastodonApiServerService { payload.on('error', done); }); + fastify.setErrorHandler((error, request, reply) => { + const data = getErrorData(error); + const status = getErrorStatus(error); + + this.logger.error(request, data, status); + + reply.code(status).send(data); + }); + fastify.register(multer.contentParser); // External endpoints @@ -87,98 +96,56 @@ export class MastodonApiServerService { this.apiTimelineMastodon.register(fastify); fastify.get('/v1/custom_emojis', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstanceCustomEmojis(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/custom_emojis', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceCustomEmojis(); + reply.send(data.data); }); fastify.get('/v1/announcements', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstanceAnnouncements(); - reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/announcements', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceAnnouncements(); + reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - try { - if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.dismissInstanceAnnouncement(_request.body['id']); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/announcements/${_request.body.id}/dismiss`, data); - reply.code(401).send(data); - } + if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); + const client = this.clientService.getClient(_request); + const data = await client.dismissInstanceAnnouncement(_request.body['id']); + reply.send(data.data); }); fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - try { - const multipartData = await _request.file(); - if (!multipartData) { - reply.code(401).send({ error: 'No image' }); - return; - } - const client = this.clientService.getClient(_request); - const data = await client.uploadMedia(multipartData); - reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/media', data); - reply.code(401).send(data); + const multipartData = await _request.file(); + if (!multipartData) { + reply.code(401).send({ error: 'No image' }); + return; } + const client = this.clientService.getClient(_request); + const data = await client.uploadMedia(multipartData); + reply.send(convertAttachment(data.data as Entity.Attachment)); }); fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - try { - const multipartData = await _request.file(); - if (!multipartData) { - reply.code(401).send({ error: 'No image' }); - return; - } - const client = this.clientService.getClient(_request); - const data = await client.uploadMedia(multipartData, _request.body); - reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v2/media', data); - reply.code(401).send(data); + const multipartData = await _request.file(); + if (!multipartData) { + reply.code(401).send({ error: 'No image' }); + return; } + const client = this.clientService.getClient(_request); + const data = await client.uploadMedia(multipartData, _request.body); + reply.send(convertAttachment(data.data as Entity.Attachment)); }); fastify.get('/v1/trends', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstanceTrends(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceTrends(); + reply.send(data.data); }); fastify.get('/v1/trends/tags', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstanceTrends(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends/tags', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getInstanceTrends(); + reply.send(data.data); }); fastify.get('/v1/trends/links', async (_request, reply) => { @@ -187,132 +154,81 @@ export class MastodonApiServerService { }); fastify.get('/v1/preferences', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getPreferences(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/preferences', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getPreferences(); + reply.send(data.data); }); fastify.get('/v1/followed_tags', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getFollowedTags(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/followed_tags', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + const data = await client.getFollowedTags(); + reply.send(data.data); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getBookmarks(parseTimelineArgs(_request.query)); - const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + const data = await client.getBookmarks(parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/bookmarks', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getFavourites(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + const data = await client.getFavourites(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/favourites', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(_request); - const data = await client.getMutes(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + const data = await client.getMutes(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/mutes', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(_request); - const data = await client.getBlocks(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + const data = await client.getBlocks(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/blocks', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; - const data = await client.getFollowRequests(limit); - reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account as Entity.Account)))); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/follow_requests', data); - reply.code(401).send(data); - } + const client = this.clientService.getClient(_request); + + const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; + const data = await client.getFollowRequests(limit); + const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account as Entity.Account))); + + reply.send(response); }); fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.acceptFollowRequest(_request.params.id); - const response = convertRelationship(data.data); + const client = this.clientService.getClient(_request); + const data = await client.acceptFollowRequest(_request.params.id); + const response = convertRelationship(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.rejectFollowRequest(_request.params.id); - const response = convertRelationship(data.data); + const client = this.clientService.getClient(_request); + const data = await client.rejectFollowRequest(_request.params.id); + const response = convertRelationship(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data); - reply.code(401).send(data); - } + reply.send(response); }); //#endregion @@ -327,23 +243,17 @@ export class MastodonApiServerService { is_sensitive?: string, }, }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const options = { - ..._request.body, - is_sensitive: toBoolean(_request.body.is_sensitive), - }; - const client = this.clientService.getClient(_request); - const data = await client.updateMedia(_request.params.id, options); - const response = convertAttachment(data.data); + const options = { + ..._request.body, + is_sensitive: toBoolean(_request.body.is_sensitive), + }; + const client = this.clientService.getClient(_request); + const data = await client.updateMedia(_request.params.id, options); + const response = convertAttachment(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`PUT /v1/media/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(response); }); done(); diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index bb844773c4..c7bca22922 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -3,37 +3,137 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; -import Logger, { Data } from '@/logger.js'; +import { Inject, Injectable } from '@nestjs/common'; +import Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; +import { ApiError } from '@/server/api/error.js'; +import { EnvService } from '@/core/EnvService.js'; +import { FastifyRequest } from 'fastify'; @Injectable() export class MastodonLogger { public readonly logger: Logger; - constructor(loggerService: LoggerService) { + constructor( + @Inject(EnvService) + private readonly envService: EnvService, + + loggerService: LoggerService, + ) { this.logger = loggerService.getLogger('masto-api'); } - public error(endpoint: string, error: Data): void { - this.logger.error(`Error in mastodon API endpoint ${endpoint}:`, error); + public error(request: FastifyRequest, error: MastodonError, status: number): void { + if ((status < 400 && status > 499) || this.envService.env.NODE_ENV === 'development') { + this.logger.error(`Error in mastodon endpoint ${request.method} ${request.url}:`, error); + } } } -export function getErrorData(error: unknown): Data { - if (error == null) return {}; - if (typeof(error) === 'string') return error; - if (typeof(error) === 'object') { +// TODO move elsewhere +export interface MastodonError { + error: string; + error_description: string; +} + +export function getErrorData(error: unknown): MastodonError { + if (error && typeof(error) === 'object') { + // AxiosError, comes from the backend if ('response' in error) { if (typeof(error.response) === 'object' && error.response) { if ('data' in error.response) { if (typeof(error.response.data) === 'object' && error.response.data) { - return error.response.data as Record; + if ('error' in error.response.data) { + if (typeof(error.response.data.error) === 'object' && error.response.data.error) { + if ('code' in error.response.data.error) { + if (typeof(error.response.data.error.code) === 'string') { + return convertApiError(error.response.data.error as ApiError); + } + } + + return convertUnknownError(error.response.data.error); + } + } + + return convertUnknownError(error.response.data); + } + } + } + + // No data - this is a fallback to avoid leaking request/response details in the error + return convertUnknownError(); + } + + if (error instanceof ApiError) { + return convertApiError(error); + } + + if (error instanceof Error) { + return convertGenericError(error); + } + + return convertUnknownError(error); + } + + return { + error: 'UNKNOWN_ERROR', + error_description: String(error), + }; +} + +function convertApiError(apiError: ApiError): MastodonError { + const mastoError: MastodonError & Partial = { + error: apiError.code, + error_description: apiError.message, + ...apiError, + }; + + delete mastoError.code; + delete mastoError.message; + + return mastoError; +} + +function convertUnknownError(data: object = {}): MastodonError { + return Object.assign({}, data, { + error: 'INTERNAL_ERROR', + error_description: 'Internal error occurred. Please contact us if the error persists.', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + kind: 'server', + }); +} + +function convertGenericError(error: Error): MastodonError { + const mastoError: MastodonError & Partial = { + error: 'INTERNAL_ERROR', + error_description: String(error), + ...error, + }; + + delete mastoError.name; + delete mastoError.message; + delete mastoError.stack; + + return mastoError; +} + +export function getErrorStatus(error: unknown): number { + // AxiosError, comes from the backend + if (typeof(error) === 'object' && error) { + if ('response' in error) { + if (typeof (error.response) === 'object' && error.response) { + if ('status' in error.response) { + if (typeof(error.response.status) === 'number') { + return error.response.status; } } } } - return error as Record; } - return { error }; + + if (error instanceof ApiError && error.httpStatusCode) { + return error.httpStatusCode; + } + + return 500; } diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index a5d7d89f7d..6ae6ea7c6a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { parseTimelineArgs, TimelineArgs, toBoolean } from '@/server/api/mastodon/argsUtils.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; @@ -31,32 +30,25 @@ export class ApiAccountMastodon { private readonly clientService: MastodonClientService, private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, private readonly driveService: DriveService, ) {} public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - try { - const client = await this.clientService.getClient(_request); - const data = await client.verifyAccountCredentials(); - const acct = await this.mastoConverters.convertAccount(data.data); - const response = Object.assign({}, acct, { - source: { - // TODO move these into the convertAccount logic directly - note: acct.note, - fields: acct.fields, - privacy: '', - sensitive: false, - language: '', - }, - }); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/verify_credentials', data); - reply.code(401).send(data); - } + const client = await this.clientService.getClient(_request); + const data = await client.verifyAccountCredentials(); + const acct = await this.mastoConverters.convertAccount(data.data); + const response = Object.assign({}, acct, { + source: { + // TODO move these into the convertAccount logic directly + note: acct.note, + fields: acct.fields, + privacy: '', + sensitive: false, + language: '', + }, + }); + reply.send(response); }); fastify.patch<{ @@ -80,318 +72,230 @@ export class ApiAccountMastodon { }, }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { const accessTokens = _request.headers.authorization; - try { - const client = this.clientService.getClient(_request); - // Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. - if (_request.files.length > 0 && accessTokens) { - const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const avatar = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'avatar'; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const header = (_request.files as any).find((obj: any) => { - return obj.fieldname === 'header'; - }); + const client = this.clientService.getClient(_request); + // Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. + if (_request.files.length > 0 && accessTokens) { + const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const avatar = (_request.files as any).find((obj: any) => { + return obj.fieldname === 'avatar'; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const header = (_request.files as any).find((obj: any) => { + return obj.fieldname === 'header'; + }); - if (tokeninfo && avatar) { - const upload = await this.driveService.addFile({ - user: { id: tokeninfo.userId, host: null }, - path: avatar.path, - name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, - sensitive: false, - }); - if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).avatar = upload.id; - } - } else if (tokeninfo && header) { - const upload = await this.driveService.addFile({ - user: { id: tokeninfo.userId, host: null }, - path: header.path, - name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, - sensitive: false, - }); - if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).header = upload.id; - } + if (tokeninfo && avatar) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: avatar.path, + name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).avatar = upload.id; + } + } else if (tokeninfo && header) { + const upload = await this.driveService.addFile({ + user: { id: tokeninfo.userId, host: null }, + path: header.path, + name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, + sensitive: false, + }); + if (upload.type.startsWith('image/')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).header = upload.id; } } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((_request.body as any).fields_attributes) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fields = (_request.body as any).fields_attributes.map((field: any) => { - if (!(field.name.trim() === '' && field.value.trim() === '')) { - if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); - if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); - } - return { - ...field, - }; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); - } - - const options = { - ..._request.body, - discoverable: toBoolean(_request.body.discoverable), - bot: toBoolean(_request.body.bot), - locked: toBoolean(_request.body.locked), - source: _request.body.source ? { - ..._request.body.source, - sensitive: toBoolean(_request.body.source.sensitive), - } : undefined, - }; - const data = await client.updateCredentials(options); - reply.send(await this.mastoConverters.convertAccount(data.data)); - } catch (e) { - const data = getErrorData(e); - this.logger.error('PATCH /v1/accounts/update_credentials', data); - reply.code(401).send(data); } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((_request.body as any).fields_attributes) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fields = (_request.body as any).fields_attributes.map((field: any) => { + if (!(field.name.trim() === '' && field.value.trim() === '')) { + if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); + if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); + } + return { + ...field, + }; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); + } + + const options = { + ..._request.body, + discoverable: toBoolean(_request.body.discoverable), + bot: toBoolean(_request.body.bot), + locked: toBoolean(_request.body.locked), + source: _request.body.source ? { + ..._request.body.source, + sensitive: toBoolean(_request.body.source.sensitive), + } : undefined, + }; + const data = await client.updateCredentials(options); + const response = await this.mastoConverters.convertAccount(data.data); + + reply.send(response); }); fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { - try { - if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); + if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); - const client = this.clientService.getClient(_request); - const data = await client.search(_request.query.acct, { type: 'accounts' }); - const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); - data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; - const response = await this.mastoConverters.convertAccount(data.data.accounts[0]); + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.acct, { type: 'accounts' }); + const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); + data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; + const response = await this.mastoConverters.convertAccount(data.data.accounts[0]); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/lookup', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get('/v1/accounts/relationships', async (_request, reply) => { - try { - let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; - if (typeof ids === 'string') { - ids = [ids]; - } - - const client = this.clientService.getClient(_request); - const data = await client.getRelationships(ids); - const response = data.data.map(relationship => convertRelationship(relationship)); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/accounts/relationships', data); - reply.code(401).send(data); + let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; + if (typeof ids === 'string') { + ids = [ids]; } + + const client = this.clientService.getClient(_request); + const data = await client.getRelationships(ids); + const response = data.data.map(relationship => convertRelationship(relationship)); + + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getAccount(_request.params.id); - const account = await this.mastoConverters.convertAccount(data.data); + const client = this.clientService.getClient(_request); + const data = await client.getAccount(_request.params.id); + const account = await this.mastoConverters.convertAccount(data.data); - reply.send(account); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(account); }); fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getAccountStatuses(_request.params.id, parseTimelineArgs(_request.query)); - const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getAccountStatuses(_request.params.id, parseTimelineArgs(_request.query)); + const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getFeaturedTags(); - const response = data.data.map((tag) => convertFeaturedTag(tag)); + const client = this.clientService.getClient(_request); + const data = await client.getFeaturedTags(); + const response = data.data.map((tag) => convertFeaturedTag(tag)); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getAccountFollowers( - _request.params.id, - parseTimelineArgs(_request.query), - ); - const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + const client = this.clientService.getClient(_request); + const data = await client.getAccountFollowers( + _request.params.id, + parseTimelineArgs(_request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getAccountFollowing( - _request.params.id, - parseTimelineArgs(_request.query), - ); - const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + const client = this.clientService.getClient(_request); + const data = await client.getAccountFollowing( + _request.params.id, + parseTimelineArgs(_request.query), + ); + const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getAccountLists(_request.params.id); - const response = data.data.map((list) => convertList(list)); + const client = this.clientService.getClient(_request); + const data = await client.getAccountLists(_request.params.id); + const response = data.data.map((list) => convertList(list)); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.followAccount(_request.params.id); - const acct = convertRelationship(data.data); - acct.following = true; + const client = this.clientService.getClient(_request); + const data = await client.followAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = true; - reply.send(acct); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data); - reply.code(401).send(data); - } + reply.send(acct); }); fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.unfollowAccount(_request.params.id); - const acct = convertRelationship(data.data); - acct.following = false; + const client = this.clientService.getClient(_request); + const data = await client.unfollowAccount(_request.params.id); + const acct = convertRelationship(data.data); + acct.following = false; - reply.send(acct); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data); - reply.code(401).send(data); - } + reply.send(acct); }); fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.blockAccount(_request.params.id); - const response = convertRelationship(data.data); + const client = this.clientService.getClient(_request); + const data = await client.blockAccount(_request.params.id); + const response = convertRelationship(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.unblockAccount(_request.params.id); - const response = convertRelationship(data.data); + const client = this.clientService.getClient(_request); + const data = await client.unblockAccount(_request.params.id); + const response = convertRelationship(data.data); - return reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data); - reply.code(401).send(data); - } + return reply.send(response); }); fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.muteAccount( - _request.params.id, - _request.body.notifications ?? true, - ); - const response = convertRelationship(data.data); + const client = this.clientService.getClient(_request); + const data = await client.muteAccount( + _request.params.id, + _request.body.notifications ?? true, + ); + const response = convertRelationship(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.unmuteAccount(_request.params.id); - const response = convertRelationship(data.data); + const client = this.clientService.getClient(_request); + const data = await client.unmuteAccount(_request.params.id); + const response = convertRelationship(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data); - reply.code(401).send(data); - } + reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index 17b9ba889d..e1c5f27739 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -4,7 +4,6 @@ */ import { Injectable } from '@nestjs/common'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; @@ -61,60 +60,53 @@ type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; export class ApiAppsMastodon { constructor( private readonly clientService: MastodonClientService, - private readonly logger: MastodonLogger, ) {} public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - const body = _request.body ?? _request.query; - if (!body.scopes) return reply.code(400).send({ error: 'Missing required payload "scopes"' }); - if (!body.redirect_uris) return reply.code(400).send({ error: 'Missing required payload "redirect_uris"' }); - if (!body.client_name) return reply.code(400).send({ error: 'Missing required payload "client_name"' }); + const body = _request.body ?? _request.query; + if (!body.scopes) return reply.code(400).send({ error: 'Missing required payload "scopes"' }); + if (!body.redirect_uris) return reply.code(400).send({ error: 'Missing required payload "redirect_uris"' }); + if (!body.client_name) return reply.code(400).send({ error: 'Missing required payload "client_name"' }); - let scope = body.scopes; - if (typeof scope === 'string') { - scope = scope.split(/[ +]/g); - } - - const pushScope = new Set(); - for (const s of scope) { - if (s.match(/^read/)) { - for (const r of readScope) { - pushScope.add(r); - } - } - if (s.match(/^write/)) { - for (const r of writeScope) { - pushScope.add(r); - } - } - } - - const red = body.redirect_uris; - - const client = this.clientService.getClient(_request); - const appData = await client.registerApp(body.client_name, { - scopes: Array.from(pushScope), - redirect_uris: red, - website: body.website, - }); - - const response = { - id: Math.floor(Math.random() * 100).toString(), - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url || '').toString('base64'), - client_secret: appData.clientSecret, - }; - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/apps', data); - reply.code(401).send(data); + let scope = body.scopes; + if (typeof scope === 'string') { + scope = scope.split(/[ +]/g); } + + const pushScope = new Set(); + for (const s of scope) { + if (s.match(/^read/)) { + for (const r of readScope) { + pushScope.add(r); + } + } + if (s.match(/^write/)) { + for (const r of writeScope) { + pushScope.add(r); + } + } + } + + const red = body.redirect_uris; + + const client = this.clientService.getClient(_request); + const appData = await client.registerApp(body.client_name, { + scopes: Array.from(pushScope), + redirect_uris: red, + website: body.website, + }); + + const response = { + id: Math.floor(Math.random() * 100).toString(), + name: appData.name, + website: body.website, + redirect_uri: red, + client_id: Buffer.from(appData.url || '').toString('base64'), + client_secret: appData.clientSecret, + }; + + reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 10353ff7af..7f986974fc 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -6,7 +6,6 @@ import { Injectable } from '@nestjs/common'; import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { convertFilter } from '../converters.js'; import type { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; @@ -28,105 +27,74 @@ interface ApiFilterMastodonRoute { export class ApiFilterMastodon { constructor( private readonly clientService: MastodonClientService, - private readonly logger: MastodonLogger, ) {} public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.get('/v1/filters', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(_request); - const data = await client.getFilters(); - const response = data.data.map((filter) => convertFilter(filter)); + const data = await client.getFilters(); + const response = data.data.map((filter) => convertFilter(filter)); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/filters', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get('/v1/filters/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getFilter(_request.params.id); - const response = convertFilter(data.data); + const client = this.clientService.getClient(_request); + const data = await client.getFilter(_request.params.id); + const response = convertFilter(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); - if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); - const options = { - phrase: _request.body.phrase, - context: _request.body.context, - irreversible: toBoolean(_request.body.irreversible), - whole_word: toBoolean(_request.body.whole_word), - expires_in: _request.body.expires_in, - }; + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; - const client = this.clientService.getClient(_request); - const data = await client.createFilter(_request.body.phrase, _request.body.context, options); - const response = convertFilter(data.data); + const client = this.clientService.getClient(_request); + const data = await client.createFilter(_request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/filters', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); - if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); - const options = { - phrase: _request.body.phrase, - context: _request.body.context, - irreversible: toBoolean(_request.body.irreversible), - whole_word: toBoolean(_request.body.whole_word), - expires_in: _request.body.expires_in, - }; + const options = { + phrase: _request.body.phrase, + context: _request.body.context, + irreversible: toBoolean(_request.body.irreversible), + whole_word: toBoolean(_request.body.whole_word), + expires_in: _request.body.expires_in, + }; - const client = this.clientService.getClient(_request); - const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options); - const response = convertFilter(data.data); + const client = this.clientService.getClient(_request); + const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options); + const response = convertFilter(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.delete('/v1/filters/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.deleteFilter(_request.params.id); + const client = this.clientService.getClient(_request); + const data = await client.deleteFilter(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index ffffb5e537..bc7ef69100 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -10,12 +10,9 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type { MiMeta, UsersRepository } from '@/models/_.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; -// TODO rename to ApiInstanceMastodon - @Injectable() export class ApiInstanceMastodon { constructor( @@ -29,82 +26,75 @@ export class ApiInstanceMastodon { private readonly config: Config, private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, private readonly clientService: MastodonClientService, ) {} public register(fastify: FastifyInstance): void { fastify.get('/v1/instance', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getInstance(); - const instance = data.data; - const admin = await this.usersRepository.findOne({ - where: { - host: IsNull(), - isRoot: true, - isDeleted: false, - isSuspended: false, - }, - order: { id: 'ASC' }, - }); - const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); + const client = this.clientService.getClient(_request); + const data = await client.getInstance(); + const instance = data.data; + const admin = await this.usersRepository.findOne({ + where: { + host: IsNull(), + isRoot: true, + isDeleted: false, + isSuspended: false, + }, + order: { id: 'ASC' }, + }); + const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); - const response = { - uri: this.config.url, - title: this.meta.name || 'Sharkey', - short_description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - email: instance.email || '', - version: `3.0.0 (compatible; Sharkey ${this.config.version})`, - urls: instance.urls, - stats: { - user_count: instance.stats.user_count, - status_count: instance.stats.status_count, - domain_count: instance.stats.domain_count, + const response = { + uri: this.config.url, + title: this.meta.name || 'Sharkey', + short_description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + email: instance.email || '', + version: `3.0.0 (compatible; Sharkey ${this.config.version})`, + urls: instance.urls, + stats: { + user_count: instance.stats.user_count, + status_count: instance.stats.status_count, + domain_count: instance.stats.domain_count, + }, + thumbnail: this.meta.backgroundImageUrl || '/static-assets/transparent.png', + languages: this.meta.langs, + registrations: !this.meta.disableRegistration || instance.registrations, + approval_required: this.meta.approvalRequiredForSignup, + invites_enabled: instance.registrations, + configuration: { + accounts: { + max_featured_tags: 20, }, - thumbnail: this.meta.backgroundImageUrl || '/static-assets/transparent.png', - languages: this.meta.langs, - registrations: !this.meta.disableRegistration || instance.registrations, - approval_required: this.meta.approvalRequiredForSignup, - invites_enabled: instance.registrations, - configuration: { - accounts: { - max_featured_tags: 20, - }, - statuses: { - max_characters: this.config.maxNoteLength, - max_media_attachments: 16, - characters_reserved_per_url: instance.uri.length, - }, - media_attachments: { - supported_mime_types: FILE_TYPE_BROWSERSAFE, - image_size_limit: 10485760, - image_matrix_limit: 16777216, - video_size_limit: 41943040, - video_frame_rate_limit: 60, - video_matrix_limit: 2304000, - }, - polls: { - max_options: 10, - max_characters_per_option: 150, - min_expiration: 50, - max_expiration: 2629746, - }, - reactions: { - max_reactions: 1, - }, + statuses: { + max_characters: this.config.maxNoteLength, + max_media_attachments: 16, + characters_reserved_per_url: instance.uri.length, }, - contact_account: contact, - rules: [], - }; + media_attachments: { + supported_mime_types: FILE_TYPE_BROWSERSAFE, + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_rate_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 10, + max_characters_per_option: 150, + min_expiration: 50, + max_expiration: 2629746, + }, + reactions: { + max_reactions: 1, + }, + }, + contact_account: contact, + rules: [], + }; - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/instance', data); - reply.code(401).send(data); - } + reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 0dba247e5f..27e6cbcd0d 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -6,7 +6,6 @@ import { Injectable } from '@nestjs/common'; import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastodonClientService } from '../MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; @@ -23,75 +22,50 @@ export class ApiNotificationsMastodon { constructor( private readonly mastoConverters: MastoConverters, private readonly clientService: MastodonClientService, - private readonly logger: MastodonLogger, ) {} public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.get('/v1/notifications', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getNotifications(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map(async n => { - const converted = await this.mastoConverters.convertNotification(n, me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; - } - return converted; - })); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/notifications', data); - reply.code(401).send(data); - } - }); - - fastify.get('/v1/notification/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getNotification(_request.params.id); - const converted = await this.mastoConverters.convertNotification(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getNotifications(parseTimelineArgs(_request.query)); + const response = Promise.all(data.data.map(async n => { + const converted = await this.mastoConverters.convertNotification(n, me); if (converted.type === 'reaction') { converted.type = 'favourite'; } + return converted; + })); - reply.send(converted); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/notification/${_request.params.id}`, data); - reply.code(401).send(data); + reply.send(response); + }); + + fastify.get('/v1/notification/:id', async (_request, reply) => { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getNotification(_request.params.id); + const converted = await this.mastoConverters.convertNotification(data.data, me); + if (converted.type === 'reaction') { + converted.type = 'favourite'; } + + reply.send(converted); }); fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.dismissNotification(_request.params.id); + const client = this.clientService.getClient(_request); + const data = await client.dismissNotification(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data); - reply.code(401).send(data); - } + reply.send(data.data); }); fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.dismissNotifications(); + const client = this.clientService.getClient(_request); + const data = await client.dismissNotifications(); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/notifications/clear', data); - reply.code(401).send(data); - } + reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 3b1c984c3e..814e2cf776 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -5,7 +5,6 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastoConverters } from '../converters.js'; import { parseTimelineArgs, TimelineArgs } from '../argsUtils.js'; import Account = Entity.Account; @@ -24,107 +23,82 @@ export class ApiSearchMastodon { constructor( private readonly mastoConverters: MastoConverters, private readonly clientService: MastodonClientService, - private readonly logger: MastodonLogger, ) {} public register(fastify: FastifyInstance): void { fastify.get('/v1/search', async (_request, reply) => { - try { - if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); - const query = parseTimelineArgs(_request.query); - const client = this.clientService.getClient(_request); - const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); + const query = parseTimelineArgs(_request.query); + const client = this.clientService.getClient(_request); + const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/search', data); - reply.code(401).send(data); - } + reply.send(data.data); }); fastify.get('/v2/search', async (_request, reply) => { - try { - if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); - const query = parseTimelineArgs(_request.query); - const type = _request.query.type; - const { client, me } = await this.clientService.getAuthClient(_request); - const acct = !type || type === 'accounts' ? await client.search(_request.query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await client.search(_request.query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await client.search(_request.query.q, { type: 'hashtags', ...query }) : null; - const response = { - accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, me)) ?? []), - hashtags: tags?.data.hashtags ?? [], - }; + const query = parseTimelineArgs(_request.query); + const type = _request.query.type; + const { client, me } = await this.clientService.getAuthClient(_request); + const acct = !type || type === 'accounts' ? await client.search(_request.query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await client.search(_request.query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await client.search(_request.query.q, { type: 'hashtags', ...query }) : null; + const response = { + accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, me)) ?? []), + hashtags: tags?.data.hashtags ?? [], + }; - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v2/search', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get('/v1/trends/statuses', async (_request, reply) => { - try { - const baseUrl = this.clientService.getBaseUrl(_request); - const res = await fetch(`${baseUrl}/api/notes/featured`, - { - method: 'POST', - headers: { - ..._request.headers as HeadersInit, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: '{}', - }); - const data = await res.json() as Status[]; - const me = await this.clientService.getAuth(_request); - const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); + const baseUrl = this.clientService.getBaseUrl(_request); + const res = await fetch(`${baseUrl}/api/notes/featured`, + { + method: 'POST', + headers: { + ..._request.headers as HeadersInit, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: '{}', + }); + const data = await res.json() as Status[]; + const me = await this.clientService.getAuth(_request); + const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/trends/statuses', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get('/v2/suggestions', async (_request, reply) => { - try { - const baseUrl = this.clientService.getBaseUrl(_request); - const res = await fetch(`${baseUrl}/api/users`, - { - method: 'POST', - headers: { - ..._request.headers as HeadersInit, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - limit: parseTimelineArgs(_request.query).limit ?? 20, - origin: 'local', - sort: '+follower', - state: 'alive', - }), - }); - const data = await res.json() as Account[]; - const response = await Promise.all(data.map(async entry => { - return { - source: 'global', - account: await this.mastoConverters.convertAccount(entry), - }; - })); + const baseUrl = this.clientService.getBaseUrl(_request); + const res = await fetch(`${baseUrl}/api/users`, + { + method: 'POST', + headers: { + ..._request.headers as HeadersInit, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + limit: parseTimelineArgs(_request.query).limit ?? 20, + origin: 'local', + sort: '+follower', + state: 'alive', + }), + }); + const data = await res.json() as Account[]; + const response = await Promise.all(data.map(async entry => { + return { + source: 'global', + account: await this.mastoConverters.convertAccount(entry), + }; + })); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v2/suggestions', data); - reply.code(401).send(data); - } + reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index ba61918b75..8b9ccf44b6 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -6,7 +6,6 @@ import querystring, { ParsedUrlQueryInput } from 'querystring'; import { Injectable } from '@nestjs/common'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; @@ -22,154 +21,99 @@ function normalizeQuery(data: Record) { export class ApiStatusMastodon { constructor( private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, private readonly clientService: MastodonClientService, ) {} public register(fastify: FastifyInstance): void { fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getStatus(_request.params.id); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.getStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getStatusSource(_request.params.id); + const client = this.clientService.getClient(_request); + const data = await client.getStatusSource(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/source`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } + reply.send(data.data); }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); - const ancestors = await Promise.all(data.ancestors.map(async status => await this.mastoConverters.convertStatus(status, me))); - const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status, me))); - const response = { ancestors, descendants }; + const { client, me } = await this.clientService.getAuthClient(_request); + const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); + const ancestors = await Promise.all(data.ancestors.map(async status => await this.mastoConverters.convertStatus(status, me))); + const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status, me))); + const response = { ancestors, descendants }; - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/context`, data); - reply.code(_request.is404 ? 404 : 401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const user = await this.clientService.getAuth(_request); - const edits = await this.mastoConverters.getEdits(_request.params.id, user); + const user = await this.clientService.getAuth(_request); + const edits = await this.mastoConverters.getEdits(_request.params.id, user); - reply.send(edits); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/history`, data); - reply.code(401).send(data); - } + reply.send(edits); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getStatusRebloggedBy(_request.params.id); - const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + const client = this.clientService.getClient(_request); + const data = await client.getStatusRebloggedBy(_request.params.id); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/reblogged_by`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getStatusFavouritedBy(_request.params.id); - const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + const client = this.clientService.getClient(_request); + const data = await client.getStatusFavouritedBy(_request.params.id); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/favourited_by`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getMedia(_request.params.id); - const response = convertAttachment(data.data); + const client = this.clientService.getClient(_request); + const data = await client.getMedia(_request.params.id); + const response = convertAttachment(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/media/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getPoll(_request.params.id); - const response = convertPoll(data.data); + const client = this.clientService.getClient(_request); + const data = await client.getPoll(_request.params.id); + const response = convertPoll(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/polls/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' }); - const client = this.clientService.getClient(_request); - const data = await client.votePoll(_request.params.id, _request.body.choices); - const response = convertPoll(data.data); + const client = this.clientService.getClient(_request); + const data = await client.votePoll(_request.params.id, _request.body.choices); + const response = convertPoll(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/polls/${_request.params.id}/votes`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ @@ -196,60 +140,55 @@ export class ApiStatusMastodon { } }>('/v1/statuses', async (_request, reply) => { let body = _request.body; - try { - const { client, me } = await this.clientService.getAuthClient(_request); - if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) - ) { - body = normalizeQuery(body); - } - const text = body.status ??= ' '; - const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); - const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); - const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); - if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) { - const a = await client.createEmojiReaction( - body.in_reply_to_id, - removed, - ); - reply.send(a.data); - } - if (body.in_reply_to_id && removed === '/unreact') { - const id = body.in_reply_to_id; - const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter(e => e.me)[0].name; - const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); - } - if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - - if (body.poll && !body.poll.options) { - return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); - } - if (body.poll && !body.poll.expires_in) { - return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); - } - - const options = { - ...body, - sensitive: toBoolean(body.sensitive), - poll: body.poll ? { - options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion - expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion - multiple: toBoolean(body.poll.multiple), - hide_totals: toBoolean(body.poll.hide_totals), - } : undefined, - }; - - const data = await client.postStatus(text, options); - const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/statuses', data); - reply.code(401).send(data); + if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) + ) { + body = normalizeQuery(body); } + const text = body.status ??= ' '; + const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); + const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); + const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); + + const { client, me } = await this.clientService.getAuthClient(_request); + if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) { + const a = await client.createEmojiReaction( + body.in_reply_to_id, + removed, + ); + reply.send(a.data); + } + if (body.in_reply_to_id && removed === '/unreact') { + const id = body.in_reply_to_id; + const post = await client.getStatus(id); + const react = post.data.emoji_reactions.filter(e => e.me)[0].name; + const data = await client.deleteEmojiReaction(id, react); + reply.send(data.data); + } + if (!body.media_ids) body.media_ids = undefined; + if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; + + if (body.poll && !body.poll.options) { + return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); + } + if (body.poll && !body.poll.expires_in) { + return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); + } + + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.postStatus(text, options); + const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); + + reply.send(response); }); fastify.put<{ @@ -267,210 +206,138 @@ export class ApiStatusMastodon { }, } }>('/v1/statuses/:id', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); - const body = _request.body; + const { client, me } = await this.clientService.getAuthClient(_request); + const body = _request.body; - if (!body.media_ids || !body.media_ids.length) { - body.media_ids = undefined; - } - - const options = { - ...body, - sensitive: toBoolean(body.sensitive), - poll: body.poll ? { - options: body.poll.options, - expires_in: toInt(body.poll.expires_in), - multiple: toBoolean(body.poll.multiple), - hide_totals: toBoolean(body.poll.hide_totals), - } : undefined, - }; - - const data = await client.editStatus(_request.params.id, options); - const response = await this.mastoConverters.convertStatus(data.data, me); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}`, data); - reply.code(401).send(data); + if (!body.media_ids || !body.media_ids.length) { + body.media_ids = undefined; } + + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options, + expires_in: toInt(body.poll.expires_in), + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.editStatus(_request.params.id, options); + const response = await this.mastoConverters.convertStatus(data.data, me); + + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.createEmojiReaction(_request.params.id, '❤'); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.createEmojiReaction(_request.params.id, '❤'); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/favorite`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.deleteEmojiReaction(_request.params.id, '❤'); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.deleteEmojiReaction(_request.params.id, '❤'); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/statuses/${_request.params.id}/unfavorite`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.reblogStatus(_request.params.id); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.reblogStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/reblog`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.unreblogStatus(_request.params.id); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unreblogStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/unreblog`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.bookmarkStatus(_request.params.id); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.bookmarkStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/bookmark`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.unbookmarkStatus(_request.params.id); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unbookmarkStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/unbookmark`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.pinStatus(_request.params.id); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.pinStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/pin`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.unpinStatus(_request.params.id); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.unpinStatus(_request.params.id); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/unpin`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.createEmojiReaction(_request.params.id, _request.params.name); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/react/${_request.params.name}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - const response = await this.mastoConverters.convertStatus(data.data, me); + const { client, me } = await this.clientService.getAuthClient(_request); + const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); + const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/statuses/${_request.params.id}/unreact/${_request.params.name}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.deleteStatus(_request.params.id); + const client = this.clientService.getClient(_request); + const data = await client.deleteStatus(_request.params.id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/statuses/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 864fdc7691..7dee9a062c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -4,7 +4,6 @@ */ import { Injectable } from '@nestjs/common'; -import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertList, MastoConverters } from '../converters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; @@ -16,216 +15,136 @@ export class ApiTimelineMastodon { constructor( private readonly clientService: MastodonClientService, private readonly mastoConverters: MastoConverters, - private readonly logger: MastodonLogger, ) {} public register(fastify: FastifyInstance): void { fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = toBoolean(_request.query.local) + ? await client.getLocalTimeline(query) + : await client.getPublicTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); - const query = parseTimelineArgs(_request.query); - const data = toBoolean(_request.query.local) - ? await client.getLocalTimeline(query) - : await client.getPublicTimeline(query); - const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); - - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/timelines/public', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getHomeTimeline(query); - const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getHomeTimeline(query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/timelines/home', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - try { - if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); + if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getTagTimeline(_request.params.hashtag, query); - const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getTagTimeline(_request.params.hashtag, query); + const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/timelines/tag/${_request.params.hashtag}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getListTimeline(_request.params.id, query); - const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getListTimeline(_request.params.id, query); + const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/timelines/list/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { - try { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getConversationTimeline(query); - const conversations = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); + const { client, me } = await this.clientService.getAuthClient(_request); + const query = parseTimelineArgs(_request.query); + const data = await client.getConversationTimeline(query); + const conversations = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); - reply.send(conversations); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/conversations', data); - reply.code(401).send(data); - } + reply.send(conversations); }); fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getList(_request.params.id); - const response = convertList(data.data); + const client = this.clientService.getClient(_request); + const data = await client.getList(_request.params.id); + const response = convertList(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get('/v1/lists', async (_request, reply) => { - try { - const client = this.clientService.getClient(_request); - const data = await client.getLists(); - const response = data.data.map((list: Entity.List) => convertList(list)); + const client = this.clientService.getClient(_request); + const data = await client.getLists(); + const response = data.data.map((list: Entity.List) => convertList(list)); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('GET /v1/lists', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getAccountsInList(_request.params.id, _request.query); - const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + const client = this.clientService.getClient(_request); + const data = await client.getAccountsInList(_request.params.id, _request.query); + const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(accounts); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`GET /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + reply.send(accounts); }); fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const client = this.clientService.getClient(_request); - const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); + const client = this.clientService.getClient(_request); + const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`POST /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + reply.send(data.data); }); fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); - const client = this.clientService.getClient(_request); - const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); + const client = this.clientService.getClient(_request); + const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/lists/${_request.params.id}/accounts`, data); - reply.code(401).send(data); - } + reply.send(data.data); }); fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { - try { - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const client = this.clientService.getClient(_request); - const data = await client.createList(_request.body.title); - const response = convertList(data.data); + const client = this.clientService.getClient(_request); + const data = await client.createList(_request.body.title); + const response = convertList(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error('POST /v1/lists', data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); - const client = this.clientService.getClient(_request); - const data = await client.updateList(_request.params.id, _request.body.title); - const response = convertList(data.data); + const client = this.clientService.getClient(_request); + const data = await client.updateList(_request.params.id, _request.body.title); + const response = convertList(data.data); - reply.send(response); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`PUT /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send(response); }); fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - try { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - await client.deleteList(_request.params.id); + const client = this.clientService.getClient(_request); + await client.deleteList(_request.params.id); - reply.send({}); - } catch (e) { - const data = getErrorData(e); - this.logger.error(`DELETE /v1/lists/${_request.params.id}`, data); - reply.code(401).send(data); - } + reply.send({}); }); } } From cb9079208ad6f2310e684ce34b0bb34fe934102f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 20:41:21 -0400 Subject: [PATCH 04/37] format mastodon API endpoints --- .../api/mastodon/MastodonApiServerService.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 17f706e617..eca0883e65 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -104,13 +104,17 @@ export class MastodonApiServerService { fastify.get('/v1/announcements', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getInstanceAnnouncements(); - reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); + const response = data.data.map((announcement) => convertAnnouncement(announcement)); + + reply.send(response); }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); + const client = this.clientService.getClient(_request); - const data = await client.dismissInstanceAnnouncement(_request.body['id']); + const data = await client.dismissInstanceAnnouncement(_request.body.id); + reply.send(data.data); }); @@ -120,9 +124,12 @@ export class MastodonApiServerService { reply.code(401).send({ error: 'No image' }); return; } + const client = this.clientService.getClient(_request); const data = await client.uploadMedia(multipartData); - reply.send(convertAttachment(data.data as Entity.Attachment)); + const response = convertAttachment(data.data as Entity.Attachment); + + reply.send(response); }); fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { @@ -131,9 +138,12 @@ export class MastodonApiServerService { reply.code(401).send({ error: 'No image' }); return; } + const client = this.clientService.getClient(_request); const data = await client.uploadMedia(multipartData, _request.body); - reply.send(convertAttachment(data.data as Entity.Attachment)); + const response = convertAttachment(data.data as Entity.Attachment); + + reply.send(response); }); fastify.get('/v1/trends', async (_request, reply) => { From 75b6c63f4448c4f79bb652d14da2545109eb05b1 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 21:45:28 -0400 Subject: [PATCH 05/37] remove unused megalodon components --- .../api/mastodon/MastodonClientService.ts | 8 +- packages/megalodon/src/entities/instance.ts | 2 +- packages/megalodon/src/friendica.ts | 2868 --------------- .../megalodon/src/friendica/api_client.ts | 769 ---- .../src/friendica/entities/account.ts | 29 - .../src/friendica/entities/activity.ts | 8 - .../src/friendica/entities/application.ts | 7 - .../friendica/entities/async_attachment.ts | 14 - .../src/friendica/entities/attachment.ts | 49 - .../megalodon/src/friendica/entities/card.ts | 17 - .../src/friendica/entities/context.ts | 8 - .../src/friendica/entities/conversation.ts | 11 - .../megalodon/src/friendica/entities/emoji.ts | 8 - .../src/friendica/entities/featured_tag.ts | 8 - .../megalodon/src/friendica/entities/field.ts | 7 - .../src/friendica/entities/filter.ts | 12 - .../src/friendica/entities/follow_request.ts | 27 - .../src/friendica/entities/history.ts | 7 - .../src/friendica/entities/identity_proof.ts | 9 - .../src/friendica/entities/instance.ts | 28 - .../megalodon/src/friendica/entities/list.ts | 9 - .../src/friendica/entities/marker.ts | 14 - .../src/friendica/entities/mention.ts | 8 - .../src/friendica/entities/notification.ts | 14 - .../megalodon/src/friendica/entities/poll.ts | 13 - .../src/friendica/entities/poll_option.ts | 6 - .../src/friendica/entities/preferences.ts | 9 - .../friendica/entities/push_subscription.ts | 16 - .../src/friendica/entities/relationship.ts | 17 - .../src/friendica/entities/report.ts | 16 - .../src/friendica/entities/results.ts | 11 - .../friendica/entities/scheduled_status.ts | 10 - .../src/friendica/entities/source.ts | 10 - .../megalodon/src/friendica/entities/stats.ts | 7 - .../src/friendica/entities/status.ts | 49 - .../src/friendica/entities/status_params.ts | 12 - .../src/friendica/entities/status_source.ts | 7 - .../megalodon/src/friendica/entities/tag.ts | 10 - .../megalodon/src/friendica/entities/token.ts | 8 - .../megalodon/src/friendica/entities/urls.ts | 5 - packages/megalodon/src/friendica/entity.ts | 38 - .../megalodon/src/friendica/notification.ts | 14 - .../megalodon/src/friendica/web_socket.ts | 18 - packages/megalodon/src/index.ts | 13 +- packages/megalodon/src/mastodon.ts | 3169 ---------------- packages/megalodon/src/mastodon/api_client.ts | 662 ---- .../src/mastodon/entities/instance.ts | 9 +- .../megalodon/src/mastodon/notification.ts | 16 - packages/megalodon/src/mastodon/web_socket.ts | 348 -- packages/megalodon/src/megalodon.ts | 44 - packages/megalodon/src/misskey.ts | 16 +- packages/megalodon/src/pleroma.ts | 3217 ----------------- packages/megalodon/src/pleroma/api_client.ts | 824 ----- .../megalodon/src/pleroma/entities/account.ts | 31 - .../src/pleroma/entities/activity.ts | 8 - .../src/pleroma/entities/announcement.ts | 39 - .../src/pleroma/entities/application.ts | 7 - .../src/pleroma/entities/async_attachment.ts | 14 - .../src/pleroma/entities/attachment.ts | 49 - .../megalodon/src/pleroma/entities/card.ts | 11 - .../megalodon/src/pleroma/entities/context.ts | 8 - .../src/pleroma/entities/conversation.ts | 11 - .../megalodon/src/pleroma/entities/emoji.ts | 8 - .../src/pleroma/entities/featured_tag.ts | 8 - .../megalodon/src/pleroma/entities/field.ts | 7 - .../megalodon/src/pleroma/entities/filter.ts | 12 - .../megalodon/src/pleroma/entities/history.ts | 7 - .../src/pleroma/entities/identity_proof.ts | 9 - .../src/pleroma/entities/instance.ts | 46 - .../megalodon/src/pleroma/entities/list.ts | 6 - .../megalodon/src/pleroma/entities/marker.ts | 12 - .../megalodon/src/pleroma/entities/mention.ts | 8 - .../src/pleroma/entities/notification.ts | 16 - .../megalodon/src/pleroma/entities/poll.ts | 13 - .../src/pleroma/entities/poll_option.ts | 6 - .../src/pleroma/entities/preferences.ts | 9 - .../src/pleroma/entities/push_subscription.ts | 16 - .../src/pleroma/entities/reaction.ts | 10 - .../src/pleroma/entities/relationship.ts | 18 - .../megalodon/src/pleroma/entities/report.ts | 6 - .../megalodon/src/pleroma/entities/results.ts | 11 - .../src/pleroma/entities/scheduled_status.ts | 10 - .../megalodon/src/pleroma/entities/source.ts | 10 - .../megalodon/src/pleroma/entities/stats.ts | 7 - .../megalodon/src/pleroma/entities/status.ts | 65 - .../src/pleroma/entities/status_params.ts | 11 - .../src/pleroma/entities/status_source.ts | 7 - .../megalodon/src/pleroma/entities/tag.ts | 10 - .../megalodon/src/pleroma/entities/token.ts | 8 - .../megalodon/src/pleroma/entities/urls.ts | 5 - packages/megalodon/src/pleroma/entity.ts | 39 - .../megalodon/src/pleroma/notification.ts | 15 - packages/megalodon/src/pleroma/web_socket.ts | 349 -- .../megalodon/test/integration/cancel.spec.ts | 38 - .../test/integration/cancelWorker.ts | 5 - .../test/integration/mastodon.spec.ts | 218 -- .../integration/mastodon/api_client.spec.ts | 177 - .../test/integration/pleroma.spec.ts | 222 -- packages/megalodon/test/unit/mastodon.spec.ts | 6 - .../test/unit/mastodon/api_client.spec.ts | 80 - .../test/unit/pleroma/api_client.spec.ts | 226 -- .../megalodon/test/unit/webo_socket.spec.ts | 185 - 102 files changed, 30 insertions(+), 14604 deletions(-) delete mode 100644 packages/megalodon/src/friendica.ts delete mode 100644 packages/megalodon/src/friendica/api_client.ts delete mode 100644 packages/megalodon/src/friendica/entities/account.ts delete mode 100644 packages/megalodon/src/friendica/entities/activity.ts delete mode 100644 packages/megalodon/src/friendica/entities/application.ts delete mode 100644 packages/megalodon/src/friendica/entities/async_attachment.ts delete mode 100644 packages/megalodon/src/friendica/entities/attachment.ts delete mode 100644 packages/megalodon/src/friendica/entities/card.ts delete mode 100644 packages/megalodon/src/friendica/entities/context.ts delete mode 100644 packages/megalodon/src/friendica/entities/conversation.ts delete mode 100644 packages/megalodon/src/friendica/entities/emoji.ts delete mode 100644 packages/megalodon/src/friendica/entities/featured_tag.ts delete mode 100644 packages/megalodon/src/friendica/entities/field.ts delete mode 100644 packages/megalodon/src/friendica/entities/filter.ts delete mode 100644 packages/megalodon/src/friendica/entities/follow_request.ts delete mode 100644 packages/megalodon/src/friendica/entities/history.ts delete mode 100644 packages/megalodon/src/friendica/entities/identity_proof.ts delete mode 100644 packages/megalodon/src/friendica/entities/instance.ts delete mode 100644 packages/megalodon/src/friendica/entities/list.ts delete mode 100644 packages/megalodon/src/friendica/entities/marker.ts delete mode 100644 packages/megalodon/src/friendica/entities/mention.ts delete mode 100644 packages/megalodon/src/friendica/entities/notification.ts delete mode 100644 packages/megalodon/src/friendica/entities/poll.ts delete mode 100644 packages/megalodon/src/friendica/entities/poll_option.ts delete mode 100644 packages/megalodon/src/friendica/entities/preferences.ts delete mode 100644 packages/megalodon/src/friendica/entities/push_subscription.ts delete mode 100644 packages/megalodon/src/friendica/entities/relationship.ts delete mode 100644 packages/megalodon/src/friendica/entities/report.ts delete mode 100644 packages/megalodon/src/friendica/entities/results.ts delete mode 100644 packages/megalodon/src/friendica/entities/scheduled_status.ts delete mode 100644 packages/megalodon/src/friendica/entities/source.ts delete mode 100644 packages/megalodon/src/friendica/entities/stats.ts delete mode 100644 packages/megalodon/src/friendica/entities/status.ts delete mode 100644 packages/megalodon/src/friendica/entities/status_params.ts delete mode 100644 packages/megalodon/src/friendica/entities/status_source.ts delete mode 100644 packages/megalodon/src/friendica/entities/tag.ts delete mode 100644 packages/megalodon/src/friendica/entities/token.ts delete mode 100644 packages/megalodon/src/friendica/entities/urls.ts delete mode 100644 packages/megalodon/src/friendica/entity.ts delete mode 100644 packages/megalodon/src/friendica/notification.ts delete mode 100644 packages/megalodon/src/friendica/web_socket.ts delete mode 100644 packages/megalodon/src/mastodon.ts delete mode 100644 packages/megalodon/src/mastodon/api_client.ts delete mode 100644 packages/megalodon/src/mastodon/notification.ts delete mode 100644 packages/megalodon/src/mastodon/web_socket.ts delete mode 100644 packages/megalodon/src/pleroma.ts delete mode 100644 packages/megalodon/src/pleroma/api_client.ts delete mode 100644 packages/megalodon/src/pleroma/entities/account.ts delete mode 100644 packages/megalodon/src/pleroma/entities/activity.ts delete mode 100644 packages/megalodon/src/pleroma/entities/announcement.ts delete mode 100644 packages/megalodon/src/pleroma/entities/application.ts delete mode 100644 packages/megalodon/src/pleroma/entities/async_attachment.ts delete mode 100644 packages/megalodon/src/pleroma/entities/attachment.ts delete mode 100644 packages/megalodon/src/pleroma/entities/card.ts delete mode 100644 packages/megalodon/src/pleroma/entities/context.ts delete mode 100644 packages/megalodon/src/pleroma/entities/conversation.ts delete mode 100644 packages/megalodon/src/pleroma/entities/emoji.ts delete mode 100644 packages/megalodon/src/pleroma/entities/featured_tag.ts delete mode 100644 packages/megalodon/src/pleroma/entities/field.ts delete mode 100644 packages/megalodon/src/pleroma/entities/filter.ts delete mode 100644 packages/megalodon/src/pleroma/entities/history.ts delete mode 100644 packages/megalodon/src/pleroma/entities/identity_proof.ts delete mode 100644 packages/megalodon/src/pleroma/entities/instance.ts delete mode 100644 packages/megalodon/src/pleroma/entities/list.ts delete mode 100644 packages/megalodon/src/pleroma/entities/marker.ts delete mode 100644 packages/megalodon/src/pleroma/entities/mention.ts delete mode 100644 packages/megalodon/src/pleroma/entities/notification.ts delete mode 100644 packages/megalodon/src/pleroma/entities/poll.ts delete mode 100644 packages/megalodon/src/pleroma/entities/poll_option.ts delete mode 100644 packages/megalodon/src/pleroma/entities/preferences.ts delete mode 100644 packages/megalodon/src/pleroma/entities/push_subscription.ts delete mode 100644 packages/megalodon/src/pleroma/entities/reaction.ts delete mode 100644 packages/megalodon/src/pleroma/entities/relationship.ts delete mode 100644 packages/megalodon/src/pleroma/entities/report.ts delete mode 100644 packages/megalodon/src/pleroma/entities/results.ts delete mode 100644 packages/megalodon/src/pleroma/entities/scheduled_status.ts delete mode 100644 packages/megalodon/src/pleroma/entities/source.ts delete mode 100644 packages/megalodon/src/pleroma/entities/stats.ts delete mode 100644 packages/megalodon/src/pleroma/entities/status.ts delete mode 100644 packages/megalodon/src/pleroma/entities/status_params.ts delete mode 100644 packages/megalodon/src/pleroma/entities/status_source.ts delete mode 100644 packages/megalodon/src/pleroma/entities/tag.ts delete mode 100644 packages/megalodon/src/pleroma/entities/token.ts delete mode 100644 packages/megalodon/src/pleroma/entities/urls.ts delete mode 100644 packages/megalodon/src/pleroma/entity.ts delete mode 100644 packages/megalodon/src/pleroma/notification.ts delete mode 100644 packages/megalodon/src/pleroma/web_socket.ts delete mode 100644 packages/megalodon/test/integration/cancel.spec.ts delete mode 100644 packages/megalodon/test/integration/cancelWorker.ts delete mode 100644 packages/megalodon/test/integration/mastodon.spec.ts delete mode 100644 packages/megalodon/test/integration/mastodon/api_client.spec.ts delete mode 100644 packages/megalodon/test/integration/pleroma.spec.ts delete mode 100644 packages/megalodon/test/unit/mastodon.spec.ts delete mode 100644 packages/megalodon/test/unit/mastodon/api_client.spec.ts delete mode 100644 packages/megalodon/test/unit/pleroma/api_client.spec.ts delete mode 100644 packages/megalodon/test/unit/webo_socket.spec.ts diff --git a/packages/backend/src/server/api/mastodon/MastodonClientService.ts b/packages/backend/src/server/api/mastodon/MastodonClientService.ts index 82f9b7bfa9..474aaefb35 100644 --- a/packages/backend/src/server/api/mastodon/MastodonClientService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonClientService.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { megalodon, MegalodonInterface } from 'megalodon'; +import { Misskey } from 'megalodon'; import { Injectable } from '@nestjs/common'; import { MiLocalUser } from '@/models/User.js'; import { AuthenticateService } from '@/server/api/AuthenticateService.js'; @@ -18,7 +18,7 @@ export class MastodonClientService { /** * Gets the authenticated user and API client for a request. */ - public async getAuthClient(request: FastifyRequest, accessToken?: string | null): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> { + public async getAuthClient(request: FastifyRequest, accessToken?: string | null): Promise<{ client: Misskey, me: MiLocalUser | null }> { const authorization = request.headers.authorization; accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); @@ -41,14 +41,14 @@ export class MastodonClientService { /** * Creates an authenticated API client for a request. */ - public getClient(request: FastifyRequest, accessToken?: string | null): MegalodonInterface { + public getClient(request: FastifyRequest, accessToken?: string | null): Misskey { const authorization = request.headers.authorization; accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization); // TODO pass agent? const baseUrl = this.getBaseUrl(request); const userAgent = request.headers['user-agent']; - return megalodon('misskey', baseUrl, accessToken, userAgent); + return new Misskey(baseUrl, accessToken, userAgent); } /** diff --git a/packages/megalodon/src/entities/instance.ts b/packages/megalodon/src/entities/instance.ts index 8f4808be8f..7849a94aa7 100644 --- a/packages/megalodon/src/entities/instance.ts +++ b/packages/megalodon/src/entities/instance.ts @@ -10,7 +10,7 @@ namespace Entity { email: string version: string thumbnail: string | null - urls: URLs | null + urls: URLs stats: Stats languages: Array registrations: boolean diff --git a/packages/megalodon/src/friendica.ts b/packages/megalodon/src/friendica.ts deleted file mode 100644 index c5ee9d59ce..0000000000 --- a/packages/megalodon/src/friendica.ts +++ /dev/null @@ -1,2868 +0,0 @@ -import { OAuth2 } from 'oauth' -import FormData from 'form-data' -import parseLinkHeader from 'parse-link-header' - -import FriendicaAPI from './friendica/api_client' -import WebSocket from './friendica/web_socket' -import { MegalodonInterface, NoImplementedError } from './megalodon' -import Response from './response' -import Entity from './entity' -import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from './default' -import { ProxyConfig } from './proxy_config' -import OAuth from './oauth' -import { UnknownNotificationTypeError } from './notification' - -export default class Friendica implements MegalodonInterface { - public client: FriendicaAPI.Interface - public baseUrl: string - - /** - * @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.client = new FriendicaAPI.Client(baseUrl, token, agent, proxyConfig) - this.baseUrl = baseUrl - } - - public cancel(): void { - return this.client.cancel() - } - - /** - * First, call createApp to get client_id and client_secret. - * Next, call generateAuthUrl to get authorization url. - * @param client_name Form Data, which is sent to /api/v1/apps - * @param options Form Data, which is sent to /api/v1/apps. and properties should be **snake_case** - */ - public async registerApp( - client_name: string, - options: Partial<{ scopes: Array; redirect_uris: string; website: string }> - ): Promise { - const scopes = options.scopes || DEFAULT_SCOPE - return this.createApp(client_name, options).then(async appData => { - return this.generateAuthUrl(appData.client_id, appData.client_secret, { - scope: scopes, - redirect_uri: appData.redirect_uri - }).then(url => { - appData.url = url - return appData - }) - }) - } - - /** - * Call /api/v1/apps - * - * 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 }> - ): Promise { - const scopes = options.scopes || DEFAULT_SCOPE - const redirect_uris = options.redirect_uris || NO_REDIRECT - - const params: { - client_name: string - redirect_uris: string - scopes: string - website?: string - } = { - client_name: client_name, - redirect_uris: redirect_uris, - scopes: scopes.join(' ') - } - if (options.website) params.website = options.website - - return this.client - .post('/api/v1/apps', params) - .then((res: Response) => OAuth.AppData.from(res.data)) - } - - /** - * Generate authorization url using OAuth2. - * - * @param clientId your OAuth app's client ID - * @param clientSecret your OAuth app's client Secret - * @param options as property, redirect_uri and scope are available, and must be the same as when you register your app - */ - public generateAuthUrl( - clientId: string, - clientSecret: string, - options: Partial<{ scope: Array; redirect_uri: string }> - ): Promise { - const scope = options.scope || DEFAULT_SCOPE - const redirect_uri = options.redirect_uri || NO_REDIRECT - return new Promise(resolve => { - const oauth = new OAuth2(clientId, clientSecret, this.baseUrl, undefined, '/oauth/token') - const url = oauth.getAuthorizeUrl({ - redirect_uri: redirect_uri, - response_type: 'code', - client_id: clientId, - scope: scope.join(' ') - }) - resolve(url) - }) - } - - // ====================================== - // apps - // ====================================== - /** - * GET /api/v1/apps/verify_credentials - * - * @return An Application - */ - public verifyAppCredentials(): Promise> { - return this.client.get('/api/v1/apps/verify_credentials') - } - - // ====================================== - // apps/oauth - // ====================================== - /** - * POST /oauth/token - * - * Fetch OAuth access token. - * Get an access token based client_id and client_secret and authorization code. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param code will be generated by the link of #generateAuthUrl or #registerApp - * @param redirect_uri must be the same uri as the time when you register your OAuth application - */ - public async fetchAccessToken( - client_id: string | null, - client_secret: string, - code: string, - redirect_uri: string = NO_REDIRECT - ): Promise { - if (!client_id) { - throw new Error('client_id is required') - } - return this.client - .post('/oauth/token', { - client_id, - client_secret, - code, - redirect_uri, - grant_type: 'authorization_code' - }) - .then((res: Response) => OAuth.TokenData.from(res.data)) - } - - /** - * POST /oauth/token - * - * Refresh OAuth access token. - * Send refresh token and get new access token. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param refresh_token will be get #fetchAccessToken - */ - public async refreshToken(client_id: string, client_secret: string, refresh_token: string): Promise { - return this.client - .post('/oauth/token', { - client_id, - client_secret, - refresh_token, - grant_type: 'refresh_token' - }) - .then((res: Response) => OAuth.TokenData.from(res.data)) - } - - /** - * POST /oauth/revoke - * - * Revoke an OAuth token. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param token will be get #fetchAccessToken - */ - public async revokeToken(client_id: string, client_secret: string, token: string): Promise>> { - return this.client.post>('/oauth/revoke', { - client_id, - client_secret, - token - }) - } - - // ====================================== - // 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('friendica does not support') - reject(err) - }) - } - - /** - * GET /api/v1/accounts/verify_credentials - * - * @return Account. - */ - public async verifyAccountCredentials(): Promise> { - return this.client.get('/api/v1/accounts/verify_credentials').then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.account(res.data) - }) - }) - } - - 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 - } - fields_attributes?: Array<{ name: string; value: string }> - }): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - /** - * GET /api/v1/accounts/:id - * - * @param id The account ID. - * @return An account. - */ - public async getAccount(id: string): Promise> { - return this.client.get(`/api/v1/accounts/${id}`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.account(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/statuses - * - * @param id The account ID. - - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID but starting with most recent. - * @param options.min_id Return results newer than ID. - * @param options.pinned Return statuses which include pinned statuses. - * @param options.exclude_replies Return statuses which exclude replies. - * @param options.exclude_reblogs Return statuses which exclude reblogs. - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @return Account's statuses. - */ - public async getAccountStatuses( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - pinned?: boolean - exclude_replies?: boolean - exclude_reblogs?: boolean - only_media: boolean - } - ): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.pinned) { - params = Object.assign(params, { - pinned: options.pinned - }) - } - if (options.exclude_replies) { - params = Object.assign(params, { - exclude_replies: options.exclude_replies - }) - } - if (options.exclude_reblogs) { - params = Object.assign(params, { - exclude_reblogs: options.exclude_reblogs - }) - } - if (options.only_media) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - } - - return this.client.get>(`/api/v1/accounts/${id}/statuses`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.status(s)) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/follow - * - * @param id Target account ID. - * @return Relationship. - */ - public async subscribeAccount(id: string): Promise> { - const params = { - notify: true - } - return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/follow - * - * @param id Target account ID. - * @return Relationship. - */ - public async unsubscribeAccount(id: string): Promise> { - const params = { - notify: false - } - return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - public getAccountFavourites( - _id: string, - _options?: { - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - /** - * GET /api/v1/accounts/:id/followers - * - * @param id The account ID. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async getAccountFollowers( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - get_all?: boolean - sleep_ms?: number - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.urlToAccounts(`/api/v1/accounts/${id}/followers`, params, options?.get_all || false, options?.sleep_ms || 0) - } - - /** - * GET /api/v1/accounts/:id/following - * - * @param id The account ID. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async getAccountFollowing( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - get_all?: boolean - sleep_ms?: number - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.urlToAccounts(`/api/v1/accounts/${id}/following`, params, options?.get_all || false, options?.sleep_ms || 0) - } - - /** Helper function to optionally follow Link headers as pagination */ - private async urlToAccounts(url: string, params: Record, get_all: boolean, sleep_ms: number) { - const res = await this.client.get>(url, params) - let converted = Object.assign({}, res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - if (get_all && converted.headers.link) { - let parsed = parseLinkHeader(converted.headers.link) - while (parsed?.next) { - const nextRes = await this.client.get>(parsed?.next.url, undefined, undefined, true) - converted = Object.assign({}, converted, { - data: [...converted.data, ...nextRes.data.map(a => FriendicaAPI.Converter.account(a))] - }) - parsed = parseLinkHeader(nextRes.headers.link) - if (sleep_ms) { - await new Promise(converted => setTimeout(converted, sleep_ms)) - } - } - } - return converted - } - - /** - * GET /api/v1/accounts/:id/lists - * - * @param id The account ID. - * @return The array of lists. - */ - public async getAccountLists(id: string): Promise>> { - return this.client.get>(`/api/v1/accounts/${id}/lists`).then(res => { - return Object.assign(res, { - data: res.data.map(l => FriendicaAPI.Converter.list(l)) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/identity_proofs - * - * @param id The account ID. - * @return Array of IdentityProof - */ - public async getIdentityProof(id: string): Promise>> { - return this.client.get>(`/api/v1/accounts/${id}/identity_proofs`).then(res => { - return Object.assign(res, { - data: res.data.map(i => FriendicaAPI.Converter.identity_proof(i)) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/follow - * - * @param id The account ID. - * @param reblog Receive this account's reblogs in home timeline. - * @return Relationship - */ - public async followAccount(id: string, options?: { reblog?: boolean }): Promise> { - let params = {} - if (options) { - if (options.reblog !== undefined) { - params = Object.assign(params, { - reblog: options.reblog - }) - } - } - return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unfollow - * - * @param id The account ID. - * @return Relationship - */ - public async unfollowAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unfollow`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/block - * - * @param id The account ID. - * @return Relationship - */ - public async blockAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/block`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unblock - * - * @param id The account ID. - * @return RElationship - */ - public async unblockAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unblock`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/mute - * - * @param id The account ID. - * @param notifications Mute notifications in addition to statuses. - * @return Relationship - */ - public async muteAccount(id: string, notifications = true): Promise> { - return this.client - .post(`/api/v1/accounts/${id}/mute`, { - notifications: notifications - }) - .then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unmute - * - * @param id The account ID. - * @return Relationship - */ - public async unmuteAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unmute`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/pin - * - * @param id The account ID. - * @return Relationship - */ - public async pinAccount(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - /** - * POST /api/v1/accounts/:id/unpin - * - * @param id The account ID. - * @return Relationship - */ - public async unpinAccount(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - /** - * GET /api/v1/accounts/relationships - * - * @param id The account ID. - * @return Relationship - */ - public async getRelationship(id: string): Promise> { - return this.client - .get>('/api/v1/accounts/relationships', { - id: [id] - }) - .then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data[0]) - }) - }) - } - - /** - * Get multiple relationships in one method - * - * @param ids Array of account IDs. - * @return Array of Relationship. - */ - public async getRelationships(ids: Array): Promise>> { - return this.client - .get>('/api/v1/accounts/relationships', { - id: ids - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(r => FriendicaAPI.Converter.relationship(r)) - }) - }) - } - - /** - * GET /api/v1/accounts/search - * - * @param q Search query. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async searchAccount( - q: string, - options?: { - following?: boolean - resolve?: boolean - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = { q: q } - if (options) { - if (options.following !== undefined && options.following !== null) { - params = Object.assign(params, { - following: options.following - }) - } - if (options.resolve !== undefined && options.resolve !== null) { - params = Object.assign(params, { - resolve: options.resolve - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/accounts/search', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/bookmarks - // ====================================== - /** - * GET /api/v1/bookmarks - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getBookmarks(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - } - return this.client.get>('/api/v1/bookmarks', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // accounts/favourites - // ====================================== - /** - * GET /api/v1/favourites - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getFavourites(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/favourites', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // accounts/mutes - // ====================================== - /** - * GET /api/v1/mutes - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getMutes(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/mutes', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/blocks - // ====================================== - /** - * GET /api/v1/blocks - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/blocks', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/domain_blocks - // ====================================== - public async getDomainBlocks(_options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - public blockDomain(_domain: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - public unblockDomain(_domain: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // accounts/filters - // ====================================== - /** - * GET /api/v1/filters - * - * @return Array of filters. - */ - public async getFilters(): Promise>> { - return this.client.get>('/api/v1/filters').then(res => { - return Object.assign(res, { - data: res.data.map(f => FriendicaAPI.Converter.filter(f)) - }) - }) - } - - public async getFilter(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica 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('friendica 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('friendica does not support') - reject(err) - }) - } - - public async deleteFilter(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // accounts/reports - // ====================================== - public async report( - _account_id: string, - _options?: { - status_ids?: Array - comment: string - forward?: boolean - category?: Entity.Category - rule_ids?: Array - } - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // accounts/follow_requests - // ====================================== - /** - * GET /api/v1/follow_requests - * - * @param limit Maximum number of results. - * @return Array of FollowRequest. - */ - public async getFollowRequests(limit?: number): Promise>> { - if (limit) { - return this.client - .get>('/api/v1/follow_requests', { - limit: limit - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.follow_request(a)) - }) - }) - } else { - return this.client.get>('/api/v1/follow_requests').then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.follow_request(a)) - }) - }) - } - } - - /** - * POST /api/v1/follow_requests/:id/authorize - * - * @param id The FollowRequest ID. - * @return Relationship. - */ - public async acceptFollowRequest(id: string): Promise> { - return this.client.post(`/api/v1/follow_requests/${id}/authorize`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/follow_requests/:id/reject - * - * @param id The FollowRequest ID. - * @return Relationship. - */ - public async rejectFollowRequest(id: string): Promise> { - return this.client.post(`/api/v1/follow_requests/${id}/reject`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.relationship(res.data) - }) - }) - } - - // ====================================== - // accounts/endorsements - // ====================================== - /** - * GET /api/v1/endorsements - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return Array of accounts. - */ - public async getEndorsements(options?: { limit?: number; max_id?: string; since_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get>('/api/v1/endorsements', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/featured_tags - // ====================================== - public async getFeaturedTags(): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - public async createFeaturedTag(_name: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - public deleteFeaturedTag(_id: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - public async getSuggestedTags(): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // accounts/preferences - // ====================================== - /** - * GET /api/v1/preferences - * - * @return Preferences. - */ - public async getPreferences(): Promise> { - return this.client.get('/api/v1/preferences').then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.preferences(res.data) - }) - }) - } - - // ====================================== - // accounts/followed_tags - // ====================================== - public async getFollowedTags(): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // accounts/suggestions - // ====================================== - /** - * GET /api/v1/suggestions - * - * @param limit Maximum number of results. - * @return Array of accounts. - */ - public async getSuggestions(limit?: number): Promise>> { - if (limit) { - return this.client - .get>('/api/v1/suggestions', { - limit: limit - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } else { - return this.client.get>('/api/v1/suggestions').then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - } - - // ====================================== - // accounts/tags - // ====================================== - /** - * GET /api/v1/tags/:id - * - * @param id Target hashtag id. - * @return Tag - */ - public async getTag(id: string): Promise> { - return this.client.get(`/api/v1/tags/${id}`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.tag(res.data) - }) - }) - } - - /** - * POST /api/v1/tags/:id/follow - * - * @param id Target hashtag id. - * @return Tag - */ - public async followTag(id: string): Promise> { - return this.client.post(`/api/v1/tags/${id}/follow`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.tag(res.data) - }) - }) - } - - /** - * POST /api/v1/tags/:id/unfollow - * - * @param id Target hashtag id. - * @return Tag - */ - public async unfollowTag(id: string): Promise> { - return this.client.post(`/api/v1/tags/${id}/unfollow`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.tag(res.data) - }) - }) - } - - // ====================================== - // statuses - // ====================================== - /** - * POST /api/v1/statuses - * - * @param status Text content of status. - * @param options.media_ids Array of Attachment ids. - * @param options.poll Poll object. - * @param options.in_reply_to_id ID of the status being replied to, if status is a reply. - * @param options.sensitive Mark status and attached media as sensitive? - * @param options.spoiler_text Text to be shown as a warning or subject before the actual content. - * @param options.visibility Visibility of the posted status. - * @param options.scheduled_at ISO 8601 Datetime at which to schedule a status. - * @param options.language ISO 639 language code for this status. - * @param options.quote_id ID of the status being quoted to, if status is a quote. - * @return Status. When options.scheduled_at is present, ScheduledStatus is returned instead. - */ - 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 = { - status: status - } - if (options) { - if (options.media_ids) { - params = Object.assign(params, { - media_ids: options.media_ids - }) - } - if (options.poll) { - let pollParam = { - options: options.poll.options, - expires_in: options.poll.expires_in - } - if (options.poll.multiple !== undefined) { - pollParam = Object.assign(pollParam, { - multiple: options.poll.multiple - }) - } - if (options.poll.hide_totals !== undefined) { - pollParam = Object.assign(pollParam, { - hide_totals: options.poll.hide_totals - }) - } - params = Object.assign(params, { - poll: pollParam - }) - } - if (options.in_reply_to_id) { - params = Object.assign(params, { - in_reply_to_id: options.in_reply_to_id - }) - } - if (options.sensitive !== undefined) { - params = Object.assign(params, { - sensitive: options.sensitive - }) - } - if (options.spoiler_text) { - params = Object.assign(params, { - spoiler_text: options.spoiler_text - }) - } - if (options.visibility) { - params = Object.assign(params, { - visibility: options.visibility - }) - } - if (options.scheduled_at) { - params = Object.assign(params, { - scheduled_at: options.scheduled_at - }) - } - if (options.language) { - params = Object.assign(params, { - language: options.language - }) - } - if (options.quote_id) { - params = Object.assign(params, { - quote_id: options.quote_id - }) - } - } - if (options.scheduled_at) { - return this.client.post('/api/v1/statuses', params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.scheduled_status(res.data) - }) - }) - } - return this.client.post('/api/v1/statuses', params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - /** - * GET /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - public async getStatus(id: string): Promise> { - return this.client.get(`/api/v1/statuses/${id}`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - PUT /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - 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> { - let params = {} - if (options.status) { - params = Object.assign(params, { - status: options.status - }) - } - if (options.spoiler_text) { - params = Object.assign(params, { - spoiler_text: options.spoiler_text - }) - } - if (options.sensitive) { - params = Object.assign(params, { - sensitive: options.sensitive - }) - } - if (options.media_ids) { - params = Object.assign(params, { - media_ids: options.media_ids - }) - } - if (options.poll) { - let pollParam = {} - if (options.poll.options !== undefined) { - pollParam = Object.assign(pollParam, { - options: options.poll.options - }) - } - if (options.poll.expires_in !== undefined) { - pollParam = Object.assign(pollParam, { - expires_in: options.poll.expires_in - }) - } - if (options.poll.multiple !== undefined) { - pollParam = Object.assign(pollParam, { - multiple: options.poll.multiple - }) - } - if (options.poll.hide_totals !== undefined) { - pollParam = Object.assign(pollParam, { - hide_totals: options.poll.hide_totals - }) - } - params = Object.assign(params, { - poll: pollParam - }) - } - return this.client.put(`/api/v1/statuses/${id}`, params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * DELETE /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - public async deleteStatus(id: string): Promise> { - return this.client.del(`/api/v1/statuses/${id}`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/context - * - * Get parent and child statuses. - * @param id The target status id. - * @return Context - */ - public async getStatusContext( - id: string, - options?: { limit?: number; max_id?: string; since_id?: string } - ): Promise> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get(`/api/v1/statuses/${id}/context`, params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.context(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/source - * - * Obtain the source properties for a status so that it can be edited. - * @param id The target status id. - * @return StatusSource - */ - public async getStatusSource(id: string): Promise> { - return this.client.get(`/api/v1/statuses/${id}/source`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status_source(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/reblogged_by - * - * @param id The target status id. - * @return Array of accounts. - */ - public async getStatusRebloggedBy(id: string): Promise>> { - return this.client.get>(`/api/v1/statuses/${id}/reblogged_by`).then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/favourited_by - * - * @param id The target status id. - * @return Array of accounts. - */ - public async getStatusFavouritedBy(id: string): Promise>> { - return this.client.get>(`/api/v1/statuses/${id}/favourited_by`).then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/favourite - * - * @param id The target status id. - * @return Status. - */ - public async favouriteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/favourite`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unfavourite - * - * @param id The target status id. - * @return Status. - */ - public async unfavouriteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unfavourite`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/reblog - * - * @param id The target status id. - * @return Status. - */ - public async reblogStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/reblog`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unreblog - * - * @param id The target status id. - * @return Status. - */ - public async unreblogStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unreblog`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/bookmark - * - * @param id The target status id. - * @return Status. - */ - public async bookmarkStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/bookmark`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unbookmark - * - * @param id The target status id. - * @return Status. - */ - public async unbookmarkStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unbookmark`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/mute - * - * @param id The target status id. - * @return Status - */ - public async muteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/mute`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unmute - * - * @param id The target status id. - * @return Status - */ - public async unmuteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unmute`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/pin - * @param id The target status id. - * @return Status - */ - public async pinStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/pin`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unpin - * - * @param id The target status id. - * @return Status - */ - public async unpinStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unpin`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.status(res.data) - }) - }) - } - - // ====================================== - // statuses/media - // ====================================== - /** - * POST /api/v2/media - * - * @param file The file to be attached, using multipart form data. - * @param options.description A plain-text description of the media. - * @param options.focus Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. - * @return Attachment - */ - public async uploadMedia( - file: any, - options?: { description?: string; focus?: string } - ): Promise> { - const formData = new FormData() - formData.append('file', file) - if (options) { - if (options.description) { - formData.append('description', options.description) - } - if (options.focus) { - formData.append('focus', options.focus) - } - } - return this.client.postForm('/api/v2/media', formData).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.async_attachment(res.data) - }) - }) - } - - /** - * GET /api/v1/media/:id - * - * @param id Target media ID. - * @return Attachment - */ - public async getMedia(id: string): Promise> { - const res = await this.client.get(`/api/v1/media/${id}`) - - return Object.assign(res, { - data: FriendicaAPI.Converter.attachment(res.data) - }) - } - - /** - * PUT /api/v1/media/:id - * - * @param id Target media ID. - * @param options.file The file to be attached, using multipart form data. - * @param options.description A plain-text description of the media. - * @param options.focus Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. - * @param options.is_sensitive Whether the media is sensitive. - * @return Attachment - */ - public async updateMedia( - id: string, - options?: { - file?: any - description?: string - focus?: string - } - ): Promise> { - const formData = new FormData() - if (options) { - if (options.file) { - formData.append('file', options.file) - } - if (options.description) { - formData.append('description', options.description) - } - if (options.focus) { - formData.append('focus', options.focus) - } - } - return this.client.putForm(`/api/v1/media/${id}`, formData).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.attachment(res.data) - }) - }) - } - - // ====================================== - // statuses/polls - // ====================================== - /** - * GET /api/v1/polls/:id - * - * @param id Target poll ID. - * @return Poll - */ - public async getPoll(id: string): Promise> { - return this.client.get(`/api/v1/polls/${id}`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.poll(res.data) - }) - }) - } - - public async votePoll(_id: string, _choices: Array): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // statuses/scheduled_statuses - // ====================================== - /** - * GET /api/v1/scheduled_statuses - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of scheduled statuses. - */ - public async getScheduledStatuses(options?: { - limit?: number | null - max_id?: string | null - since_id?: string | null - min_id?: string | null - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - } - return this.client.get>('/api/v1/scheduled_statuses', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.scheduled_status(s)) - }) - }) - } - - /** - * GET /api/v1/scheduled_statuses/:id - * - * @param id Target status ID. - * @return ScheduledStatus. - */ - public async getScheduledStatus(id: string): Promise> { - return this.client.get(`/api/v1/scheduled_statuses/${id}`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.scheduled_status(res.data) - }) - }) - } - - public async scheduleStatus(_id: string, _scheduled_at?: string | null): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - /** - * DELETE /api/v1/scheduled_statuses/:id - * - * @param id Target scheduled status ID. - */ - public cancelScheduledStatus(id: string): Promise>> { - return this.client.del>(`/api/v1/scheduled_statuses/${id}`) - } - - // ====================================== - // timelines - // ====================================== - /** - * GET /api/v1/timelines/public - * - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getPublicTimeline(options?: { - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = { - local: false - } - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/public', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/public - * - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getLocalTimeline(options?: { - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = { - local: true - } - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/public', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/tag/:hashtag - * - * @param hashtag Content of a #hashtag, not including # symbol. - * @param options.local Show only local statuses? Defaults to false. - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getTagTimeline( - hashtag: string, - options?: { - local?: boolean - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>(`/api/v1/timelines/tag/${hashtag}`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/home - * - * @param options.local Show only local statuses? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getHomeTimeline(options?: { - local?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/home', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/list/:list_id - * - * @param list_id Local ID of the list in the database. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getListTimeline( - list_id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>(`/api/v1/timelines/list/${list_id}`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => FriendicaAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // timelines/conversations - // ====================================== - /** - * GET /api/v1/conversations - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getConversationTimeline(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/conversations', params).then(res => { - return Object.assign(res, { - data: res.data.map(c => FriendicaAPI.Converter.conversation(c)) - }) - }) - } - - /** - * DELETE /api/v1/conversations/:id - * - * @param id Target conversation ID. - */ - public deleteConversation(id: string): Promise>> { - return this.client.del>(`/api/v1/conversations/${id}`) - } - - /** - * POST /api/v1/conversations/:id/read - * - * @param id Target conversation ID. - * @return Conversation. - */ - public async readConversation(id: string): Promise> { - return this.client.post(`/api/v1/conversations/${id}/read`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.conversation(res.data) - }) - }) - } - - // ====================================== - // timelines/lists - // ====================================== - /** - * GET /api/v1/lists - * - * @return Array of lists. - */ - public async getLists(): Promise>> { - return this.client.get>('/api/v1/lists').then(res => { - return Object.assign(res, { - data: res.data.map(l => FriendicaAPI.Converter.list(l)) - }) - }) - } - - /** - * GET /api/v1/lists/:id - * - * @param id Target list ID. - * @return List. - */ - public async getList(id: string): Promise> { - return this.client.get(`/api/v1/lists/${id}`).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.list(res.data) - }) - }) - } - - /** - * POST /api/v1/lists - * - * @param title List name. - * @return List. - */ - public async createList(title: string): Promise> { - return this.client - .post('/api/v1/lists', { - title: title - }) - .then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.list(res.data) - }) - }) - } - - /** - * PUT /api/v1/lists/:id - * - * @param id Target list ID. - * @param title New list name. - * @return List. - */ - public async updateList(id: string, title: string): Promise> { - return this.client - .put(`/api/v1/lists/${id}`, { - title: title - }) - .then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.list(res.data) - }) - }) - } - - /** - * DELETE /api/v1/lists/:id - * - * @param id Target list ID. - */ - public deleteList(id: string): Promise>> { - return this.client.del>(`/api/v1/lists/${id}`) - } - - /** - * GET /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param options.limit Max number of results to return. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getAccountsInList( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get>(`/api/v1/lists/${id}/accounts`, params).then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - - /** - * POST /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param account_ids Array of account IDs to add to the list. - */ - public addAccountsToList(id: string, account_ids: Array): Promise>> { - return this.client.post>(`/api/v1/lists/${id}/accounts`, { - account_ids: account_ids - }) - } - - /** - * DELETE /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param account_ids Array of account IDs to add to the list. - */ - public deleteAccountsFromList(id: string, account_ids: Array): Promise>> { - return this.client.del>(`/api/v1/lists/${id}/accounts`, { - account_ids: account_ids - }) - } - - // ====================================== - // timelines/markers - // ====================================== - public async getMarkers(_timeline: Array): Promise>> { - return new Promise(resolve => { - const res: Response = { - data: {}, - status: 200, - statusText: '200', - headers: {} - } - resolve(res) - }) - } - - public async saveMarkers(_options?: { - home?: { last_read_id: string } - notifications?: { last_read_id: string } - }): Promise> { - return new Promise(resolve => { - const res: Response = { - data: {}, - status: 200, - statusText: '200', - headers: {} - } - resolve(res) - }) - } - - // ====================================== - // notifications - // ====================================== - /** - * GET /api/v1/notifications - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @param options.exclude_types Array of types to exclude. - * @param options.account_id Return only notifications received from this account. - * @return Array of notifications. - */ - public async getNotifications(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - exclude_types?: Array - account_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.exclude_types) { - params = Object.assign(params, { - exclude_types: options.exclude_types.map(e => FriendicaAPI.Converter.encodeNotificationType(e)) - }) - } - if (options.account_id) { - params = Object.assign(params, { - account_id: options.account_id - }) - } - } - return this.client.get>('/api/v1/notifications', params).then(res => { - return Object.assign(res, { - data: res.data.flatMap(n => { - const notify = FriendicaAPI.Converter.notification(n) - if (notify instanceof UnknownNotificationTypeError) return [] - return notify - }) - }) - }) - } - - /** - * GET /api/v1/notifications/:id - * - * @param id Target notification ID. - * @return Notification. - */ - public async getNotification(id: string): Promise> { - const res = await this.client.get(`/api/v1/notifications/${id}`) - const notify = FriendicaAPI.Converter.notification(res.data) - if (notify instanceof UnknownNotificationTypeError) { - throw new UnknownNotificationTypeError() - } - return { ...res, data: notify } - } - - /** - * POST /api/v1/notifications/clear - */ - public dismissNotifications(): Promise>> { - return this.client.post>('/api/v1/notifications/clear') - } - - /** - * POST /api/v1/notifications/:id/dismiss - * - * @param id Target notification ID. - */ - public dismissNotification(id: string): Promise>> { - return this.client.post>(`/api/v1/notifications/${id}/dismiss`) - } - - public readNotifications(_options: { - id?: string - max_id?: string - }): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // notifications/push - // ====================================== - /** - * POST /api/v1/push/subscription - * - * @param subscription[endpoint] Endpoint URL that is called when a notification event occurs. - * @param subscription[keys][p256dh] User agent public key. Base64 encoded string of public key of ECDH key using prime256v1 curve. - * @param subscription[keys] Auth secret. Base64 encoded string of 16 bytes of random data. - * @param data[alerts][follow] Receive follow notifications? - * @param data[alerts][favourite] Receive favourite notifications? - * @param data[alerts][reblog] Receive reblog notifictaions? - * @param data[alerts][mention] Receive mention notifications? - * @param data[alerts][poll] Receive poll notifications? - * @return PushSubscription. - */ - 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> { - let params = { - subscription - } - if (data) { - params = Object.assign(params, { - data - }) - } - return this.client.post('/api/v1/push/subscription', params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * GET /api/v1/push/subscription - * - * @return PushSubscription. - */ - public async getPushSubscription(): Promise> { - return this.client.get('/api/v1/push/subscription').then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * PUT /api/v1/push/subscription - * - * @param data[alerts][follow] Receive follow notifications? - * @param data[alerts][favourite] Receive favourite notifications? - * @param data[alerts][reblog] Receive reblog notifictaions? - * @param data[alerts][mention] Receive mention notifications? - * @param data[alerts][poll] Receive poll notifications? - * @return PushSubscription. - */ - public async updatePushSubscription( - data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean } } | null - ): Promise> { - let params = {} - if (data) { - params = Object.assign(params, { - data - }) - } - return this.client.put('/api/v1/push/subscription', params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * DELETE /api/v1/push/subscription - */ - public deletePushSubscription(): Promise>> { - return this.client.del>('/api/v1/push/subscription') - } - - // ====================================== - // search - // ====================================== - /** - * GET /api/v2/search - * - * @param q The search query. - * @param options.type Enum of search target. - * @param options.limit Maximum number of results to load, per type. Defaults to 20. Max 40. - * @param options.max_id Return results older than this id. - * @param options.min_id Return results immediately newer than this id. - * @param options.resolve Attempt WebFinger lookup. Defaults to false. - * @param options.following Only include accounts that the user is following. Defaults to false. - * @param options.account_id If provided, statuses returned will be authored only by this account. - * @param options.exclude_unreviewed Filter out unreviewed tags? Defaults to false. - * @return Results. - */ - public async search( - q: string, - options?: { - type?: 'accounts' | 'hashtags' | 'statuses' - limit?: number - max_id?: string - min_id?: string - resolve?: boolean - offset?: number - following?: boolean - account_id?: string - exclude_unreviewed?: boolean - } - ): Promise> { - let params = { - q - } - if (options) { - if (options.type) { - params = Object.assign(params, { - type: options.type - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.resolve !== undefined) { - params = Object.assign(params, { - resolve: options.resolve - }) - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset - }) - } - if (options.following !== undefined) { - params = Object.assign(params, { - following: options.following - }) - } - if (options.account_id) { - params = Object.assign(params, { - account_id: options.account_id - }) - } - if (options.exclude_unreviewed) { - params = Object.assign(params, { - exclude_unreviewed: options.exclude_unreviewed - }) - } - } - return this.client.get('/api/v2/search', params).then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.results(res.data) - }) - }) - } - - // ====================================== - // instance - // ====================================== - /** - * GET /api/v1/instance - */ - public async getInstance(): Promise> { - return this.client.get('/api/v1/instance').then(res => { - return Object.assign(res, { - data: FriendicaAPI.Converter.instance(res.data) - }) - }) - } - - /** - * GET /api/v1/instance/peers - */ - public getInstancePeers(): Promise>> { - return this.client.get>('/api/v1/instance/peers') - } - - /** - * GET /api/v1/instance/activity - */ - public async getInstanceActivity(): Promise>> { - return this.client.get>('/api/v1/instance/activity').then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.activity(a)) - }) - }) - } - - // ====================================== - // instance/trends - // ====================================== - /** - * GET /api/v1/trends - * - * @param limit Maximum number of results to return. Defaults to 10. - */ - public async getInstanceTrends(limit?: number | null): Promise>> { - let params = {} - if (limit) { - params = Object.assign(params, { - limit - }) - } - return this.client.get>('/api/v1/trends', params).then(res => { - return Object.assign(res, { - data: res.data.map(t => FriendicaAPI.Converter.tag(t)) - }) - }) - } - - // ====================================== - // instance/directory - // ====================================== - /** - * GET /api/v1/directory - * - * @param options.limit How many accounts to load. Default 40. - * @param options.offset How many accounts to skip before returning results. Default 0. - * @param options.order Order of results. - * @param options.local Only return local accounts. - * @return Array of accounts. - */ - public async getInstanceDirectory(options?: { - limit?: number - offset?: number - order?: 'active' | 'new' - local?: boolean - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset - }) - } - if (options.order) { - params = Object.assign(params, { - order: options.order - }) - } - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - } - return this.client.get>('/api/v1/directory', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => FriendicaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // instance/custom_emojis - // ====================================== - /** - * GET /api/v1/custom_emojis - * - * @return Array of emojis. - */ - public async getInstanceCustomEmojis(): Promise>> { - return this.client.get>('/api/v1/custom_emojis').then(res => { - return Object.assign(res, { - data: res.data.map(e => FriendicaAPI.Converter.emoji(e)) - }) - }) - } - - // ====================================== - // instance/announcements - // ====================================== - /** - * GET /api/v1/announcements - * - * @return Array of announcements. - */ - public async getInstanceAnnouncements(): Promise>> { - return new Promise(resolve => { - resolve({ - data: [], - status: 200, - statusText: '200', - headers: null - }) - }) - } - - /** - * POST /api/v1/announcements/:id/dismiss - * - * @param id The ID of the Announcement in the database. - */ - public async dismissInstanceAnnouncement(_id: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - /** - * PUT /api/v1/announcements/:id/reactions/:name - * - * @param id The ID of the Announcement in the database. - * @param name Unicode emoji, or the shortcode of a custom emoji. - */ - public async addReactionToAnnouncement(_id: string, _name: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - /** - * DELETE /api/v1/announcements/:id/reactions/:name - * - * @param id The ID of the Announcement in the database. - * @param name Unicode emoji, or the shortcode of a custom emoji. - */ - public async removeReactionFromAnnouncement(_id: string, _name: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // Emoji reactions - // ====================================== - public async createEmojiReaction(_id: string, _emoji: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - public async deleteEmojiReaction(_id: string, _emoji: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - public async getEmojiReactions(_id: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - public async getEmojiReaction(_id: string, _emoji: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('friendica does not support') - reject(err) - }) - } - - // ====================================== - // WebSocket - // ====================================== - public userSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'user') - } - - public publicSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'public') - } - - public localSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'public:local') - } - - public tagSocket(tag: string): WebSocket { - return this.client.socket('/api/v1/streaming', 'hashtag', `tag=${tag}`) - } - - public listSocket(list_id: string): WebSocket { - return this.client.socket('/api/v1/streaming', 'list', `list=${list_id}`) - } - - public directSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'direct') - } -} diff --git a/packages/megalodon/src/friendica/api_client.ts b/packages/megalodon/src/friendica/api_client.ts deleted file mode 100644 index b0d3399784..0000000000 --- a/packages/megalodon/src/friendica/api_client.ts +++ /dev/null @@ -1,769 +0,0 @@ -import axios, { AxiosResponse, AxiosRequestConfig } from 'axios' -import objectAssignDeep from 'object-assign-deep' - -import WebSocket from './web_socket' -import Response from '../response' -import { RequestCanceledError } from '../cancel' -import proxyAgent, { ProxyConfig } from '../proxy_config' -import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from '../default' -import FriendicaEntity from './entity' -import MegalodonEntity from '../entity' -import NotificationType, { UnknownNotificationTypeError } from '../notification' -import FriendicaNotificationType from './notification' - -namespace FriendicaAPI { - /** - * Interface - */ - export interface Interface { - get(path: string, params?: any, headers?: { [key: string]: string }, pathIsFullyQualified?: boolean): Promise> - put(path: string, params?: any, headers?: { [key: string]: string }): Promise> - putForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - patch(path: string, params?: any, headers?: { [key: string]: string }): Promise> - patchForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - post(path: string, params?: any, headers?: { [key: string]: string }): Promise> - postForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - del(path: string, params?: any, headers?: { [key: string]: string }): Promise> - cancel(): void - socket(path: string, stream: string, params?: string): WebSocket - } - - /** - * Friendica API client. - * - * Using axios for request, you will handle promises. - */ - export class Client implements Interface { - static DEFAULT_SCOPE = DEFAULT_SCOPE - static DEFAULT_URL = 'https://mastodon.social' - static NO_REDIRECT = NO_REDIRECT - - private accessToken: string | null - private baseUrl: string - private userAgent: string - private abortController: AbortController - private proxyConfig: ProxyConfig | false = false - - /** - * @param baseUrl hostname or base URL - * @param accessToken access token from OAuth2 authorization - * @param userAgent UserAgent is specified in header on request. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - */ - constructor( - baseUrl: string, - accessToken: string | null = null, - userAgent: string = DEFAULT_UA, - proxyConfig: ProxyConfig | false = false - ) { - this.accessToken = accessToken - this.baseUrl = baseUrl - this.userAgent = userAgent - this.proxyConfig = proxyConfig - this.abortController = new AbortController() - axios.defaults.signal = this.abortController.signal - } - - /** - * GET request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Query parameters - * @param headers Request header object - */ - public async get( - path: string, - params = {}, - headers: { [key: string]: string } = {}, - pathIsFullyQualified = false - ): Promise> { - let options: AxiosRequestConfig = { - params: params, - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .get((pathIsFullyQualified ? '' : this.baseUrl) + path, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PUT request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async put(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .put(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PUT request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async putForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .putForm(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PATCH request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async patch(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .patch(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PATCH request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async patchForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .patchForm(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * POST request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async post(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios.post(this.baseUrl + path, params, options).then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * POST request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async postForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios.postForm(this.baseUrl + path, params, options).then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * DELETE request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async del(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - data: params, - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .delete(this.baseUrl + path, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * Cancel all requests in this instance. - * @returns void - */ - public cancel(): void { - return this.abortController.abort() - } - - /** - * Get connection and receive websocket connection for Pleroma API. - * - * @param path relative path from baseUrl: normally it is `/streaming`. - * @param stream Stream name, please refer: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/mastodon_api/mastodon_socket.ex#L19-28 - * @returns WebSocket, which inherits from EventEmitter - */ - public socket(path: string, stream: string, params?: string): WebSocket { - if (!this.accessToken) { - throw new Error('accessToken is required') - } - const url = this.baseUrl + path - const streaming = new WebSocket(url, stream, params, this.accessToken, this.userAgent, this.proxyConfig) - process.nextTick(() => { - streaming.start() - }) - return streaming - } - } - - export namespace Entity { - export type Account = FriendicaEntity.Account - export type Activity = FriendicaEntity.Activity - export type Application = FriendicaEntity.Application - export type AsyncAttachment = FriendicaEntity.AsyncAttachment - export type Attachment = FriendicaEntity.Attachment - export type Card = FriendicaEntity.Card - export type Context = FriendicaEntity.Context - export type Conversation = FriendicaEntity.Conversation - export type Emoji = FriendicaEntity.Emoji - export type FeaturedTag = FriendicaEntity.FeaturedTag - export type Field = FriendicaEntity.Field - export type Filter = FriendicaEntity.Filter - export type FollowRequest = FriendicaEntity.FollowRequest - export type History = FriendicaEntity.History - export type IdentityProof = FriendicaEntity.IdentityProof - export type Instance = FriendicaEntity.Instance - export type List = FriendicaEntity.List - export type Marker = FriendicaEntity.Marker - export type Mention = FriendicaEntity.Mention - export type Notification = FriendicaEntity.Notification - export type Poll = FriendicaEntity.Poll - export type PollOption = FriendicaEntity.PollOption - export type Preferences = FriendicaEntity.Preferences - export type PushSubscription = FriendicaEntity.PushSubscription - export type Relationship = FriendicaEntity.Relationship - export type Report = FriendicaEntity.Report - export type Results = FriendicaEntity.Results - export type ScheduledStatus = FriendicaEntity.ScheduledStatus - export type Source = FriendicaEntity.Source - export type Stats = FriendicaEntity.Stats - export type Status = FriendicaEntity.Status - export type StatusParams = FriendicaEntity.StatusParams - export type StatusSource = FriendicaEntity.StatusSource - export type Tag = FriendicaEntity.Tag - export type Token = FriendicaEntity.Token - export type URLs = FriendicaEntity.URLs - } - - export namespace Converter { - export const encodeNotificationType = ( - t: MegalodonEntity.NotificationType - ): FriendicaEntity.NotificationType | UnknownNotificationTypeError => { - switch (t) { - case NotificationType.Follow: - return FriendicaNotificationType.Follow - case NotificationType.Favourite: - return FriendicaNotificationType.Favourite - case NotificationType.Reblog: - return FriendicaNotificationType.Reblog - case NotificationType.Mention: - return FriendicaNotificationType.Mention - case NotificationType.FollowRequest: - return FriendicaNotificationType.FollowRequest - case NotificationType.Status: - return FriendicaNotificationType.Status - case NotificationType.PollExpired: - return FriendicaNotificationType.Poll - case NotificationType.Update: - return FriendicaNotificationType.Update - default: - return new UnknownNotificationTypeError() - } - } - - export const decodeNotificationType = ( - t: FriendicaEntity.NotificationType - ): MegalodonEntity.NotificationType | UnknownNotificationTypeError => { - switch (t) { - case FriendicaNotificationType.Follow: - return NotificationType.Follow - case FriendicaNotificationType.Favourite: - return NotificationType.Favourite - case FriendicaNotificationType.Mention: - return NotificationType.Mention - case FriendicaNotificationType.Reblog: - return NotificationType.Reblog - case FriendicaNotificationType.FollowRequest: - return NotificationType.FollowRequest - case FriendicaNotificationType.Status: - return NotificationType.Status - case FriendicaNotificationType.Poll: - return NotificationType.PollExpired - case FriendicaNotificationType.Update: - return NotificationType.Update - default: - return new UnknownNotificationTypeError() - } - } - - export const account = (a: Entity.Account): MegalodonEntity.Account => ({ - id: a.id, - username: a.username, - acct: a.acct, - display_name: a.display_name, - locked: a.locked, - discoverable: a.discoverable, - group: a.group, - noindex: null, - suspended: null, - limited: null, - created_at: a.created_at, - followers_count: a.followers_count, - following_count: a.following_count, - statuses_count: a.statuses_count, - note: a.note, - url: a.url, - avatar: a.avatar, - avatar_static: a.avatar_static, - header: a.header, - header_static: a.header_static, - emojis: a.emojis.map(e => emoji(e)), - moved: a.moved ? account(a.moved) : null, - fields: a.fields.map(f => field(f)), - bot: a.bot, - source: a.source ? source(a.source) : undefined - }) - export const activity = (a: Entity.Activity): MegalodonEntity.Activity => a - export const application = (a: Entity.Application): MegalodonEntity.Application => a - export const attachment = (a: Entity.Attachment): MegalodonEntity.Attachment => a - export const async_attachment = (a: Entity.AsyncAttachment) => { - if (a.url) { - return { - id: a.id, - type: a.type, - url: a.url, - remote_url: a.remote_url, - preview_url: a.preview_url, - text_url: a.text_url, - meta: a.meta, - description: a.description, - blurhash: a.blurhash - } as MegalodonEntity.Attachment - } else { - return a as MegalodonEntity.AsyncAttachment - } - } - export const card = (c: Entity.Card): MegalodonEntity.Card => ({ - url: c.url, - title: c.title, - description: c.description, - type: c.type, - image: c.image, - author_name: c.author_name, - author_url: c.author_url, - provider_name: c.provider_name, - provider_url: c.provider_url, - html: c.html, - width: c.width, - height: c.height, - embed_url: null, - blurhash: c.blurhash - }) - export const context = (c: Entity.Context): MegalodonEntity.Context => ({ - ancestors: Array.isArray(c.ancestors) ? c.ancestors.map(a => status(a)) : [], - descendants: Array.isArray(c.descendants) ? c.descendants.map(d => status(d)) : [] - }) - export const conversation = (c: Entity.Conversation): MegalodonEntity.Conversation => ({ - id: c.id, - accounts: Array.isArray(c.accounts) ? c.accounts.map(a => account(a)) : [], - last_status: c.last_status ? status(c.last_status) : null, - unread: c.unread - }) - export const emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => ({ - shortcode: e.shortcode, - static_url: e.static_url, - url: e.url, - visible_in_picker: e.visible_in_picker - }) - export const featured_tag = (e: Entity.FeaturedTag): MegalodonEntity.FeaturedTag => e - export const field = (f: Entity.Field): MegalodonEntity.Field => f - export const filter = (f: Entity.Filter): MegalodonEntity.Filter => f - export const follow_request = (f: Entity.FollowRequest): MegalodonEntity.FollowRequest => ({ - id: f.id, - username: f.username, - acct: f.acct, - display_name: f.display_name, - locked: f.locked, - bot: f.bot, - discoverable: f.discoverable, - group: f.group, - created_at: f.created_at, - note: f.note, - url: f.url, - avatar: f.avatar, - avatar_static: f.avatar_static, - header: f.header, - header_static: f.header_static, - followers_count: f.followers_count, - following_count: f.following_count, - statuses_count: f.statuses_count, - emojis: f.emojis.map(e => emoji(e)), - fields: f.fields.map(f => field(f)) - }) - export const history = (h: Entity.History): MegalodonEntity.History => h - export const identity_proof = (i: Entity.IdentityProof): MegalodonEntity.IdentityProof => i - export const instance = (i: Entity.Instance): MegalodonEntity.Instance => { - return { - uri: i.uri, - title: i.title, - description: i.description, - email: i.email, - version: i.version, - thumbnail: i.thumbnail, - urls: i.urls ? urls(i.urls) : null, - stats: stats(i.stats), - languages: i.languages, - registrations: i.registrations, - approval_required: i.approval_required, - invites_enabled: i.invites_enabled, - configuration: { - statuses: { - max_characters: i.max_toot_chars - } - }, - contact_account: account(i.contact_account), - rules: i.rules - } - } - export const list = (l: Entity.List): MegalodonEntity.List => l - export const marker = (m: Entity.Marker): MegalodonEntity.Marker => m - export const mention = (m: Entity.Mention): MegalodonEntity.Mention => m - export const notification = (n: Entity.Notification): MegalodonEntity.Notification | UnknownNotificationTypeError => { - const notificationType = decodeNotificationType(n.type) - if (notificationType instanceof UnknownNotificationTypeError) return notificationType - if (n.status) { - return { - account: account(n.account), - created_at: n.created_at, - id: n.id, - status: status(n.status), - type: notificationType - } - } else { - return { - account: account(n.account), - created_at: n.created_at, - id: n.id, - type: notificationType - } - } - } - export const poll = (p: Entity.Poll): MegalodonEntity.Poll => p - export const poll_option = (p: Entity.PollOption): MegalodonEntity.PollOption => p - export const preferences = (p: Entity.Preferences): MegalodonEntity.Preferences => p - export const push_subscription = (p: Entity.PushSubscription): MegalodonEntity.PushSubscription => p - export const relationship = (r: Entity.Relationship): MegalodonEntity.Relationship => r - export const report = (r: Entity.Report): MegalodonEntity.Report => ({ - id: r.id, - action_taken: r.action_taken, - action_taken_at: null, - category: r.category, - comment: r.comment, - forwarded: r.forwarded, - status_ids: r.status_ids, - rule_ids: r.rule_ids, - target_account: account(r.target_account) - }) - export const results = (r: Entity.Results): MegalodonEntity.Results => ({ - accounts: Array.isArray(r.accounts) ? r.accounts.map(a => account(a)) : [], - statuses: Array.isArray(r.statuses) ? r.statuses.map(s => status(s)) : [], - hashtags: Array.isArray(r.hashtags) ? r.hashtags.map(h => tag(h)) : [] - }) - export const scheduled_status = (s: Entity.ScheduledStatus): MegalodonEntity.ScheduledStatus => { - return { - id: s.id, - scheduled_at: s.scheduled_at, - params: status_params(s.params), - media_attachments: s.media_attachments ? s.media_attachments.map(a => attachment(a)) : null - } - } - export const source = (s: Entity.Source): MegalodonEntity.Source => s - export const stats = (s: Entity.Stats): MegalodonEntity.Stats => s - export const status = (s: Entity.Status): MegalodonEntity.Status => ({ - id: s.id, - uri: s.uri, - url: s.url, - account: account(s.account), - in_reply_to_id: s.in_reply_to_id, - in_reply_to_account_id: s.in_reply_to_account_id, - reblog: s.reblog ? status(s.reblog) : s.quote ? status(s.quote) : null, - content: s.content, - plain_content: null, - created_at: s.created_at, - edited_at: s.edited_at || null, - emojis: Array.isArray(s.emojis) ? s.emojis.map(e => emoji(e)) : [], - replies_count: s.replies_count, - reblogs_count: s.reblogs_count, - favourites_count: s.favourites_count, - reblogged: s.reblogged, - favourited: s.favourited, - muted: s.muted, - sensitive: s.sensitive, - spoiler_text: s.spoiler_text, - visibility: s.visibility, - media_attachments: Array.isArray(s.media_attachments) ? s.media_attachments.map(m => attachment(m)) : [], - mentions: Array.isArray(s.mentions) ? s.mentions.map(m => mention(m)) : [], - tags: s.tags, - card: s.card ? card(s.card) : null, - poll: s.poll ? poll(s.poll) : null, - application: s.application ? application(s.application) : null, - language: s.language, - pinned: s.pinned, - emoji_reactions: [], - bookmarked: s.bookmarked ? s.bookmarked : false, - quote: false - }) - export const status_params = (s: Entity.StatusParams): MegalodonEntity.StatusParams => { - return { - text: s.text, - in_reply_to_id: s.in_reply_to_id, - media_ids: s.media_ids, - sensitive: s.sensitive, - spoiler_text: s.spoiler_text, - visibility: s.visibility, - scheduled_at: s.scheduled_at, - application_id: parseInt(s.application_id) - } - } - export const status_source = (s: Entity.StatusSource): MegalodonEntity.StatusSource => s - export const tag = (t: Entity.Tag): MegalodonEntity.Tag => t - export const token = (t: Entity.Token): MegalodonEntity.Token => t - export const urls = (u: Entity.URLs): MegalodonEntity.URLs => u - } -} -export default FriendicaAPI diff --git a/packages/megalodon/src/friendica/entities/account.ts b/packages/megalodon/src/friendica/entities/account.ts deleted file mode 100644 index 670a583712..0000000000 --- a/packages/megalodon/src/friendica/entities/account.ts +++ /dev/null @@ -1,29 +0,0 @@ -/// -/// -/// -namespace FriendicaEntity { - export type Account = { - id: string - username: string - acct: string - display_name: string - locked: boolean - discoverable?: boolean - group: boolean | null - created_at: string - followers_count: number - following_count: number - statuses_count: number - note: string - url: string - avatar: string - avatar_static: string - header: string - header_static: string - emojis: Array - moved: Account | null - fields: Array - bot: boolean - source?: Source - } -} diff --git a/packages/megalodon/src/friendica/entities/activity.ts b/packages/megalodon/src/friendica/entities/activity.ts deleted file mode 100644 index 4db360d233..0000000000 --- a/packages/megalodon/src/friendica/entities/activity.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace FriendicaEntity { - export type Activity = { - week: string - statuses: string - logins: string - registrations: string - } -} diff --git a/packages/megalodon/src/friendica/entities/application.ts b/packages/megalodon/src/friendica/entities/application.ts deleted file mode 100644 index 5e54ce82d8..0000000000 --- a/packages/megalodon/src/friendica/entities/application.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace FriendicaEntity { - export type Application = { - name: string - website?: string | null - vapid_key?: string | null - } -} diff --git a/packages/megalodon/src/friendica/entities/async_attachment.ts b/packages/megalodon/src/friendica/entities/async_attachment.ts deleted file mode 100644 index 76934af66a..0000000000 --- a/packages/megalodon/src/friendica/entities/async_attachment.ts +++ /dev/null @@ -1,14 +0,0 @@ -/// -namespace FriendicaEntity { - export type AsyncAttachment = { - id: string - type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' - url: string | null - remote_url: string | null - preview_url: string - text_url: string | null - meta: Meta | null - description: string | null - blurhash: string | null - } -} diff --git a/packages/megalodon/src/friendica/entities/attachment.ts b/packages/megalodon/src/friendica/entities/attachment.ts deleted file mode 100644 index 04be0e72d2..0000000000 --- a/packages/megalodon/src/friendica/entities/attachment.ts +++ /dev/null @@ -1,49 +0,0 @@ -namespace FriendicaEntity { - export type Sub = { - // For Image, Gifv, and Video - width?: number - height?: number - size?: string - aspect?: number - - // For Gifv and Video - frame_rate?: string - - // For Audio, Gifv, and Video - duration?: number - bitrate?: number - } - - export type Focus = { - x: number - y: number - } - - export type Meta = { - original?: Sub - small?: Sub - focus?: Focus - length?: string - duration?: number - fps?: number - size?: string - width?: number - height?: number - aspect?: number - audio_encode?: string - audio_bitrate?: string - audio_channel?: string - } - - export type Attachment = { - id: string - type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' - url: string - remote_url: string | null - preview_url: string | null - text_url: string | null - meta: Meta | null - description: string | null - blurhash: string | null - } -} diff --git a/packages/megalodon/src/friendica/entities/card.ts b/packages/megalodon/src/friendica/entities/card.ts deleted file mode 100644 index c23471983b..0000000000 --- a/packages/megalodon/src/friendica/entities/card.ts +++ /dev/null @@ -1,17 +0,0 @@ -namespace FriendicaEntity { - export type Card = { - url: string - title: string - description: string - type: 'link' | 'photo' | 'video' | 'rich' - image: string | null - author_name: string - author_url: string - provider_name: string - provider_url: string - html: string - width: number - height: number - blurhash: string | null - } -} diff --git a/packages/megalodon/src/friendica/entities/context.ts b/packages/megalodon/src/friendica/entities/context.ts deleted file mode 100644 index 9c977544a7..0000000000 --- a/packages/megalodon/src/friendica/entities/context.ts +++ /dev/null @@ -1,8 +0,0 @@ -/// - -namespace FriendicaEntity { - export type Context = { - ancestors: Array - descendants: Array - } -} diff --git a/packages/megalodon/src/friendica/entities/conversation.ts b/packages/megalodon/src/friendica/entities/conversation.ts deleted file mode 100644 index 550ae70817..0000000000 --- a/packages/megalodon/src/friendica/entities/conversation.ts +++ /dev/null @@ -1,11 +0,0 @@ -/// -/// - -namespace FriendicaEntity { - export type Conversation = { - id: string - accounts: Array - last_status: Status | null - unread: boolean - } -} diff --git a/packages/megalodon/src/friendica/entities/emoji.ts b/packages/megalodon/src/friendica/entities/emoji.ts deleted file mode 100644 index a0d92e6bc7..0000000000 --- a/packages/megalodon/src/friendica/entities/emoji.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace FriendicaEntity { - export type Emoji = { - shortcode: string - static_url: string - url: string - visible_in_picker: boolean - } -} diff --git a/packages/megalodon/src/friendica/entities/featured_tag.ts b/packages/megalodon/src/friendica/entities/featured_tag.ts deleted file mode 100644 index 14dd1a8263..0000000000 --- a/packages/megalodon/src/friendica/entities/featured_tag.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace FriendicaEntity { - export type FeaturedTag = { - id: string - name: string - statuses_count: number - last_status_at: string - } -} diff --git a/packages/megalodon/src/friendica/entities/field.ts b/packages/megalodon/src/friendica/entities/field.ts deleted file mode 100644 index 299ca0a456..0000000000 --- a/packages/megalodon/src/friendica/entities/field.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace FriendicaEntity { - export type Field = { - name: string - value: string - verified_at: string | null - } -} diff --git a/packages/megalodon/src/friendica/entities/filter.ts b/packages/megalodon/src/friendica/entities/filter.ts deleted file mode 100644 index a71a936ab1..0000000000 --- a/packages/megalodon/src/friendica/entities/filter.ts +++ /dev/null @@ -1,12 +0,0 @@ -namespace FriendicaEntity { - export type Filter = { - id: string - phrase: string - context: Array - expires_at: string | null - irreversible: boolean - whole_word: boolean - } - - export type FilterContext = string -} diff --git a/packages/megalodon/src/friendica/entities/follow_request.ts b/packages/megalodon/src/friendica/entities/follow_request.ts deleted file mode 100644 index 83f5bf9ba9..0000000000 --- a/packages/megalodon/src/friendica/entities/follow_request.ts +++ /dev/null @@ -1,27 +0,0 @@ -/// -/// - -namespace FriendicaEntity { - export type FollowRequest = { - id: number - username: string - acct: string - display_name: string - locked: boolean - bot: boolean - discoverable?: boolean - group: boolean - created_at: string - note: string - url: string - avatar: string - avatar_static: string - header: string - header_static: string - followers_count: number - following_count: number - statuses_count: number - emojis: Array - fields: Array - } -} diff --git a/packages/megalodon/src/friendica/entities/history.ts b/packages/megalodon/src/friendica/entities/history.ts deleted file mode 100644 index 8f9cd6bd6b..0000000000 --- a/packages/megalodon/src/friendica/entities/history.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace FriendicaEntity { - export type History = { - day: string - uses: number - accounts: number - } -} diff --git a/packages/megalodon/src/friendica/entities/identity_proof.ts b/packages/megalodon/src/friendica/entities/identity_proof.ts deleted file mode 100644 index fb6166c65f..0000000000 --- a/packages/megalodon/src/friendica/entities/identity_proof.ts +++ /dev/null @@ -1,9 +0,0 @@ -namespace FriendicaEntity { - export type IdentityProof = { - provider: string - provider_username: string - updated_at: string - proof_url: string - profile_url: string - } -} diff --git a/packages/megalodon/src/friendica/entities/instance.ts b/packages/megalodon/src/friendica/entities/instance.ts deleted file mode 100644 index a86390eb0b..0000000000 --- a/packages/megalodon/src/friendica/entities/instance.ts +++ /dev/null @@ -1,28 +0,0 @@ -/// -/// -/// - -namespace FriendicaEntity { - export type Instance = { - uri: string - title: string - description: string - email: string - version: string - thumbnail: string | null - urls: URLs | null - stats: Stats - languages: Array - registrations: boolean - approval_required: boolean - invites_enabled: boolean - max_toot_chars: number - contact_account: Account - rules: Array - } - - export type InstanceRule = { - id: string - text: string - } -} diff --git a/packages/megalodon/src/friendica/entities/list.ts b/packages/megalodon/src/friendica/entities/list.ts deleted file mode 100644 index 90487aec28..0000000000 --- a/packages/megalodon/src/friendica/entities/list.ts +++ /dev/null @@ -1,9 +0,0 @@ -namespace FriendicaEntity { - export type List = { - id: string - title: string - replies_policy: RepliesPolicy - } - - export type RepliesPolicy = 'followed' | 'list' | 'none' -} diff --git a/packages/megalodon/src/friendica/entities/marker.ts b/packages/megalodon/src/friendica/entities/marker.ts deleted file mode 100644 index 4ec41a07d6..0000000000 --- a/packages/megalodon/src/friendica/entities/marker.ts +++ /dev/null @@ -1,14 +0,0 @@ -namespace FriendicaEntity { - export type Marker = { - home: { - last_read_id: string - version: number - updated_at: string - } - notifications: { - last_read_id: string - version: number - updated_at: string - } - } -} diff --git a/packages/megalodon/src/friendica/entities/mention.ts b/packages/megalodon/src/friendica/entities/mention.ts deleted file mode 100644 index 0e93333fe8..0000000000 --- a/packages/megalodon/src/friendica/entities/mention.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace FriendicaEntity { - export type Mention = { - id: string - username: string - url: string - acct: string - } -} diff --git a/packages/megalodon/src/friendica/entities/notification.ts b/packages/megalodon/src/friendica/entities/notification.ts deleted file mode 100644 index acdbfb9276..0000000000 --- a/packages/megalodon/src/friendica/entities/notification.ts +++ /dev/null @@ -1,14 +0,0 @@ -/// -/// - -namespace FriendicaEntity { - export type Notification = { - account: Account - created_at: string - id: string - status?: Status - type: NotificationType - } - - export type NotificationType = string -} diff --git a/packages/megalodon/src/friendica/entities/poll.ts b/packages/megalodon/src/friendica/entities/poll.ts deleted file mode 100644 index 4ac2262c5e..0000000000 --- a/packages/megalodon/src/friendica/entities/poll.ts +++ /dev/null @@ -1,13 +0,0 @@ -/// - -namespace FriendicaEntity { - export type Poll = { - id: string - expires_at: string | null - expired: boolean - multiple: boolean - votes_count: number - options: Array - voted: boolean - } -} diff --git a/packages/megalodon/src/friendica/entities/poll_option.ts b/packages/megalodon/src/friendica/entities/poll_option.ts deleted file mode 100644 index f9628ddd80..0000000000 --- a/packages/megalodon/src/friendica/entities/poll_option.ts +++ /dev/null @@ -1,6 +0,0 @@ -namespace FriendicaEntity { - export type PollOption = { - title: string - votes_count: number | null - } -} diff --git a/packages/megalodon/src/friendica/entities/preferences.ts b/packages/megalodon/src/friendica/entities/preferences.ts deleted file mode 100644 index dec8b511be..0000000000 --- a/packages/megalodon/src/friendica/entities/preferences.ts +++ /dev/null @@ -1,9 +0,0 @@ -namespace FriendicaEntity { - export type Preferences = { - 'posting:default:visibility': 'public' | 'unlisted' | 'private' | 'direct' - 'posting:default:sensitive': boolean - 'posting:default:language': string | null - 'reading:expand:media': 'default' | 'show_all' | 'hide_all' - 'reading:expand:spoilers': boolean - } -} diff --git a/packages/megalodon/src/friendica/entities/push_subscription.ts b/packages/megalodon/src/friendica/entities/push_subscription.ts deleted file mode 100644 index 857a98f27e..0000000000 --- a/packages/megalodon/src/friendica/entities/push_subscription.ts +++ /dev/null @@ -1,16 +0,0 @@ -namespace FriendicaEntity { - export type Alerts = { - follow: boolean - favourite: boolean - mention: boolean - reblog: boolean - poll: boolean - } - - export type PushSubscription = { - id: string - endpoint: string - server_key: string - alerts: Alerts - } -} diff --git a/packages/megalodon/src/friendica/entities/relationship.ts b/packages/megalodon/src/friendica/entities/relationship.ts deleted file mode 100644 index bba3099a82..0000000000 --- a/packages/megalodon/src/friendica/entities/relationship.ts +++ /dev/null @@ -1,17 +0,0 @@ -namespace FriendicaEntity { - export type Relationship = { - id: string - following: boolean - followed_by: boolean - blocking: boolean - blocked_by: boolean - muting: boolean - muting_notifications: boolean - requested: boolean - domain_blocking: boolean - showing_reblogs: boolean - endorsed: boolean - notifying: boolean - note: string | null - } -} diff --git a/packages/megalodon/src/friendica/entities/report.ts b/packages/megalodon/src/friendica/entities/report.ts deleted file mode 100644 index f20d6d2db1..0000000000 --- a/packages/megalodon/src/friendica/entities/report.ts +++ /dev/null @@ -1,16 +0,0 @@ -/// - -namespace FriendicaEntity { - export type Report = { - id: string - action_taken: boolean - category: Category - comment: string - forwarded: boolean - status_ids: Array | null - rule_ids: Array | null - target_account: Account - } - - export type Category = 'spam' | 'violation' | 'other' -} diff --git a/packages/megalodon/src/friendica/entities/results.ts b/packages/megalodon/src/friendica/entities/results.ts deleted file mode 100644 index 7af2356574..0000000000 --- a/packages/megalodon/src/friendica/entities/results.ts +++ /dev/null @@ -1,11 +0,0 @@ -/// -/// -/// - -namespace FriendicaEntity { - export type Results = { - accounts: Array - statuses: Array - hashtags: Array - } -} diff --git a/packages/megalodon/src/friendica/entities/scheduled_status.ts b/packages/megalodon/src/friendica/entities/scheduled_status.ts deleted file mode 100644 index da292f7008..0000000000 --- a/packages/megalodon/src/friendica/entities/scheduled_status.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// -/// -namespace FriendicaEntity { - export type ScheduledStatus = { - id: string - scheduled_at: string - params: StatusParams - media_attachments: Array - } -} diff --git a/packages/megalodon/src/friendica/entities/source.ts b/packages/megalodon/src/friendica/entities/source.ts deleted file mode 100644 index 4033e911e8..0000000000 --- a/packages/megalodon/src/friendica/entities/source.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// -namespace FriendicaEntity { - export type Source = { - privacy: string | null - sensitive: boolean | null - language: string | null - note: string - fields: Array - } -} diff --git a/packages/megalodon/src/friendica/entities/stats.ts b/packages/megalodon/src/friendica/entities/stats.ts deleted file mode 100644 index 8ef290b7bc..0000000000 --- a/packages/megalodon/src/friendica/entities/stats.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace FriendicaEntity { - export type Stats = { - user_count: number - status_count: number - domain_count: number - } -} diff --git a/packages/megalodon/src/friendica/entities/status.ts b/packages/megalodon/src/friendica/entities/status.ts deleted file mode 100644 index 014da84ee1..0000000000 --- a/packages/megalodon/src/friendica/entities/status.ts +++ /dev/null @@ -1,49 +0,0 @@ -/// -/// -/// -/// -/// -/// -/// - -namespace FriendicaEntity { - export type Status = { - id: string - uri: string - url: string - account: Account - in_reply_to_id: string | null - in_reply_to_account_id: string | null - reblog: Status | null - content: string - created_at: string - edited_at?: string | null - emojis: Emoji[] - replies_count: number - reblogs_count: number - favourites_count: number - reblogged: boolean | null - favourited: boolean | null - muted: boolean | null - sensitive: boolean - spoiler_text: string - visibility: 'public' | 'unlisted' | 'private' | 'direct' - media_attachments: Array - mentions: Array - tags: Array - card: Card | null - poll: Poll | null - application: Application | null - language: string | null - pinned: boolean | null - bookmarked?: boolean - // These parameters are unique parameters in fedibird.com for quote. - quote_id?: string - quote?: Status | null - } - - export type StatusTag = { - name: string - url: string - } -} diff --git a/packages/megalodon/src/friendica/entities/status_params.ts b/packages/megalodon/src/friendica/entities/status_params.ts deleted file mode 100644 index 6a14af837a..0000000000 --- a/packages/megalodon/src/friendica/entities/status_params.ts +++ /dev/null @@ -1,12 +0,0 @@ -namespace FriendicaEntity { - export type StatusParams = { - text: string - in_reply_to_id: string | null - media_ids: Array | null - sensitive: boolean | null - spoiler_text: string | null - visibility: 'public' | 'unlisted' | 'private' | null - scheduled_at: string | null - application_id: string - } -} diff --git a/packages/megalodon/src/friendica/entities/status_source.ts b/packages/megalodon/src/friendica/entities/status_source.ts deleted file mode 100644 index 2b5ee9bd0f..0000000000 --- a/packages/megalodon/src/friendica/entities/status_source.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace FriendicaEntity { - export type StatusSource = { - id: string - text: string - spoiler_text: string - } -} diff --git a/packages/megalodon/src/friendica/entities/tag.ts b/packages/megalodon/src/friendica/entities/tag.ts deleted file mode 100644 index f7998d22fd..0000000000 --- a/packages/megalodon/src/friendica/entities/tag.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// - -namespace FriendicaEntity { - export type Tag = { - name: string - url: string - history: Array - following?: boolean - } -} diff --git a/packages/megalodon/src/friendica/entities/token.ts b/packages/megalodon/src/friendica/entities/token.ts deleted file mode 100644 index 904d68651f..0000000000 --- a/packages/megalodon/src/friendica/entities/token.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace FriendicaEntity { - export type Token = { - access_token: string - token_type: string - scope: string - created_at: number - } -} diff --git a/packages/megalodon/src/friendica/entities/urls.ts b/packages/megalodon/src/friendica/entities/urls.ts deleted file mode 100644 index 8c736b9ef4..0000000000 --- a/packages/megalodon/src/friendica/entities/urls.ts +++ /dev/null @@ -1,5 +0,0 @@ -namespace FriendicaEntity { - export type URLs = { - streaming_api: string - } -} diff --git a/packages/megalodon/src/friendica/entity.ts b/packages/megalodon/src/friendica/entity.ts deleted file mode 100644 index 6d64f061ce..0000000000 --- a/packages/megalodon/src/friendica/entity.ts +++ /dev/null @@ -1,38 +0,0 @@ -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// - -export default FriendicaEntity diff --git a/packages/megalodon/src/friendica/notification.ts b/packages/megalodon/src/friendica/notification.ts deleted file mode 100644 index 78701c46bc..0000000000 --- a/packages/megalodon/src/friendica/notification.ts +++ /dev/null @@ -1,14 +0,0 @@ -import FriendicaEntity from './entity' - -namespace FriendicaNotificationType { - export const Mention: FriendicaEntity.NotificationType = 'mention' - export const Reblog: FriendicaEntity.NotificationType = 'reblog' - export const Favourite: FriendicaEntity.NotificationType = 'favourite' - export const Follow: FriendicaEntity.NotificationType = 'follow' - export const Poll: FriendicaEntity.NotificationType = 'poll' - export const FollowRequest: FriendicaEntity.NotificationType = 'follow_request' - export const Status: FriendicaEntity.NotificationType = 'status' - export const Update: FriendicaEntity.NotificationType = 'update' -} - -export default FriendicaNotificationType diff --git a/packages/megalodon/src/friendica/web_socket.ts b/packages/megalodon/src/friendica/web_socket.ts deleted file mode 100644 index ca16f24a5f..0000000000 --- a/packages/megalodon/src/friendica/web_socket.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { WebSocketInterface } from '../megalodon' -import { EventEmitter } from 'events' -import { ProxyConfig } from '../proxy_config' - -export default class WebSocket extends EventEmitter implements WebSocketInterface { - constructor( - _url: string, - _stream: string, - _params: string | undefined, - _accessToken: string, - _userAgent: string, - _proxyConfig: ProxyConfig | false = false - ) { - super() - } - public start() {} - public stop() {} -} diff --git a/packages/megalodon/src/index.ts b/packages/megalodon/src/index.ts index 070c397d2d..621f007ccf 100644 --- a/packages/megalodon/src/index.ts +++ b/packages/megalodon/src/index.ts @@ -2,15 +2,14 @@ import Response from './response' import OAuth from './oauth' import { isCancel, RequestCanceledError } from './cancel' import { ProxyConfig } from './proxy_config' -import generator, { MegalodonInterface, WebSocketInterface } from './megalodon' +import { MegalodonInterface, WebSocketInterface } from './megalodon' import { detector } from './detector' -import Mastodon from './mastodon' -import Pleroma from './pleroma' import Misskey from './misskey' import Entity from './entity' import NotificationType from './notification' import FilterContext from './filter_context' import Converter from './converter' +import MastodonEntity from './mastodon/entity'; export { Response, @@ -23,14 +22,8 @@ export { WebSocketInterface, NotificationType, FilterContext, - Mastodon, - Pleroma, Misskey, Entity, Converter, - generator, + MastodonEntity, } - -export const megalodon = generator; - -export default generator diff --git a/packages/megalodon/src/mastodon.ts b/packages/megalodon/src/mastodon.ts deleted file mode 100644 index 4a8b1fc1ea..0000000000 --- a/packages/megalodon/src/mastodon.ts +++ /dev/null @@ -1,3169 +0,0 @@ -import { OAuth2 } from 'oauth' -import FormData from 'form-data' -import parseLinkHeader from 'parse-link-header' - -import MastodonAPI from './mastodon/api_client' -import WebSocket from './mastodon/web_socket' -import { MegalodonInterface, NoImplementedError } from './megalodon' -import Response from './response' -import Entity from './entity' -import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from './default' -import { ProxyConfig } from './proxy_config' -import OAuth from './oauth' -import { UnknownNotificationTypeError } from './notification' - -export default class Mastodon implements MegalodonInterface { - public client: MastodonAPI.Interface - public baseUrl: string - - /** - * @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.client = new MastodonAPI.Client(baseUrl, token, agent, proxyConfig) - this.baseUrl = baseUrl - } - - public cancel(): void { - return this.client.cancel() - } - - /** - * Call /api/v1/apps - * - * Create an application. - * @param client_name your application's name - * @param options Form Data - */ - public async registerApp( - client_name: string, - options: Partial<{ scopes: Array; redirect_uris: string; website: string }> - ): Promise { - const scopes = options.scopes || DEFAULT_SCOPE - return this.createApp(client_name, options).then(async appData => { - return this.generateAuthUrl(appData.client_id, appData.client_secret, { - scope: scopes, - redirect_uri: appData.redirect_uri - }).then(url => { - appData.url = url - return appData - }) - }) - } - - /** - * Call /api/v1/apps - * - * 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 }> - ): Promise { - const scopes = options.scopes || DEFAULT_SCOPE - const redirect_uris = options.redirect_uris || NO_REDIRECT - - const params: { - client_name: string - redirect_uris: string - scopes: string - website?: string - } = { - client_name: client_name, - redirect_uris: redirect_uris, - scopes: scopes.join(' ') - } - if (options.website) params.website = options.website - - return this.client - .post('/api/v1/apps', params) - .then((res: Response) => OAuth.AppData.from(res.data)) - } - - /** - * Generate authorization url using OAuth2. - * - * @param clientId your OAuth app's client ID - * @param clientSecret your OAuth app's client Secret - * @param options as property, redirect_uri and scope are available, and must be the same as when you register your app - */ - public generateAuthUrl( - clientId: string, - clientSecret: string, - options: Partial<{ scope: Array; redirect_uri: string }> - ): Promise { - const scope = options.scope || DEFAULT_SCOPE - const redirect_uri = options.redirect_uri || NO_REDIRECT - return new Promise(resolve => { - const oauth = new OAuth2(clientId, clientSecret, this.baseUrl, undefined, '/oauth/token') - const url = oauth.getAuthorizeUrl({ - redirect_uri: redirect_uri, - response_type: 'code', - client_id: clientId, - scope: scope.join(' ') - }) - resolve(url) - }) - } - - // ====================================== - // apps - // ====================================== - /** - * GET /api/v1/apps/verify_credentials - * - * @return An Application - */ - public verifyAppCredentials(): Promise> { - return this.client.get('/api/v1/apps/verify_credentials') - } - - // ====================================== - // apps/oauth - // ====================================== - /** - * POST /oauth/token - * - * Fetch OAuth access token. - * Get an access token based client_id and client_secret and authorization code. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param code will be generated by the link of #generateAuthUrl or #registerApp - * @param redirect_uri must be the same uri as the time when you register your OAuth application - */ - public async fetchAccessToken( - client_id: string | null, - client_secret: string, - code: string, - redirect_uri: string = NO_REDIRECT - ): Promise { - if (!client_id) { - throw new Error('client_id is required') - } - return this.client - .post('/oauth/token', { - client_id, - client_secret, - code, - redirect_uri, - grant_type: 'authorization_code' - }) - .then((res: Response) => OAuth.TokenData.from(res.data)) - } - - /** - * POST /oauth/revoke - * - * Revoke an OAuth token. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param token will be get #fetchAccessToken - */ - public async refreshToken(client_id: string, client_secret: string, refresh_token: string): Promise { - return this.client - .post('/oauth/token', { - client_id, - client_secret, - refresh_token, - grant_type: 'refresh_token' - }) - .then((res: Response) => OAuth.TokenData.from(res.data)) - } - - /** - * POST /oauth/revoke - * - * Revoke an OAuth token. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param token will be get #fetchAccessToken - */ - public async revokeToken(client_id: string, client_secret: string, token: string): Promise> { - return this.client.post<{}>('/oauth/revoke', { - client_id, - client_secret, - token - }) - } - - // ====================================== - // accounts - // ====================================== - /** - * POST /api/v1/accounts - * - * @param username Username for the account. - * @param email Email for the account. - * @param password Password for the account. - * @param agreement Whether the user agrees to the local rules, terms, and policies. - * @param locale The language of the confirmation email that will be sent - * @param reason Text that will be reviewed by moderators if registrations require manual approval. - * @return An account token. - */ - public async registerAccount( - username: string, - email: string, - password: string, - agreement: boolean, - locale: string, - reason?: string | null - ): Promise> { - let params = { - username: username, - email: email, - password: password, - agreement: agreement, - locale: locale - } - if (reason) { - params = Object.assign(params, { - reason: reason - }) - } - return this.client.post('/api/v1/accounts', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.token(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/verify_credentials - * - * @return Account. - */ - public async verifyAccountCredentials(): Promise> { - return this.client.get('/api/v1/accounts/verify_credentials').then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.account(res.data) - }) - }) - } - - /** - * PATCH /api/v1/accounts/update_credentials - * - * @return An account. - */ - 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 - } - fields_attributes?: Array<{ name: string; value: string }> - }): Promise> { - let params = {} - if (options) { - if (options.discoverable !== undefined) { - params = Object.assign(params, { - discoverable: options.discoverable - }) - } - if (options.bot !== undefined) { - params = Object.assign(params, { - bot: options.bot - }) - } - if (options.display_name) { - params = Object.assign(params, { - display_name: options.display_name - }) - } - if (options.note) { - params = Object.assign(params, { - note: options.note - }) - } - if (options.avatar) { - params = Object.assign(params, { - avatar: options.avatar - }) - } - if (options.header) { - params = Object.assign(params, { - header: options.header - }) - } - if (options.locked !== undefined) { - params = Object.assign(params, { - locked: options.locked - }) - } - if (options.source) { - params = Object.assign(params, { - source: options.source - }) - } - if (options.fields_attributes) { - params = Object.assign(params, { - fields_attributes: options.fields_attributes - }) - } - } - return this.client.patch('/api/v1/accounts/update_credentials', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.account(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/:id - * - * @param id The account ID. - * @return An account. - */ - public async getAccount(id: string): Promise> { - return this.client.get(`/api/v1/accounts/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.account(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/statuses - * - * @param id The account ID. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID but starting with most recent. - * @param options.min_id Return results newer than ID. - * @param options.pinned Return statuses which include pinned statuses. - * @param options.exclude_replies Return statuses which exclude replies. - * @param options.exclude_reblogs Return statuses which exclude reblogs. - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @return Account's statuses. - */ - public async getAccountStatuses( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - pinned?: boolean - exclude_replies?: boolean - exclude_reblogs?: boolean - only_media: boolean - } - ): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.pinned) { - params = Object.assign(params, { - pinned: options.pinned - }) - } - if (options.exclude_replies) { - params = Object.assign(params, { - exclude_replies: options.exclude_replies - }) - } - if (options.exclude_reblogs) { - params = Object.assign(params, { - exclude_reblogs: options.exclude_reblogs - }) - } - if (options.only_media) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - } - return this.client.get>(`/api/v1/accounts/${id}/statuses`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.status(s)) - }) - }) - } - - public getAccountFavourites( - _id: string, - _options?: { - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('mastodon does not support') - reject(err) - }) - } - - /** - * POST /api/v1/accounts/:id/follow - * - * @param id Target account ID. - * @return Relationship. - */ - public async subscribeAccount(id: string): Promise> { - const params = { - notify: true - } - return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/follow - * - * @param id Target account ID. - * @return Relationship. - */ - public async unsubscribeAccount(id: string): Promise> { - const params = { - notify: false - } - return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/followers - * - * @param id The account ID. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async getAccountFollowers( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - get_all?: boolean - sleep_ms?: number - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.urlToAccounts(`/api/v1/accounts/${id}/followers`, params, options?.get_all || false, options?.sleep_ms || 0) - } - - /** - * GET /api/v1/accounts/:id/following - * - * @param id The account ID. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async getAccountFollowing( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - get_all?: boolean - sleep_ms?: number - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.urlToAccounts(`/api/v1/accounts/${id}/following`, params, options?.get_all || false, options?.sleep_ms || 0) - } - - /** Helper function to optionally follow Link headers as pagination */ - private async urlToAccounts(url: string, params: Record, get_all: boolean, sleep_ms: number) { - const res = await this.client.get>(url, params) - let converted = Object.assign({}, res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - if (get_all && converted.headers.link) { - let parsed = parseLinkHeader(converted.headers.link) - while (parsed?.next) { - const nextRes = await this.client.get>(parsed?.next.url, undefined, undefined, true) - converted = Object.assign({}, converted, { - data: [...converted.data, ...nextRes.data.map(a => MastodonAPI.Converter.account(a))] - }) - parsed = parseLinkHeader(nextRes.headers.link) - if (sleep_ms) { - await new Promise(converted => setTimeout(converted, sleep_ms)) - } - } - } - return converted - } - - /** - * GET /api/v1/accounts/:id/lists - * - * @param id The account ID. - * @return The array of lists. - */ - public async getAccountLists(id: string): Promise>> { - return this.client.get>(`/api/v1/accounts/${id}/lists`).then(res => { - return Object.assign(res, { - data: res.data.map(l => MastodonAPI.Converter.list(l)) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/identity_proofs - * - * @param id The account ID. - * @return Array of IdentityProof - */ - public async getIdentityProof(id: string): Promise>> { - return this.client.get>(`/api/v1/accounts/${id}/identity_proofs`).then(res => { - return Object.assign(res, { - data: res.data.map(i => MastodonAPI.Converter.identity_proof(i)) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/follow - * - * @param id The account ID. - * @param reblog Receive this account's reblogs in home timeline. - * @return Relationship - */ - public async followAccount(id: string, options?: { reblog?: boolean }): Promise> { - let params = {} - if (options) { - if (options.reblog !== undefined) { - params = Object.assign(params, { - reblog: options.reblog - }) - } - } - return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unfollow - * - * @param id The account ID. - * @return Relationship - */ - public async unfollowAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unfollow`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/block - * - * @param id The account ID. - * @return Relationship - */ - public async blockAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/block`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unblock - * - * @param id The account ID. - * @return RElationship - */ - public async unblockAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unblock`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/mute - * - * @param id The account ID. - * @param notifications Mute notifications in addition to statuses. - * @return Relationship - */ - public async muteAccount(id: string, notifications: boolean = true): Promise> { - return this.client - .post(`/api/v1/accounts/${id}/mute`, { - notifications: notifications - }) - .then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unmute - * - * @param id The account ID. - * @return Relationship - */ - public async unmuteAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unmute`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/pin - * - * @param id The account ID. - * @return Relationship - */ - public async pinAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/pin`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unpin - * - * @param id The account ID. - * @return Relationship - */ - public async unpinAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unpin`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/relationships - * - * @param id The account ID. - * @return Relationship - */ - public async getRelationship(id: string): Promise> { - return this.client - .get>('/api/v1/accounts/relationships', { - id: [id] - }) - .then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data[0]) - }) - }) - } - - /** - * GET /api/v1/accounts/relationships - * - * @param ids Array of account IDs. - * @return Array of Relationship. - */ - public async getRelationships(ids: Array): Promise>> { - return this.client - .get>('/api/v1/accounts/relationships', { - id: ids - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(r => MastodonAPI.Converter.relationship(r)) - }) - }) - } - - /** - * GET /api/v1/accounts/search - * - * @param q Search query. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async searchAccount( - q: string, - options?: { - following?: boolean - resolve?: boolean - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = { q: q } - if (options) { - if (options.following !== undefined && options.following !== null) { - params = Object.assign(params, { - following: options.following - }) - } - if (options.resolve !== undefined && options.resolve !== null) { - params = Object.assign(params, { - resolve: options.resolve - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/accounts/search', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/bookmarks - // ====================================== - - /** - * GET /api/v1/bookmarks - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getBookmarks(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - } - return this.client.get>('/api/v1/bookmarks', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // accounts/favourites - // ====================================== - - /** - * GET /api/v1/favourites - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getFavourites(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/favourites', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // accounts/mutes - // ====================================== - /** - * GET /api/v1/mutes - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getMutes(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/mutes', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/blocks - // ====================================== - /** - * GET /api/v1/blocks - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/blocks', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/domain_blocks - // ====================================== - /** - * GET /api/v1/domain_blocks - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of domain name. - */ - public async getDomainBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/domain_blocks', params) - } - - /** - * POST/api/v1/domain_blocks - * - * @param domain Domain to block. - */ - public blockDomain(domain: string): Promise> { - return this.client.post<{}>('/api/v1/domain_blocks', { - domain: domain - }) - } - - /** - * DELETE /api/v1/domain_blocks - * - * @param domain Domain to unblock - */ - public unblockDomain(domain: string): Promise> { - return this.client.del<{}>('/api/v1/domain_blocks', { - domain: domain - }) - } - - // ====================================== - // accounts/filters - // ====================================== - /** - * GET /api/v1/filters - * - * @return Array of filters. - */ - public async getFilters(): Promise>> { - return this.client.get>('/api/v1/filters').then(res => { - return Object.assign(res, { - data: res.data.map(f => MastodonAPI.Converter.filter(f)) - }) - }) - } - - /** - * GET /api/v1/filters/:id - * - * @param id The filter ID. - * @return Filter. - */ - public async getFilter(id: string): Promise> { - return this.client.get(`/api/v1/filters/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.filter(res.data) - }) - }) - } - - /** - * POST /api/v1/filters - * - * @param phrase Text to be filtered. - * @param context Array of enumerable strings home, notifications, public, thread, account. At least one context must be specified. - * @param options.irreversible Should the server irreversibly drop matching entities from home and notifications? - * @param options.whole_word Consider word boundaries? - * @param options.expires_in ISO 8601 Datetime for when the filter expires. - * @return Filter - */ - public async createFilter( - phrase: string, - context: Array, - options?: { - irreversible?: boolean - whole_word?: boolean - expires_in?: string - } - ): Promise> { - let params = { - phrase: phrase, - context: context - } - if (options) { - if (options.irreversible !== undefined) { - params = Object.assign(params, { - irreversible: options.irreversible - }) - } - if (options.whole_word !== undefined) { - params = Object.assign(params, { - whole_word: options.whole_word - }) - } - if (options.expires_in) { - params = Object.assign(params, { - expires_in: options.expires_in - }) - } - } - return this.client.post('/api/v1/filters', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.filter(res.data) - }) - }) - } - - /** - * PUT /api/v1/filters/:id - * - * @param id The filter ID. - * @param phrase Text to be filtered. - * @param context Array of enumerable strings home, notifications, public, thread, account. At least one context must be specified. - * @param options.irreversible Should the server irreversibly drop matching entities from home and notifications? - * @param options.whole_word Consider word boundaries? - * @param options.expires_in ISO 8601 Datetime for when the filter expires. - * @return Filter - */ - public async updateFilter( - id: string, - phrase: string, - context: Array, - options?: { - irreversible?: boolean - whole_word?: boolean - expires_in?: string - } - ): Promise> { - let params = { - phrase: phrase, - context: context - } - if (options) { - if (options.irreversible !== undefined) { - params = Object.assign(params, { - irreversible: options.irreversible - }) - } - if (options.whole_word !== undefined) { - params = Object.assign(params, { - whole_word: options.whole_word - }) - } - if (options.expires_in) { - params = Object.assign(params, { - expires_in: options.expires_in - }) - } - } - return this.client.put(`/api/v1/filters/${id}`, params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.filter(res.data) - }) - }) - } - - /** - * DELETE /api/v1/filters/:id - * - * @param id The filter ID. - * @return Removed filter. - */ - public async deleteFilter(id: string): Promise> { - return this.client.del(`/api/v1/filters/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.filter(res.data) - }) - }) - } - - // ====================================== - // accounts/reports - // ====================================== - /** - * POST /api/v1/reports - * - * @param account_id Target account ID. - * @param options.status_ids Array of Statuses ids to attach to the report. - * @param options.comment The reason for the report. Default maximum of 1000 characters. - * @param options.forward If the account is remote, should the report be forwarded to the remote admin? - * @param options.category Specify if the report is due to spam, violation of enumerated instance rules, or some other reason. Defaults to other. Will be set to violation if rule_ids[] is provided (regardless of any category value you provide). - * @param options.rule_ids For violation category reports, specify the ID of the exact rules broken. Rules and their IDs are available via GET /api/v1/instance/rules and GET /api/v1/instance. - * @return Report - */ - public async report( - account_id: string, - options?: { - status_ids?: Array - comment: string - forward?: boolean - category?: Entity.Category - rule_ids?: Array - } - ): Promise> { - let params = { - account_id: account_id - } - if (options) { - if (options.status_ids) { - params = Object.assign(params, { - status_ids: options.status_ids - }) - } - if (options.comment) { - params = Object.assign(params, { - comment: options.comment - }) - } - if (options.forward !== undefined) { - params = Object.assign(params, { - forward: options.forward - }) - } - if (options.category) { - params = Object.assign(params, { - category: options.category - }) - } - if (options.rule_ids) { - params = Object.assign(params, { - rule_ids: options.rule_ids - }) - } - } - return this.client.post('/api/v1/reports', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.report(res.data) - }) - }) - } - - // ====================================== - // accounts/follow_requests - // ====================================== - /** - * GET /api/v1/follow_requests - * - * @param limit Maximum number of results. - * @return Array of account. - */ - public async getFollowRequests(limit?: number): Promise>> { - if (limit) { - return this.client - .get>('/api/v1/follow_requests', { - limit: limit - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } else { - return this.client.get>('/api/v1/follow_requests').then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - } - - /** - * POST /api/v1/follow_requests/:id/authorize - * - * @param id Target account ID. - * @return Relationship. - */ - public async acceptFollowRequest(id: string): Promise> { - return this.client.post(`/api/v1/follow_requests/${id}/authorize`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/follow_requests/:id/reject - * - * @param id Target account ID. - * @return Relationship. - */ - public async rejectFollowRequest(id: string): Promise> { - return this.client.post(`/api/v1/follow_requests/${id}/reject`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.relationship(res.data) - }) - }) - } - - // ====================================== - // accounts/endorsements - // ====================================== - /** - * GET /api/v1/endorsements - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return Array of accounts. - */ - public async getEndorsements(options?: { limit?: number; max_id?: string; since_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get>('/api/v1/endorsements', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/featured_tags - // ====================================== - /** - * GET /api/v1/featured_tags - * - * @return Array of featured tag. - */ - public async getFeaturedTags(): Promise>> { - return this.client.get>('/api/v1/featured_tags').then(res => { - return Object.assign(res, { - data: res.data.map(f => MastodonAPI.Converter.featured_tag(f)) - }) - }) - } - - /** - * POST /api/v1/featured_tags - * - * @param name Target hashtag name. - * @return FeaturedTag. - */ - public async createFeaturedTag(name: string): Promise> { - return this.client - .post('/api/v1/featured_tags', { - name: name - }) - .then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.featured_tag(res.data) - }) - }) - } - - /** - * DELETE /api/v1/featured_tags/:id - * - * @param id Target featured tag id. - * @return Empty - */ - public deleteFeaturedTag(id: string): Promise> { - return this.client.del<{}>(`/api/v1/featured_tags/${id}`) - } - - /** - * GET /api/v1/featured_tags/suggestions - * - * @return Array of tag. - */ - public async getSuggestedTags(): Promise>> { - return this.client.get>('/api/v1/featured_tags/suggestions').then(res => { - return Object.assign(res, { - data: res.data.map(t => MastodonAPI.Converter.tag(t)) - }) - }) - } - - // ====================================== - // accounts/preferences - // ====================================== - /** - * GET /api/v1/preferences - * - * @return Preferences. - */ - public async getPreferences(): Promise> { - return this.client.get('/api/v1/preferences').then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.preferences(res.data) - }) - }) - } - - // ====================================== - // accounts/followed_tags - // ====================================== - /** - * GET /api/v1/followed_tags - * - * @return Array of Tag. - */ - public async getFollowedTags(): Promise>> { - return this.client.get>('/api/v1/followed_tags').then(res => { - return Object.assign(res, { - data: res.data.map(tag => MastodonAPI.Converter.tag(tag)) - }) - }) - } - - // ====================================== - // accounts/suggestions - // ====================================== - /** - * GET /api/v1/suggestions - * - * @param limit Maximum number of results. - * @return Array of accounts. - */ - public async getSuggestions(limit?: number): Promise>> { - if (limit) { - return this.client - .get>('/api/v1/suggestions', { - limit: limit - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } else { - return this.client.get>('/api/v1/suggestions').then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - } - - // ====================================== - // accounts/tags - // ====================================== - /** - * GET /api/v1/tags/:id - * - * @param id Target hashtag id. - * @return Tag - */ - public async getTag(id: string): Promise> { - return this.client.get(`/api/v1/tags/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.tag(res.data) - }) - }) - } - - /** - * POST /api/v1/tags/:id/follow - * - * @param id Target hashtag id. - * @return Tag - */ - public async followTag(id: string): Promise> { - return this.client.post(`/api/v1/tags/${id}/follow`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.tag(res.data) - }) - }) - } - - /** - * POST /api/v1/tags/:id/unfollow - * - * @param id Target hashtag id. - * @return Tag - */ - public async unfollowTag(id: string): Promise> { - return this.client.post(`/api/v1/tags/${id}/unfollow`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.tag(res.data) - }) - }) - } - - // ====================================== - // statuses - // ====================================== - /** - * POST /api/v1/statuses - * - * @param status Text content of status. - * @param options.media_ids Array of Attachment ids. - * @param options.poll Poll object. - * @param options.in_reply_to_id ID of the status being replied to, if status is a reply. - * @param options.sensitive Mark status and attached media as sensitive? - * @param options.spoiler_text Text to be shown as a warning or subject before the actual content. - * @param options.visibility Visibility of the posted status. - * @param options.scheduled_at ISO 8601 Datetime at which to schedule a status. - * @param options.language ISO 639 language code for this status. - * @param options.quote_id ID of the status being quoted to, if status is a quote. - * @return Status. When options.scheduled_at is present, ScheduledStatus is returned instead. - */ - 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 = { - status: status - } - if (options) { - if (options.media_ids) { - params = Object.assign(params, { - media_ids: options.media_ids - }) - } - if (options.poll) { - let pollParam = { - options: options.poll.options, - expires_in: options.poll.expires_in - } - if (options.poll.multiple !== undefined) { - pollParam = Object.assign(pollParam, { - multiple: options.poll.multiple - }) - } - if (options.poll.hide_totals !== undefined) { - pollParam = Object.assign(pollParam, { - hide_totals: options.poll.hide_totals - }) - } - params = Object.assign(params, { - poll: pollParam - }) - } - if (options.in_reply_to_id) { - params = Object.assign(params, { - in_reply_to_id: options.in_reply_to_id - }) - } - if (options.sensitive !== undefined) { - params = Object.assign(params, { - sensitive: options.sensitive - }) - } - if (options.spoiler_text) { - params = Object.assign(params, { - spoiler_text: options.spoiler_text - }) - } - if (options.visibility) { - params = Object.assign(params, { - visibility: options.visibility - }) - } - if (options.scheduled_at) { - params = Object.assign(params, { - scheduled_at: options.scheduled_at - }) - } - if (options.language) { - params = Object.assign(params, { - language: options.language - }) - } - if (options.quote_id) { - params = Object.assign(params, { - quote_id: options.quote_id - }) - } - } - if (options && options.scheduled_at) { - return this.client.post('/api/v1/statuses', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.scheduled_status(res.data) - }) - }) - } - return this.client.post('/api/v1/statuses', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - public async getStatus(id: string): Promise> { - return this.client.get(`/api/v1/statuses/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - PUT /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - 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> { - let params = {} - if (options.status) { - params = Object.assign(params, { - status: options.status - }) - } - if (options.spoiler_text) { - params = Object.assign(params, { - spoiler_text: options.spoiler_text - }) - } - if (options.sensitive) { - params = Object.assign(params, { - sensitive: options.sensitive - }) - } - if (options.media_ids) { - params = Object.assign(params, { - media_ids: options.media_ids - }) - } - if (options.poll) { - let pollParam = {} - if (options.poll.options !== undefined) { - pollParam = Object.assign(pollParam, { - options: options.poll.options - }) - } - if (options.poll.expires_in !== undefined) { - pollParam = Object.assign(pollParam, { - expires_in: options.poll.expires_in - }) - } - if (options.poll.multiple !== undefined) { - pollParam = Object.assign(pollParam, { - multiple: options.poll.multiple - }) - } - if (options.poll.hide_totals !== undefined) { - pollParam = Object.assign(pollParam, { - hide_totals: options.poll.hide_totals - }) - } - params = Object.assign(params, { - poll: pollParam - }) - } - return this.client.put(`/api/v1/statuses/${id}`, params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * DELETE /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - public async deleteStatus(id: string): Promise> { - return this.client.del(`/api/v1/statuses/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/context - * - * Get parent and child statuses. - * @param id The target status id. - * @return Context - */ - public async getStatusContext( - id: string, - options?: { limit?: number; max_id?: string; since_id?: string } - ): Promise> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get(`/api/v1/statuses/${id}/context`, params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.context(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/source - * - * Obtain the source properties for a status so that it can be edited. - * @param id The target status id. - * @return StatusSource - */ - public async getStatusSource(id: string): Promise> { - return this.client.get(`/api/v1/statuses/${id}/source`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status_source(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/reblogged_by - * - * @param id The target status id. - * @return Array of accounts. - */ - public async getStatusRebloggedBy(id: string): Promise>> { - return this.client.get>(`/api/v1/statuses/${id}/reblogged_by`).then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/favourited_by - * - * @param id The target status id. - * @return Array of accounts. - */ - public async getStatusFavouritedBy(id: string): Promise>> { - return this.client.get>(`/api/v1/statuses/${id}/favourited_by`).then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/favourite - * - * @param id The target status id. - * @return Status. - */ - public async favouriteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/favourite`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unfavourite - * - * @param id The target status id. - * @return Status. - */ - public async unfavouriteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unfavourite`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/reblog - * - * @param id The target status id. - * @return Status. - */ - public async reblogStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/reblog`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unreblog - * - * @param id The target status id. - * @return Status. - */ - public async unreblogStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unreblog`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/bookmark - * - * @param id The target status id. - * @return Status. - */ - public async bookmarkStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/bookmark`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unbookmark - * - * @param id The target status id. - * @return Status. - */ - public async unbookmarkStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unbookmark`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/mute - * - * @param id The target status id. - * @return Status - */ - public async muteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/mute`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unmute - * - * @param id The target status id. - * @return Status - */ - public async unmuteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unmute`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/pin - * @param id The target status id. - * @return Status - */ - public async pinStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/pin`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unpin - * - * @param id The target status id. - * @return Status - */ - public async unpinStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unpin`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.status(res.data) - }) - }) - } - - // ====================================== - // statuses/media - // ====================================== - /** - * POST /api/v2/media - * - * @param file The file to be attached, using multipart form data. - * @param options.description A plain-text description of the media. - * @param options.focus Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. - * @return Attachment - */ - public async uploadMedia( - file: any, - options?: { description?: string; focus?: string } - ): Promise> { - const formData = new FormData() - formData.append('file', file) - if (options) { - if (options.description) { - formData.append('description', options.description) - } - if (options.focus) { - formData.append('focus', options.focus) - } - } - return this.client.postForm('/api/v2/media', formData).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.async_attachment(res.data) - }) - }) - } - - /** - * GET /api/v1/media/:id - * - * @param id Target media ID. - * @return Attachment - */ - public async getMedia(id: string): Promise> { - const res = await this.client.get(`/api/v1/media/${id}`) - - return Object.assign(res, { - data: MastodonAPI.Converter.attachment(res.data) - }) - } - - /** - * PUT /api/v1/media/:id - * - * @param id Target media ID. - * @param options.file The file to be attached, using multipart form data. - * @param options.description A plain-text description of the media. - * @param options.focus Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. - * @param options.is_sensitive Whether the media is sensitive. - * @return Attachment - */ - public async updateMedia( - id: string, - options?: { - file?: any - description?: string - focus?: string - } - ): Promise> { - const formData = new FormData() - if (options) { - if (options.file) { - formData.append('file', options.file) - } - if (options.description) { - formData.append('description', options.description) - } - if (options.focus) { - formData.append('focus', options.focus) - } - } - return this.client.putForm(`/api/v1/media/${id}`, formData).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.attachment(res.data) - }) - }) - } - - // ====================================== - // statuses/polls - // ====================================== - /** - * GET /api/v1/polls/:id - * - * @param id Target poll ID. - * @return Poll - */ - public async getPoll(id: string): Promise> { - return this.client.get(`/api/v1/polls/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.poll(res.data) - }) - }) - } - - /** - * POST /api/v1/polls/:id/votes - * - * @param id Target poll ID. - * @param choices Array of own votes containing index for each option (starting from 0). - * @return Poll - */ - public async votePoll(id: string, choices: Array): Promise> { - return this.client - .post(`/api/v1/polls/${id}/votes`, { - choices: choices - }) - .then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.poll(res.data) - }) - }) - } - - // ====================================== - // statuses/scheduled_statuses - // ====================================== - /** - * GET /api/v1/scheduled_statuses - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of scheduled statuses. - */ - public async getScheduledStatuses(options?: { - limit?: number | null - max_id?: string | null - since_id?: string | null - min_id?: string | null - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - } - return this.client.get>('/api/v1/scheduled_statuses', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.scheduled_status(s)) - }) - }) - } - - /** - * GET /api/v1/scheduled_statuses/:id - * - * @param id Target status ID. - * @return ScheduledStatus. - */ - public async getScheduledStatus(id: string): Promise> { - return this.client.get(`/api/v1/scheduled_statuses/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.scheduled_status(res.data) - }) - }) - } - - /** - * PUT /api/v1/scheduled_statuses/:id - * - * @param id Target scheduled status ID. - * @param scheduled_at ISO 8601 Datetime at which the status will be published. - * @return ScheduledStatus. - */ - public async scheduleStatus(id: string, scheduled_at?: string | null): Promise> { - let params = {} - if (scheduled_at) { - params = Object.assign(params, { - scheduled_at: scheduled_at - }) - } - return this.client.put(`/api/v1/scheduled_statuses/${id}`, params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.scheduled_status(res.data) - }) - }) - } - - /** - * DELETE /api/v1/scheduled_statuses/:id - * - * @param id Target scheduled status ID. - */ - public cancelScheduledStatus(id: string): Promise> { - return this.client.del<{}>(`/api/v1/scheduled_statuses/${id}`) - } - - // ====================================== - // timelines - // ====================================== - /** - * GET /api/v1/timelines/public - * - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getPublicTimeline(options?: { - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = { - local: false - } - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/public', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/public - * - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getLocalTimeline(options?: { - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = { - local: true - } - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/public', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/tag/:hashtag - * - * @param hashtag Content of a #hashtag, not including # symbol. - * @param options.local Show only local statuses? Defaults to false. - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getTagTimeline( - hashtag: string, - options?: { - local?: boolean - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>(`/api/v1/timelines/tag/${hashtag}`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/home - * - * @param options.local Show only local statuses? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getHomeTimeline(options?: { - local?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/home', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/list/:list_id - * - * @param list_id Local ID of the list in the database. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getListTimeline( - list_id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>(`/api/v1/timelines/list/${list_id}`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => MastodonAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // timelines/conversations - // ====================================== - /** - * GET /api/v1/conversations - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getConversationTimeline(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/conversations', params).then(res => { - return Object.assign(res, { - data: res.data.map(c => MastodonAPI.Converter.conversation(c)) - }) - }) - } - - /** - * DELETE /api/v1/conversations/:id - * - * @param id Target conversation ID. - */ - public deleteConversation(id: string): Promise> { - return this.client.del<{}>(`/api/v1/conversations/${id}`) - } - - /** - * POST /api/v1/conversations/:id/read - * - * @param id Target conversation ID. - * @return Conversation. - */ - public async readConversation(id: string): Promise> { - return this.client.post(`/api/v1/conversations/${id}/read`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.conversation(res.data) - }) - }) - } - - // ====================================== - // timelines/lists - // ====================================== - /** - * GET /api/v1/lists - * - * @return Array of lists. - */ - public async getLists(): Promise>> { - return this.client.get>('/api/v1/lists').then(res => { - return Object.assign(res, { - data: res.data.map(l => MastodonAPI.Converter.list(l)) - }) - }) - } - - /** - * GET /api/v1/lists/:id - * - * @param id Target list ID. - * @return List. - */ - public async getList(id: string): Promise> { - return this.client.get(`/api/v1/lists/${id}`).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.list(res.data) - }) - }) - } - - /** - * POST /api/v1/lists - * - * @param title List name. - * @return List. - */ - public async createList(title: string): Promise> { - return this.client - .post('/api/v1/lists', { - title: title - }) - .then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.list(res.data) - }) - }) - } - - /** - * PUT /api/v1/lists/:id - * - * @param id Target list ID. - * @param title New list name. - * @return List. - */ - public async updateList(id: string, title: string): Promise> { - return this.client - .put(`/api/v1/lists/${id}`, { - title: title - }) - .then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.list(res.data) - }) - }) - } - - /** - * DELETE /api/v1/lists/:id - * - * @param id Target list ID. - */ - public deleteList(id: string): Promise> { - return this.client.del<{}>(`/api/v1/lists/${id}`) - } - - /** - * GET /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param options.limit Max number of results to return. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getAccountsInList( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get>(`/api/v1/lists/${id}/accounts`, params).then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - - /** - * POST /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param account_ids Array of account IDs to add to the list. - */ - public addAccountsToList(id: string, account_ids: Array): Promise> { - return this.client.post<{}>(`/api/v1/lists/${id}/accounts`, { - account_ids: account_ids - }) - } - - /** - * DELETE /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param account_ids Array of account IDs to add to the list. - */ - public deleteAccountsFromList(id: string, account_ids: Array): Promise> { - return this.client.del<{}>(`/api/v1/lists/${id}/accounts`, { - account_ids: account_ids - }) - } - - // ====================================== - // timelines/markers - // ====================================== - /** - * GET /api/v1/markers - * - * @param timelines Array of timeline names, String enum anyOf home, notifications. - * @return Marker or empty object. - */ - public async getMarkers(timeline: Array): Promise>> { - return this.client - .get>('/api/v1/markers', { - timeline: timeline - }) - .then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.marker(res.data) - }) - }) - } - - /** - * POST /api/v1/markers - * - * @param options.home Marker position of the last read status ID in home timeline. - * @param options.notifications Marker position of the last read notification ID in notifications. - * @return Marker. - */ - public async saveMarkers(options?: { - home?: { last_read_id: string } - notifications?: { last_read_id: string } - }): Promise> { - let params = {} - if (options) { - if (options.home) { - params = Object.assign(params, { - home: options.home - }) - } - if (options.notifications) { - params = Object.assign(params, { - notifications: options.notifications - }) - } - } - return this.client.post('/api/v1/markers', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.marker(res.data) - }) - }) - } - - // ====================================== - // notifications - // ====================================== - /** - * GET /api/v1/notifications - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @param options.exclude_types Array of types to exclude. - * @param options.account_id Return only notifications received from this account. - * @return Array of notifications. - */ - public async getNotifications(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - exclude_types?: Array - account_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.exclude_types) { - params = Object.assign(params, { - exclude_types: options.exclude_types.map(e => MastodonAPI.Converter.encodeNotificationType(e)) - }) - } - if (options.account_id) { - params = Object.assign(params, { - account_id: options.account_id - }) - } - } - return this.client.get>('/api/v1/notifications', params).then(res => { - return Object.assign(res, { - data: res.data.flatMap(n => { - const notify = MastodonAPI.Converter.notification(n) - if (notify instanceof UnknownNotificationTypeError) return [] - return notify - }) - }) - }) - } - - /** - * GET /api/v1/notifications/:id - * - * @param id Target notification ID. - * @return Notification. - */ - public async getNotification(id: string): Promise> { - const res = await this.client.get(`/api/v1/notifications/${id}`) - const notify = MastodonAPI.Converter.notification(res.data) - if (notify instanceof UnknownNotificationTypeError) { - throw new UnknownNotificationTypeError() - } - return { ...res, data: notify } - } - - /** - * POST /api/v1/notifications/clear - */ - public dismissNotifications(): Promise> { - return this.client.post<{}>('/api/v1/notifications/clear') - } - - /** - * POST /api/v1/notifications/:id/dismiss - * - * @param id Target notification ID. - */ - public dismissNotification(id: string): Promise> { - return this.client.post<{}>(`/api/v1/notifications/${id}/dismiss`) - } - - public readNotifications(_options: { - id?: string - max_id?: string - }): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('mastodon does not support') - reject(err) - }) - } - - // ====================================== - // notifications/push - // ====================================== - /** - * POST /api/v1/push/subscription - * - * @param subscription[endpoint] Endpoint URL that is called when a notification event occurs. - * @param subscription[keys][p256dh] User agent public key. Base64 encoded string of public key of ECDH key using prime256v1 curve. - * @param subscription[keys] Auth secret. Base64 encoded string of 16 bytes of random data. - * @param data[alerts][follow] Receive follow notifications? - * @param data[alerts][favourite] Receive favourite notifications? - * @param data[alerts][reblog] Receive reblog notifictaions? - * @param data[alerts][mention] Receive mention notifications? - * @param data[alerts][poll] Receive poll notifications? - * @return PushSubscription. - */ - 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> { - let params = { - subscription - } - if (data) { - params = Object.assign(params, { - data - }) - } - return this.client.post('/api/v1/push/subscription', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * GET /api/v1/push/subscription - * - * @return PushSubscription. - */ - public async getPushSubscription(): Promise> { - return this.client.get('/api/v1/push/subscription').then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * PUT /api/v1/push/subscription - * - * @param data[alerts][follow] Receive follow notifications? - * @param data[alerts][favourite] Receive favourite notifications? - * @param data[alerts][reblog] Receive reblog notifictaions? - * @param data[alerts][mention] Receive mention notifications? - * @param data[alerts][poll] Receive poll notifications? - * @return PushSubscription. - */ - public async updatePushSubscription( - data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean } } | null - ): Promise> { - let params = {} - if (data) { - params = Object.assign(params, { - data - }) - } - return this.client.put('/api/v1/push/subscription', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * DELETE /api/v1/push/subscription - */ - public deletePushSubscription(): Promise> { - return this.client.del<{}>('/api/v1/push/subscription') - } - - // ====================================== - // search - // ====================================== - /** - * GET /api/v2/search - * - * @param q The search query. - * @param type Enum of search target. - * @param options.limit Maximum number of results to load, per type. Defaults to 20. Max 40. - * @param options.max_id Return results older than this id. - * @param options.min_id Return results immediately newer than this id. - * @param options.resolve Attempt WebFinger lookup. Defaults to false. - * @param options.following Only include accounts that the user is following. Defaults to false. - * @param options.account_id If provided, statuses returned will be authored only by this account. - * @param options.exclude_unreviewed Filter out unreviewed tags? Defaults to false. - * @return Results. - */ - public async search( - q: string, - options?: { - type?: 'accounts' | 'hashtags' | 'statuses' - limit?: number - max_id?: string - min_id?: string - resolve?: boolean - offset?: number - following?: boolean - account_id?: string - exclude_unreviewed?: boolean - } - ): Promise> { - let params = { - q - } - if (options) { - if (options.type) { - params = Object.assign(params, { - type: options.type - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.resolve !== undefined) { - params = Object.assign(params, { - resolve: options.resolve - }) - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset - }) - } - if (options.following !== undefined) { - params = Object.assign(params, { - following: options.following - }) - } - if (options.account_id) { - params = Object.assign(params, { - account_id: options.account_id - }) - } - if (options.exclude_unreviewed) { - params = Object.assign(params, { - exclude_unreviewed: options.exclude_unreviewed - }) - } - } - return this.client.get('/api/v2/search', params).then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.results(res.data) - }) - }) - } - - // ====================================== - // instance - // ====================================== - /** - * GET /api/v1/instance - */ - public async getInstance(): Promise> { - return this.client.get('/api/v1/instance').then(res => { - return Object.assign(res, { - data: MastodonAPI.Converter.instance(res.data) - }) - }) - } - - /** - * GET /api/v1/instance/peers - */ - public getInstancePeers(): Promise>> { - return this.client.get>('/api/v1/instance/peers') - } - - /** - * GET /api/v1/instance/activity - */ - public async getInstanceActivity(): Promise>> { - return this.client.get>('/api/v1/instance/activity').then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.activity(a)) - }) - }) - } - - // ====================================== - // instance/trends - // ====================================== - /** - * GET /api/v1/trends - * - * @param limit Maximum number of results to return. Defaults to 10. - */ - public async getInstanceTrends(limit?: number | null): Promise>> { - let params = {} - if (limit) { - params = Object.assign(params, { - limit - }) - } - return this.client.get>('/api/v1/trends', params).then(res => { - return Object.assign(res, { - data: res.data.map(t => MastodonAPI.Converter.tag(t)) - }) - }) - } - - // ====================================== - // instance/directory - // ====================================== - /** - * GET /api/v1/directory - * - * @param options.limit How many accounts to load. Default 40. - * @param options.offset How many accounts to skip before returning results. Default 0. - * @param options.order Order of results. - * @param options.local Only return local accounts. - * @return Array of accounts. - */ - public async getInstanceDirectory(options?: { - limit?: number - offset?: number - order?: 'active' | 'new' - local?: boolean - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset - }) - } - if (options.order) { - params = Object.assign(params, { - order: options.order - }) - } - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - } - return this.client.get>('/api/v1/directory', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // instance/custom_emojis - // ====================================== - /** - * GET /api/v1/custom_emojis - * - * @return Array of emojis. - */ - public async getInstanceCustomEmojis(): Promise>> { - return this.client.get>('/api/v1/custom_emojis').then(res => { - return Object.assign(res, { - data: res.data.map(e => MastodonAPI.Converter.emoji(e)) - }) - }) - } - - // ====================================== - // instance/announcements - // ====================================== - /** - * GET /api/v1/announcements - * - * @return Array of announcements. - */ - public async getInstanceAnnouncements(): Promise>> { - return this.client.get>('/api/v1/announcements').then(res => { - return Object.assign(res, { - data: res.data.map(a => MastodonAPI.Converter.announcement(a)) - }) - }) - } - - /** - * POST /api/v1/announcements/:id/dismiss - * - * @param id The ID of the Announcement in the database. - */ - public async dismissInstanceAnnouncement(id: string): Promise>> { - return this.client.post>(`/api/v1/announcements/${id}/dismiss`) - } - - /** - * PUT /api/v1/announcements/:id/reactions/:name - * - * @param id The ID of the Announcement in the database. - * @param name Unicode emoji, or the shortcode of a custom emoji. - */ - public async addReactionToAnnouncement(id: string, name: string): Promise>> { - return this.client.put>(`/api/v1/announcements/${id}/reactions/${name}`) - } - - /** - * DELETE /api/v1/announcements/:id/reactions/:name - * - * @param id The ID of the Announcement in the database. - * @param name Unicode emoji, or the shortcode of a custom emoji. - */ - public async removeReactionFromAnnouncement(id: string, name: string): Promise>> { - return this.client.del>(`/api/v1/announcements/${id}/reactions/${name}`) - } - - // ====================================== - // Emoji reactions - // ====================================== - public async createEmojiReaction(_id: string, _emoji: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('mastodon does not support') - reject(err) - }) - } - - public async deleteEmojiReaction(_id: string, _emoji: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('mastodon does not support') - reject(err) - }) - } - - public async getEmojiReactions(_id: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('mastodon does not support') - reject(err) - }) - } - - public async getEmojiReaction(_id: string, _emoji: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('mastodon does not support') - reject(err) - }) - } - - // ====================================== - // WebSocket - // ====================================== - public userSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'user') - } - - public publicSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'public') - } - - public localSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'public:local') - } - - public tagSocket(tag: string): WebSocket { - return this.client.socket('/api/v1/streaming', 'hashtag', `tag=${tag}`) - } - - public listSocket(list_id: string): WebSocket { - return this.client.socket('/api/v1/streaming', 'list', `list=${list_id}`) - } - - public directSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'direct') - } -} diff --git a/packages/megalodon/src/mastodon/api_client.ts b/packages/megalodon/src/mastodon/api_client.ts deleted file mode 100644 index ba4bd36ead..0000000000 --- a/packages/megalodon/src/mastodon/api_client.ts +++ /dev/null @@ -1,662 +0,0 @@ -import axios, { AxiosResponse, AxiosRequestConfig } from 'axios' -import objectAssignDeep from 'object-assign-deep' - -import WebSocket from './web_socket' -import Response from '../response' -import { RequestCanceledError } from '../cancel' -import proxyAgent, { ProxyConfig } from '../proxy_config' -import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from '../default' -import MastodonEntity from './entity' -import MegalodonEntity from '../entity' -import NotificationType, { UnknownNotificationTypeError } from '../notification' -import MastodonNotificationType from './notification' - -namespace MastodonAPI { - /** - * Interface - */ - export interface Interface { - get(path: string, params?: any, headers?: { [key: string]: string }, pathIsFullyQualified?: boolean): Promise> - put(path: string, params?: any, headers?: { [key: string]: string }): Promise> - putForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - patch(path: string, params?: any, headers?: { [key: string]: string }): Promise> - patchForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - post(path: string, params?: any, headers?: { [key: string]: string }): Promise> - postForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - del(path: string, params?: any, headers?: { [key: string]: string }): Promise> - cancel(): void - socket(path: string, stream: string, params?: string): WebSocket - } - - /** - * Mastodon API client. - * - * Using axios for request, you will handle promises. - */ - export class Client implements Interface { - static DEFAULT_SCOPE = DEFAULT_SCOPE - static DEFAULT_URL = 'https://mastodon.social' - static NO_REDIRECT = NO_REDIRECT - - private accessToken: string | null - private baseUrl: string - private userAgent: string - private abortController: AbortController - private proxyConfig: ProxyConfig | false = false - - /** - * @param baseUrl hostname or base URL - * @param accessToken access token from OAuth2 authorization - * @param userAgent UserAgent is specified in header on request. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - */ - constructor( - baseUrl: string, - accessToken: string | null = null, - userAgent: string = DEFAULT_UA, - proxyConfig: ProxyConfig | false = false - ) { - this.accessToken = accessToken - this.baseUrl = baseUrl - this.userAgent = userAgent - this.proxyConfig = proxyConfig - this.abortController = new AbortController() - axios.defaults.signal = this.abortController.signal - } - - /** - * GET request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Query parameters - * @param headers Request header object - */ - public async get( - path: string, - params = {}, - headers: { [key: string]: string } = {}, - pathIsFullyQualified = false - ): Promise> { - let options: AxiosRequestConfig = { - params: params, - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .get((pathIsFullyQualified ? '' : this.baseUrl) + path, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PUT request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async put(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .put(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PUT request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async putForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .putForm(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PATCH request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async patch(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .patch(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PATCH request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async patchForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .patchForm(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * POST request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async post(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios.post(this.baseUrl + path, params, options).then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * POST request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async postForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios.postForm(this.baseUrl + path, params, options).then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * DELETE request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async del(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - data: params, - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .delete(this.baseUrl + path, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * Cancel all requests in this instance. - * @returns void - */ - public cancel(): void { - return this.abortController.abort() - } - - /** - * Get connection and receive websocket connection for Pleroma API. - * - * @param path relative path from baseUrl: normally it is `/streaming`. - * @param stream Stream name, please refer: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/mastodon_api/mastodon_socket.ex#L19-28 - * @returns WebSocket, which inherits from EventEmitter - */ - public socket(path: string, stream: string, params?: string): WebSocket { - if (!this.accessToken) { - throw new Error('accessToken is required') - } - const url = this.baseUrl + path - const streaming = new WebSocket(url, stream, params, this.accessToken, this.userAgent, this.proxyConfig) - process.nextTick(() => { - streaming.start() - }) - return streaming - } - } - - export namespace Entity { - export type Account = MastodonEntity.Account - export type Activity = MastodonEntity.Activity - export type Announcement = MastodonEntity.Announcement - export type Application = MastodonEntity.Application - export type AsyncAttachment = MegalodonEntity.AsyncAttachment - export type Attachment = MastodonEntity.Attachment - export type Card = MastodonEntity.Card - export type Context = MastodonEntity.Context - export type Conversation = MastodonEntity.Conversation - export type Emoji = MastodonEntity.Emoji - export type FeaturedTag = MastodonEntity.FeaturedTag - export type Field = MastodonEntity.Field - export type Filter = MastodonEntity.Filter - export type History = MastodonEntity.History - export type IdentityProof = MastodonEntity.IdentityProof - export type Instance = MastodonEntity.Instance - export type List = MastodonEntity.List - export type Marker = MastodonEntity.Marker - export type Mention = MastodonEntity.Mention - export type Notification = MastodonEntity.Notification - export type Poll = MastodonEntity.Poll - export type PollOption = MastodonEntity.PollOption - export type Preferences = MastodonEntity.Preferences - export type PushSubscription = MastodonEntity.PushSubscription - export type Relationship = MastodonEntity.Relationship - export type Report = MastodonEntity.Report - export type Results = MastodonEntity.Results - export type Role = MastodonEntity.Role - export type ScheduledStatus = MastodonEntity.ScheduledStatus - export type Source = MastodonEntity.Source - export type Stats = MastodonEntity.Stats - export type Status = MastodonEntity.Status - export type StatusParams = MastodonEntity.StatusParams - export type StatusSource = MastodonEntity.StatusSource - export type Tag = MastodonEntity.Tag - export type Token = MastodonEntity.Token - export type URLs = MastodonEntity.URLs - } - - export namespace Converter { - export const encodeNotificationType = ( - t: MegalodonEntity.NotificationType - ): MastodonEntity.NotificationType | UnknownNotificationTypeError => { - switch (t) { - case NotificationType.Follow: - return MastodonNotificationType.Follow - case NotificationType.Favourite: - return MastodonNotificationType.Favourite - case NotificationType.Reblog: - return MastodonNotificationType.Reblog - case NotificationType.Mention: - return MastodonNotificationType.Mention - case NotificationType.FollowRequest: - return MastodonNotificationType.FollowRequest - case NotificationType.Status: - return MastodonNotificationType.Status - case NotificationType.PollExpired: - return MastodonNotificationType.Poll - case NotificationType.Update: - return MastodonNotificationType.Update - case NotificationType.AdminSignup: - return MastodonNotificationType.AdminSignup - case NotificationType.AdminReport: - return MastodonNotificationType.AdminReport - default: - return new UnknownNotificationTypeError() - } - } - - export const decodeNotificationType = ( - t: MastodonEntity.NotificationType - ): MegalodonEntity.NotificationType | UnknownNotificationTypeError => { - switch (t) { - case MastodonNotificationType.Follow: - return NotificationType.Follow - case MastodonNotificationType.Favourite: - return NotificationType.Favourite - case MastodonNotificationType.Mention: - return NotificationType.Mention - case MastodonNotificationType.Reblog: - return NotificationType.Reblog - case MastodonNotificationType.FollowRequest: - return NotificationType.FollowRequest - case MastodonNotificationType.Status: - return NotificationType.Status - case MastodonNotificationType.Poll: - return NotificationType.PollExpired - case MastodonNotificationType.Update: - return NotificationType.Update - case MastodonNotificationType.AdminSignup: - return NotificationType.AdminSignup - case MastodonNotificationType.AdminReport: - return NotificationType.AdminReport - default: - return new UnknownNotificationTypeError() - } - } - - export const account = (a: Entity.Account): MegalodonEntity.Account => a - export const activity = (a: Entity.Activity): MegalodonEntity.Activity => a - export const announcement = (a: Entity.Announcement): MegalodonEntity.Announcement => a - export const application = (a: Entity.Application): MegalodonEntity.Application => a - export const attachment = (a: Entity.Attachment): MegalodonEntity.Attachment => a - export const async_attachment = (a: Entity.AsyncAttachment) => { - if (a.url) { - return { - id: a.id, - type: a.type, - url: a.url!, - remote_url: a.remote_url, - preview_url: a.preview_url, - text_url: a.text_url, - meta: a.meta, - description: a.description, - blurhash: a.blurhash - } as MegalodonEntity.Attachment - } else { - return a as MegalodonEntity.AsyncAttachment - } - } - export const card = (c: Entity.Card): MegalodonEntity.Card => c - export const context = (c: Entity.Context): MegalodonEntity.Context => ({ - ancestors: Array.isArray(c.ancestors) ? c.ancestors.map(a => status(a)) : [], - descendants: Array.isArray(c.descendants) ? c.descendants.map(d => status(d)) : [] - }) - export const conversation = (c: Entity.Conversation): MegalodonEntity.Conversation => ({ - id: c.id, - accounts: Array.isArray(c.accounts) ? c.accounts.map(a => account(a)) : [], - last_status: c.last_status ? status(c.last_status) : null, - unread: c.unread - }) - export const emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => e - export const featured_tag = (e: Entity.FeaturedTag): MegalodonEntity.FeaturedTag => e - export const field = (f: Entity.Field): MegalodonEntity.Field => f - export const filter = (f: Entity.Filter): MegalodonEntity.Filter => f - export const history = (h: Entity.History): MegalodonEntity.History => h - export const identity_proof = (i: Entity.IdentityProof): MegalodonEntity.IdentityProof => i - export const instance = (i: Entity.Instance): MegalodonEntity.Instance => i - export const list = (l: Entity.List): MegalodonEntity.List => l - export const marker = (m: Entity.Marker | Record): MegalodonEntity.Marker | Record => m - export const mention = (m: Entity.Mention): MegalodonEntity.Mention => m - export const notification = (n: Entity.Notification): MegalodonEntity.Notification | UnknownNotificationTypeError => { - const notificationType = decodeNotificationType(n.type) - if (notificationType instanceof UnknownNotificationTypeError) return notificationType - if (n.status) { - return { - account: account(n.account), - created_at: n.created_at, - id: n.id, - status: status(n.status), - type: notificationType - } - } else { - return { - account: account(n.account), - created_at: n.created_at, - id: n.id, - type: notificationType - } - } - } - export const poll = (p: Entity.Poll): MegalodonEntity.Poll => p - export const poll_option = (p: Entity.PollOption): MegalodonEntity.PollOption => p - export const preferences = (p: Entity.Preferences): MegalodonEntity.Preferences => p - export const push_subscription = (p: Entity.PushSubscription): MegalodonEntity.PushSubscription => p - export const relationship = (r: Entity.Relationship): MegalodonEntity.Relationship => r - export const report = (r: Entity.Report): MegalodonEntity.Report => r - export const results = (r: Entity.Results): MegalodonEntity.Results => ({ - accounts: Array.isArray(r.accounts) ? r.accounts.map(a => account(a)) : [], - statuses: Array.isArray(r.statuses) ? r.statuses.map(s => status(s)) : [], - hashtags: Array.isArray(r.hashtags) ? r.hashtags.map(h => tag(h)) : [] - }) - export const scheduled_status = (s: Entity.ScheduledStatus): MegalodonEntity.ScheduledStatus => s - export const source = (s: Entity.Source): MegalodonEntity.Source => s - export const stats = (s: Entity.Stats): MegalodonEntity.Stats => s - export const status = (s: Entity.Status): MegalodonEntity.Status => ({ - id: s.id, - uri: s.uri, - url: s.url, - account: account(s.account), - in_reply_to_id: s.in_reply_to_id, - in_reply_to_account_id: s.in_reply_to_account_id, - reblog: s.reblog ? status(s.reblog) : s.quote ? status(s.quote) : null, - content: s.content, - plain_content: null, - created_at: s.created_at, - edited_at: s.edited_at || null, - emojis: Array.isArray(s.emojis) ? s.emojis.map(e => emoji(e)) : [], - replies_count: s.replies_count, - reblogs_count: s.reblogs_count, - favourites_count: s.favourites_count, - reblogged: s.reblogged, - favourited: s.favourited, - muted: s.muted, - sensitive: s.sensitive, - spoiler_text: s.spoiler_text, - visibility: s.visibility, - media_attachments: Array.isArray(s.media_attachments) ? s.media_attachments.map(m => attachment(m)) : [], - mentions: Array.isArray(s.mentions) ? s.mentions.map(m => mention(m)) : [], - tags: s.tags, - card: s.card ? card(s.card) : null, - poll: s.poll ? poll(s.poll) : null, - application: s.application ? application(s.application) : null, - language: s.language, - pinned: s.pinned, - emoji_reactions: [], - bookmarked: s.bookmarked ? s.bookmarked : false, - // Now quote is supported only fedibird.com. - quote: s.quote !== undefined && s.quote !== null - }) - export const status_params = (s: Entity.StatusParams): MegalodonEntity.StatusParams => s - export const status_source = (s: Entity.StatusSource): MegalodonEntity.StatusSource => s - export const tag = (t: Entity.Tag): MegalodonEntity.Tag => t - export const token = (t: Entity.Token): MegalodonEntity.Token => t - export const urls = (u: Entity.URLs): MegalodonEntity.URLs => u - } -} -export default MastodonAPI diff --git a/packages/megalodon/src/mastodon/entities/instance.ts b/packages/megalodon/src/mastodon/entities/instance.ts index 842e2c6bbf..ab0875a273 100644 --- a/packages/megalodon/src/mastodon/entities/instance.ts +++ b/packages/megalodon/src/mastodon/entities/instance.ts @@ -37,8 +37,15 @@ namespace MastodonEntity { min_expiration: number max_expiration: number } + accounts: { + max_featured_tags: number; + max_pinned_statuses: number; + } + reactions: { + max_reactions: number, + } } - contact_account: Account + contact_account: Account | null rules: Array } diff --git a/packages/megalodon/src/mastodon/notification.ts b/packages/megalodon/src/mastodon/notification.ts deleted file mode 100644 index b7551a019e..0000000000 --- a/packages/megalodon/src/mastodon/notification.ts +++ /dev/null @@ -1,16 +0,0 @@ -import MastodonEntity from './entity' - -namespace MastodonNotificationType { - export const Mention: MastodonEntity.NotificationType = 'mention' - export const Reblog: MastodonEntity.NotificationType = 'reblog' - export const Favourite: MastodonEntity.NotificationType = 'favourite' - export const Follow: MastodonEntity.NotificationType = 'follow' - export const Poll: MastodonEntity.NotificationType = 'poll' - export const FollowRequest: MastodonEntity.NotificationType = 'follow_request' - export const Status: MastodonEntity.NotificationType = 'status' - export const Update: MastodonEntity.NotificationType = 'update' - export const AdminSignup: MastodonEntity.NotificationType = 'admin.sign_up' - export const AdminReport: MastodonEntity.NotificationType = 'admin.report' -} - -export default MastodonNotificationType diff --git a/packages/megalodon/src/mastodon/web_socket.ts b/packages/megalodon/src/mastodon/web_socket.ts deleted file mode 100644 index 28bf38a666..0000000000 --- a/packages/megalodon/src/mastodon/web_socket.ts +++ /dev/null @@ -1,348 +0,0 @@ -import WS from 'ws' -import dayjs, { Dayjs } from 'dayjs' -import { EventEmitter } from 'events' -import proxyAgent, { ProxyConfig } from '../proxy_config' -import { WebSocketInterface } from '../megalodon' -import MastodonAPI from './api_client' -import { UnknownNotificationTypeError } from '../notification' - -/** - * WebSocket - * Pleroma is not support streaming. It is support websocket instead of streaming. - * So this class connect to Phoenix websocket for Pleroma. - */ -export default class WebSocket extends EventEmitter implements WebSocketInterface { - public url: string - public stream: string - public params: string | null - public parser: Parser - public headers: { [key: string]: string } - public proxyConfig: ProxyConfig | false = false - private _accessToken: string - private _reconnectInterval: number - private _reconnectMaxAttempts: number - private _reconnectCurrentAttempts: number - private _connectionClosed: boolean - private _client: WS | null - private _pongReceivedTimestamp: Dayjs - private _heartbeatInterval: number = 60000 - private _pongWaiting: boolean = false - - /** - * @param url Full url of websocket: e.g. https://pleroma.io/api/v1/streaming - * @param stream Stream name, please refer: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/mastodon_api/mastodon_socket.ex#L19-28 - * @param accessToken The access token. - * @param userAgent The specified User Agent. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - */ - constructor( - url: string, - stream: string, - params: string | undefined, - accessToken: string, - userAgent: string, - proxyConfig: ProxyConfig | false = false - ) { - super() - this.url = url - this.stream = stream - if (params === undefined) { - this.params = null - } else { - this.params = params - } - this.parser = new Parser() - this.headers = { - 'User-Agent': userAgent - } - this.proxyConfig = proxyConfig - this._accessToken = accessToken - this._reconnectInterval = 10000 - this._reconnectMaxAttempts = Infinity - this._reconnectCurrentAttempts = 0 - this._connectionClosed = false - this._client = null - this._pongReceivedTimestamp = dayjs() - } - - /** - * Start websocket connection. - */ - public start() { - this._connectionClosed = false - this._resetRetryParams() - this._startWebSocketConnection() - } - - /** - * Reset connection and start new websocket connection. - */ - private _startWebSocketConnection() { - this._resetConnection() - this._setupParser() - this._client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers, this.proxyConfig) - this._bindSocket(this._client) - } - - /** - * Stop current connection. - */ - public stop() { - this._connectionClosed = true - this._resetConnection() - this._resetRetryParams() - } - - /** - * Clean up current connection, and listeners. - */ - private _resetConnection() { - if (this._client) { - this._client.close(1000) - this._client.removeAllListeners() - this._client = null - } - - if (this.parser) { - this.parser.removeAllListeners() - } - } - - /** - * Resets the parameters used in reconnect. - */ - private _resetRetryParams() { - this._reconnectCurrentAttempts = 0 - } - - /** - * Reconnects to the same endpoint. - */ - private _reconnect() { - setTimeout(() => { - // Skip reconnect when client is connecting. - // https://github.com/websockets/ws/blob/7.2.1/lib/websocket.js#L365 - if (this._client && this._client.readyState === WS.CONNECTING) { - return - } - - if (this._reconnectCurrentAttempts < this._reconnectMaxAttempts) { - this._reconnectCurrentAttempts++ - this._clearBinding() - if (this._client) { - // In reconnect, we want to close the connection immediately, - // because recoonect is necessary when some problems occur. - this._client.terminate() - } - // Call connect methods - console.log('Reconnecting') - this._client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers, this.proxyConfig) - this._bindSocket(this._client) - } - }, this._reconnectInterval) - } - - /** - * @param url Base url of streaming endpoint. - * @param stream The specified stream name. - * @param accessToken Access token. - * @param headers The specified headers. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - * @return A WebSocket instance. - */ - private _connect( - url: string, - stream: string, - params: string | null, - accessToken: string, - headers: { [key: string]: string }, - proxyConfig: ProxyConfig | false - ): WS { - const parameter: Array = [`stream=${stream}`] - - if (params) { - parameter.push(params) - } - - if (accessToken !== null) { - parameter.push(`access_token=${accessToken}`) - } - const requestURL: string = `${url}/?${parameter.join('&')}` - let options: WS.ClientOptions = { - headers: headers - } - if (proxyConfig) { - options = Object.assign(options, { - agent: proxyAgent(proxyConfig) - }) - } - - const cli: WS = new WS(requestURL, options) - return cli - } - - /** - * Clear binding event for web socket client. - */ - private _clearBinding() { - if (this._client) { - this._client.removeAllListeners('close') - this._client.removeAllListeners('pong') - this._client.removeAllListeners('open') - this._client.removeAllListeners('message') - this._client.removeAllListeners('error') - } - } - - /** - * Bind event for web socket client. - * @param client A WebSocket instance. - */ - private _bindSocket(client: WS) { - client.on('close', (code: number, _reason: Buffer) => { - // Refer the code: https://tools.ietf.org/html/rfc6455#section-7.4 - if (code === 1000) { - this.emit('close', {}) - } else { - console.log(`Closed connection with ${code}`) - // If already called close method, it does not retry. - if (!this._connectionClosed) { - this._reconnect() - } - } - }) - client.on('pong', () => { - this._pongWaiting = false - this.emit('pong', {}) - this._pongReceivedTimestamp = dayjs() - // It is required to anonymous function since get this scope in checkAlive. - setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) - }) - client.on('open', () => { - this.emit('connect', {}) - // Call first ping event. - setTimeout(() => { - client.ping('') - }, 10000) - }) - client.on('message', (data: WS.Data, isBinary: boolean) => { - this.parser.parse(data, isBinary) - }) - client.on('error', (err: Error) => { - this.emit('error', err) - }) - } - - /** - * Set up parser when receive message. - */ - private _setupParser() { - this.parser.on('update', (status: MastodonAPI.Entity.Status) => { - this.emit('update', MastodonAPI.Converter.status(status)) - }) - this.parser.on('notification', (notification: MastodonAPI.Entity.Notification) => { - const n = MastodonAPI.Converter.notification(notification) - if (n instanceof UnknownNotificationTypeError) { - console.warn(`Unknown notification event has received: ${notification}`) - } else { - this.emit('notification', n) - } - }) - this.parser.on('delete', (id: string) => { - this.emit('delete', id) - }) - this.parser.on('conversation', (conversation: MastodonAPI.Entity.Conversation) => { - this.emit('conversation', MastodonAPI.Converter.conversation(conversation)) - }) - this.parser.on('status_update', (status: MastodonAPI.Entity.Status) => { - this.emit('status_update', MastodonAPI.Converter.status(status)) - }) - this.parser.on('error', (err: Error) => { - this.emit('parser-error', err) - }) - this.parser.on('heartbeat', _ => { - this.emit('heartbeat', 'heartbeat') - }) - } - - /** - * Call ping and wait to pong. - */ - private _checkAlive(timestamp: Dayjs) { - const now: Dayjs = dayjs() - // Block multiple calling, if multiple pong event occur. - // It the duration is less than interval, through ping. - if (now.diff(timestamp) > this._heartbeatInterval - 1000 && !this._connectionClosed) { - // Skip ping when client is connecting. - // https://github.com/websockets/ws/blob/7.2.1/lib/websocket.js#L289 - if (this._client && this._client.readyState !== WS.CONNECTING) { - this._pongWaiting = true - this._client.ping('') - setTimeout(() => { - if (this._pongWaiting) { - this._pongWaiting = false - this._reconnect() - } - }, 10000) - } - } - } -} - -/** - * Parser - * This class provides parser for websocket message. - */ -export class Parser extends EventEmitter { - /** - * @param message Message body of websocket. - */ - public parse(data: WS.Data, isBinary: boolean) { - const message = isBinary ? data : data.toString() - if (typeof message !== 'string') { - this.emit('heartbeat', {}) - return - } - - if (message === '') { - this.emit('heartbeat', {}) - return - } - - let event = '' - let payload = '' - let mes = {} - try { - const obj = JSON.parse(message) - event = obj.event - payload = obj.payload - mes = JSON.parse(payload) - } catch (err) { - // delete event does not have json object - if (event !== 'delete') { - this.emit('error', new Error(`Error parsing websocket reply: ${message}, error message: ${err}`)) - return - } - } - - switch (event) { - case 'update': - this.emit('update', mes as MastodonAPI.Entity.Status) - break - case 'notification': - this.emit('notification', mes as MastodonAPI.Entity.Notification) - break - case 'conversation': - this.emit('conversation', mes as MastodonAPI.Entity.Conversation) - break - case 'delete': - this.emit('delete', payload) - break - case 'status.update': - this.emit('status_update', mes as MastodonAPI.Entity.Status) - break - default: - this.emit('error', new Error(`Unknown event has received: ${message}`)) - } - } -} diff --git a/packages/megalodon/src/megalodon.ts b/packages/megalodon/src/megalodon.ts index e2245f7c21..4328f41f1c 100644 --- a/packages/megalodon/src/megalodon.ts +++ b/packages/megalodon/src/megalodon.ts @@ -1,11 +1,6 @@ import Response from './response' import OAuth from './oauth' -import Pleroma from './pleroma' -import { ProxyConfig } from './proxy_config' -import Mastodon from './mastodon' import Entity from './entity' -import Misskey from './misskey' -import Friendica from './friendica' export interface WebSocketInterface { start(): void @@ -1413,42 +1408,3 @@ export class NodeinfoError extends Error { Object.setPrototypeOf(this, new.target.prototype) } } - -/** - * Get client for each SNS according to megalodon interface. - * - * @param sns Name of your SNS, `mastodon` or `pleroma`. - * @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. - * @return Client instance for each SNS you specified. - */ -const generator = ( - sns: 'mastodon' | 'pleroma' | 'misskey' | 'friendica', - baseUrl: string, - accessToken: string | null = null, - userAgent: string | null = null, - proxyConfig: ProxyConfig | false = false -): MegalodonInterface => { - switch (sns) { - case 'pleroma': { - const pleroma = new Pleroma(baseUrl, accessToken, userAgent, proxyConfig) - return pleroma - } - case 'misskey': { - const misskey = new Misskey(baseUrl, accessToken, userAgent, proxyConfig) - return misskey - } - case 'friendica': { - const friendica = new Friendica(baseUrl, accessToken, userAgent, proxyConfig) - return friendica - } - case 'mastodon': { - const mastodon = new Mastodon(baseUrl, accessToken, userAgent, proxyConfig) - return mastodon - } - } -} - -export default generator diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index 01d5652650..c9a33e3130 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -303,8 +303,8 @@ export default class Misskey implements MegalodonInterface { max_id?: string since_id?: string pinned?: boolean - exclude_replies: boolean - exclude_reblogs: boolean + exclude_replies?: boolean + exclude_reblogs?: boolean only_media?: boolean } ): Promise>> { @@ -2352,6 +2352,18 @@ export default class Misskey implements MegalodonInterface { } })) } + default: { + return { + status: 400, + statusText: 'bad request', + headers: {}, + data: { + accounts: [], + statuses: [], + hashtags: [], + } + } + } } } diff --git a/packages/megalodon/src/pleroma.ts b/packages/megalodon/src/pleroma.ts deleted file mode 100644 index 265c7d3c0b..0000000000 --- a/packages/megalodon/src/pleroma.ts +++ /dev/null @@ -1,3217 +0,0 @@ -import { OAuth2 } from 'oauth' -import FormData from 'form-data' - -import PleromaAPI from './pleroma/api_client' -import WebSocket from './pleroma/web_socket' -import { MegalodonInterface, NoImplementedError, ArgumentError } from './megalodon' -import Response from './response' -import Entity from './entity' -import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from './default' -import { ProxyConfig } from './proxy_config' -import OAuth from './oauth' -import { UnknownNotificationTypeError } from './notification' - -export default class Pleroma implements MegalodonInterface { - public client: PleromaAPI.Interface - public baseUrl: string - - /** - * @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.client = new PleromaAPI.Client(baseUrl, token, agent, proxyConfig) - this.baseUrl = baseUrl - } - - public cancel(): void { - return this.client.cancel() - } - - /** - * First, call createApp to get client_id and client_secret. - * Next, call generateAuthUrl to get authorization url. - * @param client_name Form Data, which is sent to /api/v1/apps - * @param options Form Data, which is sent to /api/v1/apps. and properties should be **snake_case** - */ - public async registerApp( - client_name: string, - options: Partial<{ scopes: Array; redirect_uris: string; website: string }> - ): Promise { - const scopes = options.scopes || DEFAULT_SCOPE - return this.createApp(client_name, options).then(async appData => { - return this.generateAuthUrl(appData.client_id, appData.client_secret, { - scope: scopes, - redirect_uri: appData.redirect_uri - }).then(url => { - appData.url = url - return appData - }) - }) - } - - /** - * Call /api/v1/apps - * - * 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 }> - ): Promise { - const scopes = options.scopes || DEFAULT_SCOPE - const redirect_uris = options.redirect_uris || NO_REDIRECT - - const params: { - client_name: string - redirect_uris: string - scopes: string - website?: string - } = { - client_name: client_name, - redirect_uris: redirect_uris, - scopes: scopes.join(' ') - } - if (options.website) params.website = options.website - - return this.client - .post('/api/v1/apps', params) - .then((res: Response) => OAuth.AppData.from(res.data)) - } - - /** - * Generate authorization url using OAuth2. - * - * @param clientId your OAuth app's client ID - * @param clientSecret your OAuth app's client Secret - * @param options as property, redirect_uri and scope are available, and must be the same as when you register your app - */ - public generateAuthUrl( - clientId: string, - clientSecret: string, - options: Partial<{ scope: Array; redirect_uri: string }> - ): Promise { - const scope = options.scope || DEFAULT_SCOPE - const redirect_uri = options.redirect_uri || NO_REDIRECT - return new Promise(resolve => { - const oauth = new OAuth2(clientId, clientSecret, this.baseUrl, undefined, '/oauth/token') - const url = oauth.getAuthorizeUrl({ - redirect_uri: redirect_uri, - response_type: 'code', - client_id: clientId, - scope: scope.join(' ') - }) - resolve(url) - }) - } - - // ====================================== - // apps - // ====================================== - /** - * GET /api/v1/apps/verify_credentials - * - * @return An Application - */ - public verifyAppCredentials(): Promise> { - return this.client.get('/api/v1/apps/verify_credentials') - } - - // ====================================== - // apps/oauth - // ====================================== - /** - * POST /oauth/token - * - * Fetch OAuth access token. - * Get an access token based client_id and client_secret and authorization code. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param code will be generated by the link of #generateAuthUrl or #registerApp - * @param redirect_uri must be the same uri as the time when you register your OAuth application - */ - public async fetchAccessToken( - client_id: string | null, - client_secret: string, - code: string, - redirect_uri: string = NO_REDIRECT - ): Promise { - if (!client_id) { - throw new Error('client_id is required') - } - return this.client - .post('/oauth/token', { - client_id, - client_secret, - code, - redirect_uri, - grant_type: 'authorization_code' - }) - .then((res: Response) => OAuth.TokenData.from(res.data)) - } - - /** - * POST /oauth/token - * - * Refresh OAuth access token. - * Send refresh token and get new access token. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param refresh_token will be get #fetchAccessToken - */ - public async refreshToken(client_id: string, client_secret: string, refresh_token: string): Promise { - return this.client - .post('/oauth/token', { - client_id, - client_secret, - refresh_token, - grant_type: 'refresh_token' - }) - .then((res: Response) => OAuth.TokenData.from(res.data)) - } - - /** - * POST /oauth/revoke - * - * Revoke an OAuth token. - * @param client_id will be generated by #createApp or #registerApp - * @param client_secret will be generated by #createApp or #registerApp - * @param token will be get #fetchAccessToken - */ - public async revokeToken(client_id: string, client_secret: string, token: string): Promise> { - return this.client.post<{}>('/oauth/revoke', { - client_id, - client_secret, - token - }) - } - - // ====================================== - // accounts - // ====================================== - /** - * POST /api/v1/accounts - * - * @param username Username for the account. - * @param email Email for the account. - * @param password Password for the account. - * @param agreement Whether the user agrees to the local rules, terms, and policies. - * @param locale The language of the confirmation email that will be sent - * @param reason Text that will be reviewed by moderators if registrations require manual approval. - * @return An account token. - */ - public async registerAccount( - username: string, - email: string, - password: string, - agreement: boolean, - locale: string, - reason?: string | null - ): Promise> { - let params = { - username: username, - email: email, - password: password, - agreement: agreement, - locale: locale - } - if (reason) { - params = Object.assign(params, { - reason: reason - }) - } - return this.client.post('/api/v1/accounts', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.token(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/verify_credentials - * - * @return Account. - */ - public async verifyAccountCredentials(): Promise> { - return this.client.get('/api/v1/accounts/verify_credentials').then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.account(res.data) - }) - }) - } - - /** - * PATCH /api/v1/accounts/update_credentials - * - * @return An account. - */ - 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 - } - fields_attributes?: Array<{ name: string; value: string }> - }): Promise> { - let params = {} - if (options) { - if (options.discoverable !== undefined) { - params = Object.assign(params, { - discoverable: options.discoverable - }) - } - if (options.bot !== undefined) { - params = Object.assign(params, { - bot: options.bot - }) - } - if (options.display_name) { - params = Object.assign(params, { - display_name: options.display_name - }) - } - if (options.note) { - params = Object.assign(params, { - note: options.note - }) - } - if (options.avatar) { - params = Object.assign(params, { - avatar: options.avatar - }) - } - if (options.header) { - params = Object.assign(params, { - header: options.header - }) - } - if (options.locked !== undefined) { - params = Object.assign(params, { - locked: options.locked - }) - } - if (options.source) { - params = Object.assign(params, { - source: options.source - }) - } - if (options.fields_attributes) { - params = Object.assign(params, { - fields_attributes: options.fields_attributes - }) - } - } - return this.client.patch('/api/v1/accounts/update_credentials', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.account(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/:id - * - * @param id The account ID. - * @return An account. - */ - public async getAccount(id: string): Promise> { - return this.client.get(`/api/v1/accounts/${id}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.account(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/statuses - * - * @param id The account ID. - - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID but starting with most recent. - * @param options.min_id Return results newer than ID. - * @param options.pinned Return statuses which include pinned statuses. - * @param options.exclude_replies Return statuses which exclude replies. - * @param options.exclude_reblogs Return statuses which exclude reblogs. - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @return Account's statuses. - */ - 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>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.pinned) { - params = Object.assign(params, { - pinned: options.pinned - }) - } - if (options.exclude_replies) { - params = Object.assign(params, { - exclude_replies: options.exclude_replies - }) - } - if (options.exclude_reblogs) { - params = Object.assign(params, { - exclude_reblogs: options.exclude_reblogs - }) - } - if (options.only_media) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - } - return this.client.get>(`/api/v1/accounts/${id}/statuses`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/pleroma/accounts/:id/favourites - * - * @param id Target account ID. - * @param options.limit Max number of results to return. - * @param options.max_id Return results order than ID. - * @param options.since_id Return results newer than ID. - * @return Array of statuses. - */ - public async getAccountFavourites( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get>(`/api/v1/pleroma/accounts/${id}/favourites`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - /** - * POST /api/v1/pleroma/accounts/:id/subscribe - * - * @param id Target account ID. - * @return Relationship. - */ - public async subscribeAccount(id: string): Promise> { - return this.client.post(`/api/v1/pleroma/accounts/${id}/subscribe`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/pleroma/accounts/:id/unsubscribe - * - * @param id Target account ID. - * @return Relationship. - */ - public async unsubscribeAccount(id: string): Promise> { - return this.client.post(`/api/v1/pleroma/accounts/${id}/unsubscribe`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/followers - * - * @param id The account ID. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async getAccountFollowers( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>(`/api/v1/accounts/${id}/followers`, params).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/following - * - * @param id The account ID. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async getAccountFollowing( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>(`/api/v1/accounts/${id}/following`, params).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/lists - * - * @param id The account ID. - * @return The array of lists. - */ - public async getAccountLists(id: string): Promise>> { - return this.client.get>(`/api/v1/accounts/${id}/lists`).then(res => { - return Object.assign(res, { - data: res.data.map(l => PleromaAPI.Converter.list(l)) - }) - }) - } - - /** - * GET /api/v1/accounts/:id/identity_proofs - * - * @param id The account ID. - * @return Array of IdentityProof - */ - public async getIdentityProof(id: string): Promise>> { - return this.client.get>(`/api/v1/accounts/${id}/identity_proofs`).then(res => { - return Object.assign(res, { - data: res.data.map(i => PleromaAPI.Converter.identity_proof(i)) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/follow - * - * @param id The account ID. - * @param reblog Receive this account's reblogs in home timeline. - * @return Relationship - */ - public async followAccount(id: string, options?: { reblog?: boolean }): Promise> { - let params = {} - if (options) { - if (options.reblog !== undefined) { - params = Object.assign(params, { - reblog: options.reblog - }) - } - } - return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unfollow - * - * @param id The account ID. - * @return Relationship - */ - public async unfollowAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unfollow`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/block - * - * @param id The account ID. - * @return Relationship - */ - public async blockAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/block`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unblock - * - * @param id The account ID. - * @return RElationship - */ - public async unblockAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unblock`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/mute - * - * @param id The account ID. - * @param notifications Mute notifications in addition to statuses. - * @return Relationship - */ - public async muteAccount(id: string, notifications: boolean = true): Promise> { - return this.client - .post(`/api/v1/accounts/${id}/mute`, { - notifications: notifications - }) - .then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unmute - * - * @param id The account ID. - * @return Relationship - */ - public async unmuteAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unmute`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/pin - * - * @param id The account ID. - * @return Relationship - */ - public async pinAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/pin`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/accounts/:id/unpin - * - * @param id The account ID. - * @return Relationship - */ - public async unpinAccount(id: string): Promise> { - return this.client.post(`/api/v1/accounts/${id}/unpin`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * GET /api/v1/accounts/relationships - * - * @param id The account ID. - * @return Relationship - */ - public async getRelationship(id: string): Promise> { - return this.client - .get>('/api/v1/accounts/relationships', { - id: [id] - }) - .then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data[0]) - }) - }) - } - - /** - * Get multiple relationships in one method - * - * @param ids Array of account IDs. - * @return Array of Relationship. - */ - public async getRelationships(ids: Array): Promise>> { - return this.client - .get>('/api/v1/accounts/relationships', { - id: ids - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(r => PleromaAPI.Converter.relationship(r)) - }) - }) - } - - /** - * GET /api/v1/accounts/search - * - * @param q Search query. - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return The array of accounts. - */ - public async searchAccount( - q: string, - options?: { - following?: boolean - resolve?: boolean - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = { q: q } - if (options) { - if (options.following !== undefined && options.following !== null) { - params = Object.assign(params, { - following: options.following - }) - } - if (options.resolve !== undefined && options.resolve !== null) { - params = Object.assign(params, { - resolve: options.resolve - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/accounts/search', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/bookmarks - // ====================================== - /** - * GET /api/v1/bookmarks - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getBookmarks(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - } - return this.client.get>('/api/v1/bookmarks', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // accounts/favourites - // ====================================== - /** - * GET /api/v1/favourites - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getFavourites(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/favourites', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // accounts/mutes - // ====================================== - /** - * GET /api/v1/mutes - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getMutes(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/mutes', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/blocks - // ====================================== - /** - * GET /api/v1/blocks - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/blocks', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/domain_blocks - // ====================================== - /** - * GET /api/v1/domain_blocks - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of domain name. - */ - public async getDomainBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/domain_blocks', params) - } - - /** - * POST/api/v1/domain_blocks - * - * @param domain Domain to block. - */ - public blockDomain(domain: string): Promise> { - return this.client.post<{}>('/api/v1/domain_blocks', { - domain: domain - }) - } - - /** - * DELETE /api/v1/domain_blocks - * - * @param domain Domain to unblock - */ - public unblockDomain(domain: string): Promise> { - return this.client.del<{}>('/api/v1/domain_blocks', { - domain: domain - }) - } - - // ====================================== - // accounts/filters - // ====================================== - /** - * GET /api/v1/filters - * - * @return Array of filters. - */ - public async getFilters(): Promise>> { - return this.client.get>('/api/v1/filters').then(res => { - return Object.assign(res, { - data: res.data.map(f => PleromaAPI.Converter.filter(f)) - }) - }) - } - - /** - * GET /api/v1/filters/:id - * - * @param id The filter ID. - * @return Filter. - */ - public async getFilter(id: string): Promise> { - return this.client.get(`/api/v1/filters/${id}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.filter(res.data) - }) - }) - } - - /** - * POST /api/v1/filters - * - * @param phrase Text to be filtered. - * @param context Array of enumerable strings home, notifications, public, thread, account. At least one context must be specified. - * @param options.irreversible Should the server irreversibly drop matching entities from home and notifications? - * @param options.whole_word Consider word boundaries? - * @param options.expires_in ISO 8601 Datetime for when the filter expires. - * @return Filter - */ - public async createFilter( - phrase: string, - context: Array, - options?: { - irreversible?: boolean - whole_word?: boolean - expires_in?: string - } - ): Promise> { - let params = { - phrase: phrase, - context: context - } - if (options) { - if (options.irreversible !== undefined) { - params = Object.assign(params, { - irreversible: options.irreversible - }) - } - if (options.whole_word !== undefined) { - params = Object.assign(params, { - whole_word: options.whole_word - }) - } - if (options.expires_in) { - params = Object.assign(params, { - expires_in: options.expires_in - }) - } - } - return this.client.post('/api/v1/filters', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.filter(res.data) - }) - }) - } - - /** - * PUT /api/v1/filters/:id - * - * @param id The filter ID. - * @param phrase Text to be filtered. - * @param context Array of enumerable strings home, notifications, public, thread, account. At least one context must be specified. - * @param options.irreversible Should the server irreversibly drop matching entities from home and notifications? - * @param options.whole_word Consider word boundaries? - * @param options.expires_in ISO 8601 Datetime for when the filter expires. - * @return Filter - */ - public async updateFilter( - id: string, - phrase: string, - context: Array, - options?: { - irreversible?: boolean - whole_word?: boolean - expires_in?: string - } - ): Promise> { - let params = { - phrase: phrase, - context: context - } - if (options) { - if (options.irreversible !== undefined) { - params = Object.assign(params, { - irreversible: options.irreversible - }) - } - if (options.whole_word !== undefined) { - params = Object.assign(params, { - whole_word: options.whole_word - }) - } - if (options.expires_in) { - params = Object.assign(params, { - expires_in: options.expires_in - }) - } - } - return this.client.put(`/api/v1/filters/${id}`, params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.filter(res.data) - }) - }) - } - - /** - * DELETE /api/v1/filters/:id - * - * @param id The filter ID. - * @return Removed filter. - */ - public async deleteFilter(id: string): Promise> { - return this.client.del(`/api/v1/filters/${id}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.filter(res.data) - }) - }) - } - - // ====================================== - // accounts/reports - // ====================================== - /** - * POST /api/v1/reports - * - * @param account_id Target account ID. - * @param options.status_ids Array of Statuses ids to attach to the report. - * @param options.comment The reason for the report. Default maximum of 1000 characters. - * @param options.forward If the account is remote, should the report be forwarded to the remote admin? - * @param options.category Specify if the report is due to spam, violation of enumerated instance rules, or some other reason. Defaults to other. Will be set to violation if rule_ids[] is provided (regardless of any category value you provide). - * @param options.rule_ids For violation category reports, specify the ID of the exact rules broken. Rules and their IDs are available via GET /api/v1/instance/rules and GET /api/v1/instance. - * @return Report - */ - public async report( - account_id: string, - options?: { - status_ids?: Array - comment: string - forward?: boolean - category?: Entity.Category - rule_ids?: Array - } - ): Promise> { - let params = { - account_id: account_id - } - if (options) { - if (options.status_ids) { - params = Object.assign(params, { - status_ids: options.status_ids - }) - } - if (options.comment) { - params = Object.assign(params, { - comment: options.comment - }) - } - if (options.forward !== undefined) { - params = Object.assign(params, { - forward: options.forward - }) - } - if (options.category) { - params = Object.assign(params, { - category: options.category - }) - } - if (options.rule_ids) { - params = Object.assign(params, { - rule_ids: options.rule_ids - }) - } - } - return this.client.post('/api/v1/reports', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.report(res.data) - }) - }) - } - - // ====================================== - // accounts/follow_requests - // ====================================== - /** - * GET /api/v1/follow_requests - * - * @param limit Maximum number of results. - * @return Array of account. - */ - public async getFollowRequests(limit?: number): Promise>> { - if (limit) { - return this.client - .get>('/api/v1/follow_requests', { - limit: limit - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } else { - return this.client.get>('/api/v1/follow_requests').then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - } - - /** - * POST /api/v1/follow_requests/:id/authorize - * - * @param id Target account ID. - * @return Relationship. - */ - public async acceptFollowRequest(id: string): Promise> { - return this.client.post(`/api/v1/follow_requests/${id}/authorize`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - /** - * POST /api/v1/follow_requests/:id/reject - * - * @param id Target account ID. - * @return Relationship. - */ - public async rejectFollowRequest(id: string): Promise> { - return this.client.post(`/api/v1/follow_requests/${id}/reject`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.relationship(res.data) - }) - }) - } - - // ====================================== - // accounts/endorsements - // ====================================== - /** - * GET /api/v1/endorsements - * - * @param options.limit Max number of results to return. Defaults to 40. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @return Array of accounts. - */ - public async getEndorsements(options?: { limit?: number; max_id?: string; since_id?: string }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get>('/api/v1/endorsements', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // accounts/featured_tags - // ====================================== - /** - * GET /api/v1/featured_tags - * - * @return Array of featured tag. - */ - public async getFeaturedTags(): Promise>> { - return this.client.get>('/api/v1/featured_tags').then(res => { - return Object.assign(res, { - data: res.data.map(f => PleromaAPI.Converter.featured_tag(f)) - }) - }) - } - - /** - * POST /api/v1/featured_tags - * - * @param name Target hashtag name. - * @return FeaturedTag. - */ - public async createFeaturedTag(name: string): Promise> { - return this.client - .post('/api/v1/featured_tags', { - name: name - }) - .then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.featured_tag(res.data) - }) - }) - } - - /** - * DELETE /api/v1/featured_tags/:id - * - * @param id Target featured tag id. - * @return Empty - */ - public deleteFeaturedTag(id: string): Promise> { - return this.client.del<{}>(`/api/v1/featured_tags/${id}`) - } - - /** - * GET /api/v1/featured_tags/suggestions - * - * @return Array of tag. - */ - public async getSuggestedTags(): Promise>> { - return this.client.get>('/api/v1/featured_tags/suggestions').then(res => { - return Object.assign(res, { - data: res.data.map(t => PleromaAPI.Converter.tag(t)) - }) - }) - } - - // ====================================== - // accounts/preferences - // ====================================== - /** - * GET /api/v1/preferences - * - * @return Preferences. - */ - public async getPreferences(): Promise> { - return this.client.get('/api/v1/preferences').then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.preferences(res.data) - }) - }) - } - - // ====================================== - // accounts/followed_tags - // ====================================== - public async getFollowedTags(): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('pleroma does not support') - reject(err) - }) - } - - // ====================================== - // accounts/suggestions - // ====================================== - /** - * GET /api/v1/suggestions - * - * @param limit Maximum number of results. - * @return Array of accounts. - */ - public async getSuggestions(limit?: number): Promise>> { - if (limit) { - return this.client - .get>('/api/v1/suggestions', { - limit: limit - }) - .then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } else { - return this.client.get>('/api/v1/suggestions').then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - } - - // ====================================== - // accounts/tags - // ====================================== - /** - * GET /api/v1/tags/:id - * - * @param id Target hashtag id. - * @return Tag - */ - public async getTag(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('pleroma does not support') - reject(err) - }) - } - - /** - * POST /api/v1/tags/:id/follow - * - * @param id Target hashtag id. - * @return Tag - */ - public async followTag(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('pleroma does not support') - reject(err) - }) - } - - /** - * POST /api/v1/tags/:id/unfollow - * - * @param id Target hashtag id. - * @return Tag - */ - public async unfollowTag(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('pleroma does not support') - reject(err) - }) - } - - // ====================================== - // statuses - // ====================================== - /** - * POST /api/v1/statuses - * - * @param status Text content of status. - * @param options.media_ids Array of Attachment ids. - * @param options.poll Poll object. - * @param options.in_reply_to_id ID of the status being replied to, if status is a reply. - * @param options.sensitive Mark status and attached media as sensitive? - * @param options.spoiler_text Text to be shown as a warning or subject before the actual content. - * @param options.visibility Visibility of the posted status. - * @param options.scheduled_at ISO 8601 Datetime at which to schedule a status. - * @param options.language ISO 639 language code for this status. - * @param options.quote_id ID of the status being quoted to, if status is a quote. - * @return Status. When options.scheduled_at is present, ScheduledStatus is returned instead. - */ - 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 = { - status: status - } - if (options) { - if (options.media_ids) { - params = Object.assign(params, { - media_ids: options.media_ids - }) - } - if (options.poll) { - let pollParam = { - options: options.poll.options, - expires_in: options.poll.expires_in - } - if (options.poll.multiple !== undefined) { - pollParam = Object.assign(pollParam, { - multiple: options.poll.multiple - }) - } - if (options.poll.hide_totals !== undefined) { - pollParam = Object.assign(pollParam, { - hide_totals: options.poll.hide_totals - }) - } - params = Object.assign(params, { - poll: pollParam - }) - } - if (options.in_reply_to_id) { - params = Object.assign(params, { - in_reply_to_id: options.in_reply_to_id - }) - } - if (options.sensitive !== undefined) { - params = Object.assign(params, { - sensitive: options.sensitive - }) - } - if (options.spoiler_text) { - params = Object.assign(params, { - spoiler_text: options.spoiler_text - }) - } - if (options.visibility) { - params = Object.assign(params, { - visibility: options.visibility - }) - } - if (options.scheduled_at) { - params = Object.assign(params, { - scheduled_at: options.scheduled_at - }) - } - if (options.language) { - params = Object.assign(params, { - language: options.language - }) - } - if (options.quote_id) { - params = Object.assign(params, { - quote_id: options.quote_id - }) - } - } - if (options && options.scheduled_at) { - return this.client.post('/api/v1/statuses', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.scheduled_status(res.data) - }) - }) - } - return this.client.post('/api/v1/statuses', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - public async getStatus(id: string): Promise> { - return this.client.get(`/api/v1/statuses/${id}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - PUT /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - 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> { - let params = {} - if (options.status) { - params = Object.assign(params, { - status: options.status - }) - } - if (options.spoiler_text) { - params = Object.assign(params, { - spoiler_text: options.spoiler_text - }) - } - if (options.sensitive) { - params = Object.assign(params, { - sensitive: options.sensitive - }) - } - if (options.media_ids) { - params = Object.assign(params, { - media_ids: options.media_ids - }) - } - if (options.poll) { - let pollParam = {} - if (options.poll.options !== undefined) { - pollParam = Object.assign(pollParam, { - options: options.poll.options - }) - } - if (options.poll.expires_in !== undefined) { - pollParam = Object.assign(pollParam, { - expires_in: options.poll.expires_in - }) - } - if (options.poll.multiple !== undefined) { - pollParam = Object.assign(pollParam, { - multiple: options.poll.multiple - }) - } - if (options.poll.hide_totals !== undefined) { - pollParam = Object.assign(pollParam, { - hide_totals: options.poll.hide_totals - }) - } - params = Object.assign(params, { - poll: pollParam - }) - } - return this.client.put(`/api/v1/statuses/${id}`, params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * DELETE /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - public async deleteStatus(id: string): Promise> { - return this.client.del(`/api/v1/statuses/${id}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/context - * - * Get parent and child statuses. - * @param id The target status id. - * @return Context - */ - public async getStatusContext( - id: string, - options?: { limit?: number; max_id?: string; since_id?: string } - ): Promise> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get(`/api/v1/statuses/${id}/context`, params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.context(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/source - * - * Obtain the source properties for a status so that it can be edited. - * @param id The target status id. - * @return StatusSource - */ - public async getStatusSource(id: string): Promise> { - return this.client.get(`/api/v1/statuses/${id}/source`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status_source(res.data) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/reblogged_by - * - * @param id The target status id. - * @return Array of accounts. - */ - public async getStatusRebloggedBy(id: string): Promise>> { - return this.client.get>(`/api/v1/statuses/${id}/reblogged_by`).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - /** - * GET /api/v1/statuses/:id/favourited_by - * - * @param id The target status id. - * @return Array of accounts. - */ - public async getStatusFavouritedBy(id: string): Promise>> { - return this.client.get>(`/api/v1/statuses/${id}/favourited_by`).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/favourite - * - * @param id The target status id. - * @return Status. - */ - public async favouriteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/favourite`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unfavourite - * - * @param id The target status id. - * @return Status. - */ - public async unfavouriteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unfavourite`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/reblog - * - * @param id The target status id. - * @return Status. - */ - public async reblogStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/reblog`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unreblog - * - * @param id The target status id. - * @return Status. - */ - public async unreblogStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unreblog`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/bookmark - * - * @param id The target status id. - * @return Status. - */ - public async bookmarkStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/bookmark`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unbookmark - * - * @param id The target status id. - * @return Status. - */ - public async unbookmarkStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unbookmark`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/mute - * - * @param id The target status id. - * @return Status - */ - public async muteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/mute`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unmute - * - * @param id The target status id. - * @return Status - */ - public async unmuteStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unmute`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/pin - * @param id The target status id. - * @return Status - */ - public async pinStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/pin`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * POST /api/v1/statuses/:id/unpin - * - * @param id The target status id. - * @return Status - */ - public async unpinStatus(id: string): Promise> { - return this.client.post(`/api/v1/statuses/${id}/unpin`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - // ====================================== - // statuses/media - // ====================================== - /** - * POST /api/v2/media - * - * @param file The file to be attached, using multipart form data. - * @param options.description A plain-text description of the media. - * @param options.focus Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. - * @return Attachment - */ - public async uploadMedia( - file: any, - options?: { description?: string; focus?: string } - ): Promise> { - const formData = new FormData() - formData.append('file', file) - if (options) { - if (options.description) { - formData.append('description', options.description) - } - if (options.focus) { - formData.append('focus', options.focus) - } - } - return this.client.postForm('/api/v2/media', formData).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.async_attachment(res.data) - }) - }) - } - - /** - * GET /api/v1/media/:id - * - * @param id Target media ID. - * @return Attachment - */ - public async getMedia(id: string): Promise> { - const res = await this.client.get(`/api/v1/media/${id}`) - - return Object.assign(res, { - data: PleromaAPI.Converter.attachment(res.data) - }) - } - - /** - * PUT /api/v1/media/:id - * - * @param id Target media ID. - * @param options.file The file to be attached, using multipart form data. - * @param options.description A plain-text description of the media. - * @param options.focus Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. - * @param options.is_sensitive Whether the media is sensitive. - * @return Attachment - */ - public async updateMedia( - id: string, - options?: { - file?: any - description?: string - focus?: string - } - ): Promise> { - const formData = new FormData() - if (options) { - if (options.file) { - formData.append('file', options.file) - } - if (options.description) { - formData.append('description', options.description) - } - if (options.focus) { - formData.append('focus', options.focus) - } - } - return this.client.putForm(`/api/v1/media/${id}`, formData).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.attachment(res.data) - }) - }) - } - - // ====================================== - // statuses/polls - // ====================================== - /** - * GET /api/v1/polls/:id - * - * @param id Target poll ID. - * @return Poll - */ - public async getPoll(id: string): Promise> { - return this.client.get(`/api/v1/polls/${id}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.poll(res.data) - }) - }) - } - - /** - * POST /api/v1/polls/:id/votes - * - * @param id Target poll ID. - * @param choices Array of own votes containing index for each option (starting from 0). - * @return Poll - */ - public async votePoll(id: string, choices: Array): Promise> { - return this.client - .post(`/api/v1/polls/${id}/votes`, { - choices: choices - }) - .then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.poll(res.data) - }) - }) - } - - // ====================================== - // statuses/scheduled_statuses - // ====================================== - /** - * GET /api/v1/scheduled_statuses - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of scheduled statuses. - */ - public async getScheduledStatuses(options?: { - limit?: number | null - max_id?: string | null - since_id?: string | null - min_id?: string | null - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - } - return this.client.get>('/api/v1/scheduled_statuses', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.scheduled_status(s)) - }) - }) - } - - /** - * GET /api/v1/scheduled_statuses/:id - * - * @param id Target status ID. - * @return ScheduledStatus. - */ - public async getScheduledStatus(id: string): Promise> { - return this.client.get(`/api/v1/scheduled_statuses/${id}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.scheduled_status(res.data) - }) - }) - } - - /** - * PUT /api/v1/scheduled_statuses/:id - * - * @param id Target scheduled status ID. - * @param scheduled_at ISO 8601 Datetime at which the status will be published. - * @return ScheduledStatus. - */ - public async scheduleStatus(id: string, scheduled_at?: string | null): Promise> { - let params = {} - if (scheduled_at) { - params = Object.assign(params, { - scheduled_at: scheduled_at - }) - } - return this.client.put(`/api/v1/scheduled_statuses/${id}`, params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.scheduled_status(res.data) - }) - }) - } - - /** - * DELETE /api/v1/scheduled_statuses/:id - * - * @param id Target scheduled status ID. - */ - public cancelScheduledStatus(id: string): Promise> { - return this.client.del<{}>(`/api/v1/scheduled_statuses/${id}`) - } - - // ====================================== - // timelines - // ====================================== - /** - * GET /api/v1/timelines/public - * - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getPublicTimeline(options?: { - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = { - local: false - } - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/public', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/public - * - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getLocalTimeline(options?: { - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = { - local: true - } - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/public', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/tag/:hashtag - * - * @param hashtag Content of a #hashtag, not including # symbol. - * @param options.local Show only local statuses? Defaults to false. - * @param options.only_media Show only statuses with media attached? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getTagTimeline( - hashtag: string, - options?: { - local?: boolean - only_media?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - if (options.only_media !== undefined) { - params = Object.assign(params, { - only_media: options.only_media - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>(`/api/v1/timelines/tag/${hashtag}`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/home - * - * @param options.local Show only local statuses? Defaults to false. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getHomeTimeline(options?: { - local?: boolean - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/timelines/home', params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - /** - * GET /api/v1/timelines/list/:list_id - * - * @param list_id Local ID of the list in the database. - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getListTimeline( - list_id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>(`/api/v1/timelines/list/${list_id}`, params).then(res => { - return Object.assign(res, { - data: res.data.map(s => PleromaAPI.Converter.status(s)) - }) - }) - } - - // ====================================== - // timelines/conversations - // ====================================== - /** - * GET /api/v1/conversations - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of statuses. - */ - public async getConversationTimeline(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - } - return this.client.get>('/api/v1/conversations', params).then(res => { - return Object.assign(res, { - data: res.data.map(c => PleromaAPI.Converter.conversation(c)) - }) - }) - } - - /** - * DELETE /api/v1/conversations/:id - * - * @param id Target conversation ID. - */ - public deleteConversation(id: string): Promise> { - return this.client.del<{}>(`/api/v1/conversations/${id}`) - } - - /** - * POST /api/v1/conversations/:id/read - * - * @param id Target conversation ID. - * @return Conversation. - */ - public async readConversation(id: string): Promise> { - return this.client.post(`/api/v1/conversations/${id}/read`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.conversation(res.data) - }) - }) - } - - // ====================================== - // timelines/lists - // ====================================== - /** - * GET /api/v1/lists - * - * @return Array of lists. - */ - public async getLists(): Promise>> { - return this.client.get>('/api/v1/lists').then(res => { - return Object.assign(res, { - data: res.data.map(l => PleromaAPI.Converter.list(l)) - }) - }) - } - - /** - * GET /api/v1/lists/:id - * - * @param id Target list ID. - * @return List. - */ - public async getList(id: string): Promise> { - return this.client.get(`/api/v1/lists/${id}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.list(res.data) - }) - }) - } - - /** - * POST /api/v1/lists - * - * @param title List name. - * @return List. - */ - public async createList(title: string): Promise> { - return this.client - .post('/api/v1/lists', { - title: title - }) - .then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.list(res.data) - }) - }) - } - - /** - * PUT /api/v1/lists/:id - * - * @param id Target list ID. - * @param title New list name. - * @return List. - */ - public async updateList(id: string, title: string): Promise> { - return this.client - .put(`/api/v1/lists/${id}`, { - title: title - }) - .then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.list(res.data) - }) - }) - } - - /** - * DELETE /api/v1/lists/:id - * - * @param id Target list ID. - */ - public deleteList(id: string): Promise> { - return this.client.del<{}>(`/api/v1/lists/${id}`) - } - - /** - * GET /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param options.limit Max number of results to return. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @return Array of accounts. - */ - public async getAccountsInList( - id: string, - options?: { - limit?: number - max_id?: string - since_id?: string - } - ): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - } - return this.client.get>(`/api/v1/lists/${id}/accounts`, params).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - /** - * POST /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param account_ids Array of account IDs to add to the list. - */ - public addAccountsToList(id: string, account_ids: Array): Promise> { - return this.client.post<{}>(`/api/v1/lists/${id}/accounts`, { - account_ids: account_ids - }) - } - - /** - * DELETE /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param account_ids Array of account IDs to add to the list. - */ - public deleteAccountsFromList(id: string, account_ids: Array): Promise> { - return this.client.del<{}>(`/api/v1/lists/${id}/accounts`, { - account_ids: account_ids - }) - } - - // ====================================== - // timelines/markers - // ====================================== - /** - * GET /api/v1/markers - * - * @param timelines Array of timeline names, String enum anyOf home, notifications. - * @return Marker or empty object. - */ - public async getMarkers(timeline: Array): Promise>> { - return this.client - .get>('/api/v1/markers', { - timeline: timeline - }) - .then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.marker(res.data) - }) - }) - } - - /** - * POST /api/v1/markers - * - * @param options.home Marker position of the last read status ID in home timeline. - * @param options.notifications Marker position of the last read notification ID in notifications. - * @return Marker. - */ - public async saveMarkers(options?: { - home?: { last_read_id: string } - notifications?: { last_read_id: string } - }): Promise> { - let params = {} - if (options) { - if (options.home) { - params = Object.assign(params, { - home: options.home - }) - } - if (options.notifications) { - params = Object.assign(params, { - notifications: options.notifications - }) - } - } - return this.client.post('/api/v1/markers', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.marker(res.data) - }) - }) - } - - // ====================================== - // notifications - // ====================================== - /** - * GET /api/v1/notifications - * - * @param options.limit Max number of results to return. Defaults to 20. - * @param options.max_id Return results older than ID. - * @param options.since_id Return results newer than ID. - * @param options.min_id Return results immediately newer than ID. - * @param options.exclude_types Array of types to exclude. - * @param options.account_id Return only notifications received from this account. - * @return Array of notifications. - */ - public async getNotifications(options?: { - limit?: number - max_id?: string - since_id?: string - min_id?: string - exclude_types?: Array - account_id?: string - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.since_id) { - params = Object.assign(params, { - since_id: options.since_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.exclude_types) { - params = Object.assign(params, { - exclude_types: options.exclude_types.map(e => PleromaAPI.Converter.encodeNotificationType(e)) - }) - } - if (options.account_id) { - params = Object.assign(params, { - account_id: options.account_id - }) - } - } - return this.client.get>('/api/v1/notifications', params).then(res => { - return Object.assign(res, { - data: res.data.flatMap(n => { - const notify = PleromaAPI.Converter.notification(n) - if (notify instanceof UnknownNotificationTypeError) return [] - return notify - }) - }) - }) - } - - /** - * GET /api/v1/notifications/:id - * - * @param id Target notification ID. - * @return Notification. - */ - public async getNotification(id: string): Promise> { - const res = await this.client.get(`/api/v1/notifications/${id}`) - const notify = PleromaAPI.Converter.notification(res.data) - if (notify instanceof UnknownNotificationTypeError) { - throw new UnknownNotificationTypeError() - } - return { ...res, data: notify } - } - - /** - * POST /api/v1/notifications/clear - */ - public dismissNotifications(): Promise> { - return this.client.post<{}>('/api/v1/notifications/clear') - } - - /** - * POST /api/v1/notifications/:id/dismiss - * - * @param id Target notification ID. - */ - public dismissNotification(id: string): Promise> { - return this.client.post<{}>(`/api/v1/notifications/${id}/dismiss`) - } - - /** - * POST /api/v1/pleroma/notifcations/read - * - * @param id A single notification ID to read - * @param max_id Read all notifications up to this ID - * @return Array of notifications - */ - public async readNotifications(options: { - id?: string - max_id?: string - }): Promise>> { - if (options.id) { - const res = await this.client.post('/api/v1/pleroma/notifications/read', { - id: options.id - }) - const notify = PleromaAPI.Converter.notification(res.data) - if (notify instanceof UnknownNotificationTypeError) return { ...res, data: [] } - return { ...res, data: notify } - } else if (options.max_id) { - const res = await this.client.post>('/api/v1/pleroma/notifications/read', { - max_id: options.max_id - }) - return { - ...res, - data: res.data.flatMap(n => { - const notify = PleromaAPI.Converter.notification(n) - if (notify instanceof UnknownNotificationTypeError) return [] - return notify - }) - } - } else { - return new Promise((_, reject) => { - const err = new ArgumentError('id or max_id is required') - reject(err) - }) - } - } - - // ====================================== - // notifications/push - // ====================================== - /** - * POST /api/v1/push/subscription - * - * @param subscription[endpoint] Endpoint URL that is called when a notification event occurs. - * @param subscription[keys][p256dh] User agent public key. Base64 encoded string of public key of ECDH key using prime256v1 curve. - * @param subscription[keys] Auth secret. Base64 encoded string of 16 bytes of random data. - * @param data[alerts][follow] Receive follow notifications? - * @param data[alerts][favourite] Receive favourite notifications? - * @param data[alerts][reblog] Receive reblog notifictaions? - * @param data[alerts][mention] Receive mention notifications? - * @param data[alerts][poll] Receive poll notifications? - * @return PushSubscription. - */ - 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> { - let params = { - subscription - } - if (data) { - params = Object.assign(params, { - data - }) - } - return this.client.post('/api/v1/push/subscription', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * GET /api/v1/push/subscription - * - * @return PushSubscription. - */ - public async getPushSubscription(): Promise> { - return this.client.get('/api/v1/push/subscription').then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * PUT /api/v1/push/subscription - * - * @param data[alerts][follow] Receive follow notifications? - * @param data[alerts][favourite] Receive favourite notifications? - * @param data[alerts][reblog] Receive reblog notifictaions? - * @param data[alerts][mention] Receive mention notifications? - * @param data[alerts][poll] Receive poll notifications? - * @return PushSubscription. - */ - public async updatePushSubscription( - data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean } } | null - ): Promise> { - let params = {} - if (data) { - params = Object.assign(params, { - data - }) - } - return this.client.put('/api/v1/push/subscription', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.push_subscription(res.data) - }) - }) - } - - /** - * DELETE /api/v1/push/subscription - */ - public deletePushSubscription(): Promise> { - return this.client.del<{}>('/api/v1/push/subscription') - } - - // ====================================== - // search - // ====================================== - /** - * GET /api/v2/search - * - * @param q The search query. - * @param options.type Enum of search target. - * @param options.limit Maximum number of results to load, per type. Defaults to 20. Max 40. - * @param options.max_id Return results older than this id. - * @param options.min_id Return results immediately newer than this id. - * @param options.resolve Attempt WebFinger lookup. Defaults to false. - * @param options.following Only include accounts that the user is following. Defaults to false. - * @param options.account_id If provided, statuses returned will be authored only by this account. - * @param options.exclude_unreviewed Filter out unreviewed tags? Defaults to false. - * @return Results. - */ - public async search( - q: string, - options?: { - type?: 'accounts' | 'hashtags' | 'statuses' - limit?: number - max_id?: string - min_id?: string - resolve?: boolean - offset?: number - following?: boolean - account_id?: string - exclude_unreviewed?: boolean - } - ): Promise> { - let params = { - q - } - if (options) { - if (options.type) { - params = Object.assign(params, { - type: options.type - }) - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.max_id) { - params = Object.assign(params, { - max_id: options.max_id - }) - } - if (options.min_id) { - params = Object.assign(params, { - min_id: options.min_id - }) - } - if (options.resolve !== undefined) { - params = Object.assign(params, { - resolve: options.resolve - }) - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset - }) - } - if (options.following !== undefined) { - params = Object.assign(params, { - following: options.following - }) - } - if (options.account_id) { - params = Object.assign(params, { - account_id: options.account_id - }) - } - if (options.exclude_unreviewed) { - params = Object.assign(params, { - exclude_unreviewed: options.exclude_unreviewed - }) - } - } - return this.client.get('/api/v2/search', params).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.results(res.data) - }) - }) - } - - // ====================================== - // instance - // ====================================== - /** - * GET /api/v1/instance - */ - public async getInstance(): Promise> { - return this.client.get('/api/v1/instance').then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.instance(res.data) - }) - }) - } - - /** - * GET /api/v1/instance/peers - */ - public getInstancePeers(): Promise>> { - return this.client.get>('/api/v1/instance/peers') - } - - /** - * GET /api/v1/instance/activity - */ - public async getInstanceActivity(): Promise>> { - return this.client.get>('/api/v1/instance/activity').then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.activity(a)) - }) - }) - } - - // ====================================== - // instance/trends - // ====================================== - /** - * GET /api/v1/trends - * - * @param limit Maximum number of results to return. Defaults to 10. - */ - public async getInstanceTrends(limit?: number | null): Promise>> { - let params = {} - if (limit) { - params = Object.assign(params, { - limit - }) - } - return this.client.get>('/api/v1/trends', params).then(res => { - return Object.assign(res, { - data: res.data.map(t => PleromaAPI.Converter.tag(t)) - }) - }) - } - - // ====================================== - // instance/directory - // ====================================== - /** - * GET /api/v1/directory - * - * @param options.limit How many accounts to load. Default 40. - * @param options.offset How many accounts to skip before returning results. Default 0. - * @param options.order Order of results. - * @param options.local Only return local accounts. - * @return Array of accounts. - */ - public async getInstanceDirectory(options?: { - limit?: number - offset?: number - order?: 'active' | 'new' - local?: boolean - }): Promise>> { - let params = {} - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit - }) - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset - }) - } - if (options.order) { - params = Object.assign(params, { - order: options.order - }) - } - if (options.local !== undefined) { - params = Object.assign(params, { - local: options.local - }) - } - } - return this.client.get>('/api/v1/directory', params).then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.account(a)) - }) - }) - } - - // ====================================== - // instance/custom_emojis - // ====================================== - /** - * GET /api/v1/custom_emojis - * - * @return Array of emojis. - */ - public async getInstanceCustomEmojis(): Promise>> { - return this.client.get>('/api/v1/custom_emojis').then(res => { - return Object.assign(res, { - data: res.data.map(e => PleromaAPI.Converter.emoji(e)) - }) - }) - } - - // ====================================== - // instance/announcements - // ====================================== - /** - * GET /api/v1/announcements - * - * @return Array of announcements. - */ - public async getInstanceAnnouncements(): Promise>> { - return this.client.get>('/api/v1/announcements').then(res => { - return Object.assign(res, { - data: res.data.map(a => PleromaAPI.Converter.announcement(a)) - }) - }) - } - - /** - * POST /api/v1/announcements/:id/dismiss - * - * @param id The ID of the Announcement in the database. - */ - public async dismissInstanceAnnouncement(id: string): Promise>> { - return this.client.post>(`/api/v1/announcements/${id}/dismiss`) - } - - /** - * PUT /api/v1/announcements/:id/reactions/:name - * - * @param id The ID of the Announcement in the database. - * @param name Unicode emoji, or the shortcode of a custom emoji. - */ - public async addReactionToAnnouncement(_id: string, _name: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('pleroma does not support') - reject(err) - }) - } - - /** - * DELETE /api/v1/announcements/:id/reactions/:name - * - * @param id The ID of the Announcement in the database. - * @param name Unicode emoji, or the shortcode of a custom emoji. - */ - public async removeReactionFromAnnouncement(_id: string, _name: string): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError('pleroma does not support') - reject(err) - }) - } - - // ====================================== - // Emoji reactions - // ====================================== - /** - * PUT /api/v1/pleroma/statuses/:status_id/reactions/:emoji - * - * @param {string} id Target status ID. - * @param {string} emoji Reaction emoji string. This string is raw unicode emoji. - */ - public async createEmojiReaction(id: string, emoji: string): Promise> { - return this.client.put(`/api/v1/pleroma/statuses/${id}/reactions/${encodeURI(emoji)}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * DELETE /api/v1/pleroma/statuses/:status_id/reactions/:emoji - * - * @param {string} id Target status ID. - * @param {string} emoji Reaction emoji string. This string is raw unicode emoji. - */ - public async deleteEmojiReaction(id: string, emoji: string): Promise> { - return this.client.del(`/api/v1/pleroma/statuses/${id}/reactions/${encodeURI(emoji)}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.status(res.data) - }) - }) - } - - /** - * GET /api/v1/pleroma/statuses/:status_id/reactions - * - * @param {string} id Target status ID. - */ - public async getEmojiReactions(id: string): Promise>> { - return this.client.get>(`/api/v1/pleroma/statuses/${id}/reactions`).then(res => { - return Object.assign(res, { - data: res.data.map(r => PleromaAPI.Converter.reaction(r)) - }) - }) - } - - /** - * GET /api/v1/pleroma/statuses/:status_id/reactions/:emoji - * - * @param {string} id Target status ID. - * @param {string} emoji Reaction emoji string. This string is url encoded unicode emoji. - */ - public async getEmojiReaction(id: string, emoji: string): Promise> { - return this.client.get(`/api/v1/pleroma/statuses/${id}/reactions/${encodeURI(emoji)}`).then(res => { - return Object.assign(res, { - data: PleromaAPI.Converter.reaction(res.data) - }) - }) - } - - // ====================================== - // WebSocket - // ====================================== - public userSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'user') - } - - public publicSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'public') - } - - public localSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'public:local') - } - - public tagSocket(tag: string): WebSocket { - return this.client.socket('/api/v1/streaming', 'hashtag', `tag=${tag}`) - } - - public listSocket(list_id: string): WebSocket { - return this.client.socket('/api/v1/streaming', 'list', `list=${list_id}`) - } - - public directSocket(): WebSocket { - return this.client.socket('/api/v1/streaming', 'direct') - } -} diff --git a/packages/megalodon/src/pleroma/api_client.ts b/packages/megalodon/src/pleroma/api_client.ts deleted file mode 100644 index c20350b67c..0000000000 --- a/packages/megalodon/src/pleroma/api_client.ts +++ /dev/null @@ -1,824 +0,0 @@ -import axios, { AxiosResponse, AxiosRequestConfig } from 'axios' -import objectAssignDeep from 'object-assign-deep' - -import MegalodonEntity from '../entity' -import PleromaEntity from './entity' -import Response from '../response' -import { RequestCanceledError } from '../cancel' -import proxyAgent, { ProxyConfig } from '../proxy_config' -import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from '../default' -import WebSocket from './web_socket' -import NotificationType, { UnknownNotificationTypeError } from '../notification' -import PleromaNotificationType from './notification' - -namespace PleromaAPI { - export namespace Entity { - export type Account = PleromaEntity.Account - export type Activity = PleromaEntity.Activity - export type Announcement = PleromaEntity.Announcement - export type Application = PleromaEntity.Application - export type AsyncAttachment = PleromaEntity.AsyncAttachment - export type Attachment = PleromaEntity.Attachment - export type Card = PleromaEntity.Card - export type Context = PleromaEntity.Context - export type Conversation = PleromaEntity.Conversation - export type Emoji = PleromaEntity.Emoji - export type FeaturedTag = PleromaEntity.FeaturedTag - export type Field = PleromaEntity.Field - export type Filter = PleromaEntity.Filter - export type History = PleromaEntity.History - export type IdentityProof = PleromaEntity.IdentityProof - export type Instance = PleromaEntity.Instance - export type List = PleromaEntity.List - export type Marker = PleromaEntity.Marker - export type Mention = PleromaEntity.Mention - export type Notification = PleromaEntity.Notification - export type Poll = PleromaEntity.Poll - export type PollOption = PleromaEntity.PollOption - export type Preferences = PleromaEntity.Preferences - export type PushSubscription = PleromaEntity.PushSubscription - export type Reaction = PleromaEntity.Reaction - export type Relationship = PleromaEntity.Relationship - export type Report = PleromaEntity.Report - export type Results = PleromaEntity.Results - export type ScheduledStatus = PleromaEntity.ScheduledStatus - export type Source = PleromaEntity.Source - export type Stats = PleromaEntity.Stats - export type Status = PleromaEntity.Status - export type StatusParams = PleromaEntity.StatusParams - export type StatusSource = PleromaEntity.StatusSource - export type Tag = PleromaEntity.Tag - export type Token = PleromaEntity.Token - export type URLs = PleromaEntity.URLs - } - - export namespace Converter { - export const decodeNotificationType = ( - t: PleromaEntity.NotificationType - ): MegalodonEntity.NotificationType | UnknownNotificationTypeError => { - switch (t) { - case PleromaNotificationType.Mention: - return NotificationType.Mention - case PleromaNotificationType.Reblog: - return NotificationType.Reblog - case PleromaNotificationType.Favourite: - return NotificationType.Favourite - case PleromaNotificationType.Follow: - return NotificationType.Follow - case PleromaNotificationType.Poll: - return NotificationType.PollExpired - case PleromaNotificationType.PleromaEmojiReaction: - return NotificationType.EmojiReaction - case PleromaNotificationType.FollowRequest: - return NotificationType.FollowRequest - case PleromaNotificationType.Update: - return NotificationType.Update - case PleromaNotificationType.Move: - return NotificationType.Move - default: - return new UnknownNotificationTypeError() - } - } - export const encodeNotificationType = ( - t: MegalodonEntity.NotificationType - ): PleromaEntity.NotificationType | UnknownNotificationTypeError => { - switch (t) { - case NotificationType.Follow: - return PleromaNotificationType.Follow - case NotificationType.Favourite: - return PleromaNotificationType.Favourite - case NotificationType.Reblog: - return PleromaNotificationType.Reblog - case NotificationType.Mention: - return PleromaNotificationType.Mention - case NotificationType.PollExpired: - return PleromaNotificationType.Poll - case NotificationType.EmojiReaction: - return PleromaNotificationType.PleromaEmojiReaction - case NotificationType.FollowRequest: - return PleromaNotificationType.FollowRequest - case NotificationType.Update: - return PleromaNotificationType.Update - case NotificationType.Move: - return PleromaNotificationType.Move - default: - return new UnknownNotificationTypeError() - } - } - - export const account = (a: Entity.Account): MegalodonEntity.Account => { - return { - id: a.id, - username: a.username, - acct: a.acct, - display_name: a.display_name, - locked: a.locked, - discoverable: a.discoverable, - group: null, - noindex: a.noindex, - suspended: a.suspended, - limited: a.limited, - created_at: a.created_at, - followers_count: a.followers_count, - following_count: a.following_count, - statuses_count: a.statuses_count, - note: a.note, - url: a.url, - avatar: a.avatar, - avatar_static: a.avatar_static, - header: a.header, - header_static: a.header_static, - emojis: a.emojis.map(e => emoji(e)), - moved: a.moved ? account(a.moved) : null, - fields: a.fields, - bot: a.bot, - source: a.source - } - } - export const activity = (a: Entity.Activity): MegalodonEntity.Activity => a - export const announcement = (a: Entity.Announcement): MegalodonEntity.Announcement => ({ - id: a.id, - content: a.content, - starts_at: a.starts_at, - ends_at: a.ends_at, - published: a.published, - all_day: a.all_day, - published_at: a.published_at, - updated_at: a.updated_at, - read: null, - mentions: a.mentions, - statuses: a.statuses, - tags: a.tags, - emojis: a.emojis, - reactions: a.reactions - }) - export const application = (a: Entity.Application): MegalodonEntity.Application => a - export const attachment = (a: Entity.Attachment): MegalodonEntity.Attachment => a - export const async_attachment = (a: Entity.AsyncAttachment) => { - if (a.url) { - return { - id: a.id, - type: a.type, - url: a.url!, - remote_url: a.remote_url, - preview_url: a.preview_url, - text_url: a.text_url, - meta: a.meta, - description: a.description, - blurhash: a.blurhash - } as MegalodonEntity.Attachment - } else { - return a as MegalodonEntity.AsyncAttachment - } - } - export const card = (c: Entity.Card): MegalodonEntity.Card => ({ - url: c.url, - title: c.title, - description: c.description, - type: c.type, - image: c.image, - author_name: null, - author_url: null, - provider_name: c.provider_name, - provider_url: c.provider_url, - html: null, - width: null, - height: null, - embed_url: null, - blurhash: null - }) - export const context = (c: Entity.Context): MegalodonEntity.Context => ({ - ancestors: Array.isArray(c.ancestors) ? c.ancestors.map(a => status(a)) : [], - descendants: Array.isArray(c.descendants) ? c.descendants.map(d => status(d)) : [] - }) - export const conversation = (c: Entity.Conversation): MegalodonEntity.Conversation => ({ - id: c.id, - accounts: Array.isArray(c.accounts) ? c.accounts.map(a => account(a)) : [], - last_status: c.last_status ? status(c.last_status) : null, - unread: c.unread - }) - export const emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => ({ - shortcode: e.shortcode, - static_url: e.static_url, - url: e.url, - visible_in_picker: e.visible_in_picker - }) - export const featured_tag = (f: Entity.FeaturedTag): MegalodonEntity.FeaturedTag => f - export const field = (f: Entity.Field): MegalodonEntity.Field => f - export const filter = (f: Entity.Filter): MegalodonEntity.Filter => f - export const history = (h: Entity.History): MegalodonEntity.History => h - export const identity_proof = (i: Entity.IdentityProof): MegalodonEntity.IdentityProof => i - export const instance = (i: Entity.Instance): MegalodonEntity.Instance => ({ - uri: i.uri, - title: i.title, - description: i.description, - email: i.email, - version: i.version, - thumbnail: i.thumbnail, - urls: urls(i.urls), - stats: stats(i.stats), - languages: i.languages, - registrations: i.registrations, - approval_required: i.approval_required, - configuration: { - statuses: { - max_characters: i.max_toot_chars, - max_media_attachments: i.max_media_attachments - }, - polls: { - max_options: i.poll_limits.max_options, - max_characters_per_option: i.poll_limits.max_option_chars, - min_expiration: i.poll_limits.min_expiration, - max_expiration: i.poll_limits.max_expiration - } - } - }) - export const list = (l: Entity.List): MegalodonEntity.List => ({ - id: l.id, - title: l.title, - replies_policy: null - }) - export const marker = (m: Entity.Marker | Record): MegalodonEntity.Marker | Record => { - if ((m as any).notifications) { - const mm = m as Entity.Marker - return { - notifications: { - last_read_id: mm.notifications.last_read_id, - version: mm.notifications.version, - updated_at: mm.notifications.updated_at, - unread_count: mm.notifications.pleroma.unread_count - } - } - } else { - return {} - } - } - export const mention = (m: Entity.Mention): MegalodonEntity.Mention => m - export const notification = (n: Entity.Notification): MegalodonEntity.Notification | UnknownNotificationTypeError => { - const notificationType = decodeNotificationType(n.type) - if (notificationType instanceof UnknownNotificationTypeError) return notificationType - if (n.status && n.emoji) { - return { - id: n.id, - account: account(n.account), - created_at: n.created_at, - status: status(n.status), - emoji: n.emoji, - type: notificationType - } - } else if (n.status) { - return { - id: n.id, - account: account(n.account), - created_at: n.created_at, - status: status(n.status), - type: notificationType - } - } else if (n.target) { - return { - id: n.id, - account: account(n.account), - created_at: n.created_at, - target: account(n.target), - type: notificationType - } - } else { - return { - id: n.id, - account: account(n.account), - created_at: n.created_at, - type: notificationType - } - } - } - export const poll = (p: Entity.Poll): MegalodonEntity.Poll => p - export const pollOption = (p: Entity.PollOption): MegalodonEntity.PollOption => p - export const preferences = (p: Entity.Preferences): MegalodonEntity.Preferences => p - export const push_subscription = (p: Entity.PushSubscription): MegalodonEntity.PushSubscription => p - export const reaction = (r: Entity.Reaction): MegalodonEntity.Reaction => { - const p = { - count: r.count, - me: r.me, - name: r.name - } - if (r.accounts) { - return Object.assign({}, p, { - accounts: r.accounts.map(a => account(a)) - }) - } - return p - } - export const relationship = (r: Entity.Relationship): MegalodonEntity.Relationship => ({ - id: r.id, - following: r.following, - followed_by: r.followed_by, - blocking: r.blocking, - blocked_by: r.blocked_by, - muting: r.muting, - muting_notifications: r.muting_notifications, - requested: r.requested, - domain_blocking: r.domain_blocking, - showing_reblogs: r.showing_reblogs, - endorsed: r.endorsed, - notifying: r.notifying, - note: r.note - }) - export const report = (r: Entity.Report): MegalodonEntity.Report => ({ - id: r.id, - action_taken: r.action_taken, - action_taken_at: null, - category: null, - comment: null, - forwarded: null, - status_ids: null, - rule_ids: null - }) - export const results = (r: Entity.Results): MegalodonEntity.Results => ({ - accounts: Array.isArray(r.accounts) ? r.accounts.map(a => account(a)) : [], - statuses: Array.isArray(r.statuses) ? r.statuses.map(s => status(s)) : [], - hashtags: Array.isArray(r.hashtags) ? r.hashtags.map(h => tag(h)) : [] - }) - export const scheduled_status = (s: Entity.ScheduledStatus): MegalodonEntity.ScheduledStatus => ({ - id: s.id, - scheduled_at: s.scheduled_at, - params: status_params(s.params), - media_attachments: Array.isArray(s.media_attachments) ? s.media_attachments.map(m => attachment(m)) : null - }) - export const source = (s: Entity.Source): MegalodonEntity.Source => s - export const stats = (s: Entity.Stats): MegalodonEntity.Stats => s - export const status = (s: Entity.Status): MegalodonEntity.Status => ({ - id: s.id, - uri: s.uri, - url: s.url, - account: account(s.account), - in_reply_to_id: s.in_reply_to_id, - in_reply_to_account_id: s.in_reply_to_account_id, - reblog: s.reblog ? status(s.reblog) : null, - content: s.content, - plain_content: s.pleroma.content?.['text/plain'] ? s.pleroma.content['text/plain'] : null, - created_at: s.created_at, - edited_at: s.edited_at || null, - emojis: Array.isArray(s.emojis) ? s.emojis.map(e => emoji(e)) : [], - replies_count: s.replies_count, - reblogs_count: s.reblogs_count, - favourites_count: s.favourites_count, - reblogged: s.reblogged, - favourited: s.favourited, - muted: s.muted, - sensitive: s.sensitive, - spoiler_text: s.spoiler_text, - visibility: s.visibility, - media_attachments: Array.isArray(s.media_attachments) ? s.media_attachments.map(m => attachment(m)) : [], - mentions: Array.isArray(s.mentions) ? s.mentions.map(m => mention(m)) : [], - tags: s.tags, - card: s.card ? card(s.card) : null, - poll: s.poll ? poll(s.poll) : null, - application: s.application ? application(s.application) : null, - language: s.language, - pinned: s.pinned, - emoji_reactions: Array.isArray(s.pleroma.emoji_reactions) ? s.pleroma.emoji_reactions.map(r => reaction(r)) : [], - bookmarked: s.bookmarked ? s.bookmarked : false, - quote: s.reblog !== null && s.reblog.content !== s.content - }) - export const status_params = (s: Entity.StatusParams): MegalodonEntity.StatusParams => { - return { - text: s.text, - in_reply_to_id: s.in_reply_to_id, - media_ids: Array.isArray(s.media_ids) ? s.media_ids : null, - sensitive: s.sensitive, - spoiler_text: s.spoiler_text, - visibility: s.visibility, - scheduled_at: s.scheduled_at, - application_id: null - } - } - export const status_source = (s: Entity.StatusSource): MegalodonEntity.StatusSource => s - export const tag = (t: Entity.Tag): MegalodonEntity.Tag => t - export const token = (t: Entity.Token): MegalodonEntity.Token => t - export const urls = (u: Entity.URLs): MegalodonEntity.URLs => u - } - - /** - * Interface - */ - export interface Interface { - get(path: string, params?: any, headers?: { [key: string]: string }): Promise> - put(path: string, params?: any, headers?: { [key: string]: string }): Promise> - putForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - patch(path: string, params?: any, headers?: { [key: string]: string }): Promise> - patchForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - post(path: string, params?: any, headers?: { [key: string]: string }): Promise> - postForm(path: string, params?: any, headers?: { [key: string]: string }): Promise> - del(path: string, params?: any, headers?: { [key: string]: string }): Promise> - cancel(): void - socket(path: string, stream: string, params?: string): WebSocket - } - - /** - * Mastodon API client. - * - * Using axios for request, you will handle promises. - */ - export class Client implements Interface { - static DEFAULT_SCOPE = DEFAULT_SCOPE - static DEFAULT_URL = 'https://pleroma.io' - static NO_REDIRECT = NO_REDIRECT - - private accessToken: string | null - private baseUrl: string - private userAgent: string - private abortController: AbortController - private proxyConfig: ProxyConfig | false = false - - /** - * @param baseUrl hostname or base URL - * @param accessToken access token from OAuth2 authorization - * @param userAgent UserAgent is specified in header on request. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - */ - constructor( - baseUrl: string, - accessToken: string | null = null, - userAgent: string = DEFAULT_UA, - proxyConfig: ProxyConfig | false = false - ) { - this.accessToken = accessToken - this.baseUrl = baseUrl - this.userAgent = userAgent - this.proxyConfig = proxyConfig - this.abortController = new AbortController() - axios.defaults.signal = this.abortController.signal - } - - /** - * GET request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Query parameters - * @param headers Request header object - */ - public async get(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - params: params, - headers: headers - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .get(this.baseUrl + path, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PUT request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async put(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .put(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PUT request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async putForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .putForm(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PATCH request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async patch(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .patch(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * PATCH request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data. If you want to post file, please use FormData() - * @param headers Request header object - */ - public async patchForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .patchForm(this.baseUrl + path, params, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * POST request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async post(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios.post(this.baseUrl + path, params, options).then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * POST request to mastodon REST API for multipart. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async postForm(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios.postForm(this.baseUrl + path, params, options).then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * DELETE request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async del(path: string, params = {}, headers: { [key: string]: string } = {}): Promise> { - let options: AxiosRequestConfig = { - data: params, - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity - } - if (this.accessToken) { - options = objectAssignDeep({}, options, { - headers: { - Authorization: `Bearer ${this.accessToken}` - } - }) - } - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig) - }) - } - return axios - .delete(this.baseUrl + path, options) - .catch((err: Error) => { - if (axios.isCancel(err)) { - throw new RequestCanceledError(err.message) - } else { - throw err - } - }) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers - } - return res - }) - } - - /** - * Cancel all requests in this instance. - * @returns void - */ - public cancel(): void { - return this.abortController.abort() - } - - /** - * Get connection and receive websocket connection for Pleroma API. - * - * @param path relative path from baseUrl: normally it is `/streaming`. - * @param stream Stream name, please refer: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/mastodon_api/mastodon_socket.ex#L19-28 - * @returns WebSocket, which inherits from EventEmitter - */ - public socket(path: string, stream: string, params?: string): WebSocket { - if (!this.accessToken) { - throw new Error('accessToken is required') - } - const url = this.baseUrl + path - const streaming = new WebSocket(url, stream, params, this.accessToken, this.userAgent, this.proxyConfig) - process.nextTick(() => { - streaming.start() - }) - return streaming - } - } -} - -export default PleromaAPI diff --git a/packages/megalodon/src/pleroma/entities/account.ts b/packages/megalodon/src/pleroma/entities/account.ts deleted file mode 100644 index 29d42643fc..0000000000 --- a/packages/megalodon/src/pleroma/entities/account.ts +++ /dev/null @@ -1,31 +0,0 @@ -/// -/// -/// -namespace PleromaEntity { - export type Account = { - id: string - username: string - acct: string - display_name: string - locked: boolean - discoverable?: boolean - noindex: boolean | null - suspended: boolean | null - limited: boolean | null - created_at: string - followers_count: number - following_count: number - statuses_count: number - note: string - url: string - avatar: string - avatar_static: string - header: string - header_static: string - emojis: Array - moved: Account | null - fields: Array - bot: boolean - source?: Source - } -} diff --git a/packages/megalodon/src/pleroma/entities/activity.ts b/packages/megalodon/src/pleroma/entities/activity.ts deleted file mode 100644 index f70ad168eb..0000000000 --- a/packages/megalodon/src/pleroma/entities/activity.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace PleromaEntity { - export type Activity = { - week: string - statuses: string - logins: string - registrations: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/announcement.ts b/packages/megalodon/src/pleroma/entities/announcement.ts deleted file mode 100644 index 247ad90c5b..0000000000 --- a/packages/megalodon/src/pleroma/entities/announcement.ts +++ /dev/null @@ -1,39 +0,0 @@ -/// - -namespace PleromaEntity { - export type Announcement = { - id: string - content: string - starts_at: string | null - ends_at: string | null - published: boolean - all_day: boolean - published_at: string - updated_at: string - mentions: Array - statuses: Array - tags: Array - emojis: Array - reactions: Array - } - - export type AnnouncementAccount = { - id: string - username: string - url: string - acct: string - } - - export type AnnouncementStatus = { - id: string - url: string - } - - export type AnnouncementReaction = { - name: string - count: number - me: boolean | null - url: string | null - static_url: string | null - } -} diff --git a/packages/megalodon/src/pleroma/entities/application.ts b/packages/megalodon/src/pleroma/entities/application.ts deleted file mode 100644 index 055592d6ce..0000000000 --- a/packages/megalodon/src/pleroma/entities/application.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace PleromaEntity { - export type Application = { - name: string - website?: string | null - vapid_key?: string | null - } -} diff --git a/packages/megalodon/src/pleroma/entities/async_attachment.ts b/packages/megalodon/src/pleroma/entities/async_attachment.ts deleted file mode 100644 index 8784979cbb..0000000000 --- a/packages/megalodon/src/pleroma/entities/async_attachment.ts +++ /dev/null @@ -1,14 +0,0 @@ -/// -namespace PleromaEntity { - export type AsyncAttachment = { - id: string - type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' - url: string | null - remote_url: string | null - preview_url: string - text_url: string | null - meta: Meta | null - description: string | null - blurhash: string | null - } -} diff --git a/packages/megalodon/src/pleroma/entities/attachment.ts b/packages/megalodon/src/pleroma/entities/attachment.ts deleted file mode 100644 index 18d4371daf..0000000000 --- a/packages/megalodon/src/pleroma/entities/attachment.ts +++ /dev/null @@ -1,49 +0,0 @@ -namespace PleromaEntity { - export type Sub = { - // For Image, Gifv, and Video - width?: number - height?: number - size?: string - aspect?: number - - // For Gifv and Video - frame_rate?: string - - // For Audio, Gifv, and Video - duration?: number - bitrate?: number - } - - export type Focus = { - x: number - y: number - } - - export type Meta = { - original?: Sub - small?: Sub - focus?: Focus - length?: string - duration?: number - fps?: number - size?: string - width?: number - height?: number - aspect?: number - audio_encode?: string - audio_bitrate?: string - audio_channel?: string - } - - export type Attachment = { - id: string - type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' - url: string - remote_url: string | null - preview_url: string | null - text_url: string | null - meta: Meta | null - description: string | null - blurhash: string | null - } -} diff --git a/packages/megalodon/src/pleroma/entities/card.ts b/packages/megalodon/src/pleroma/entities/card.ts deleted file mode 100644 index 9aca99a8c8..0000000000 --- a/packages/megalodon/src/pleroma/entities/card.ts +++ /dev/null @@ -1,11 +0,0 @@ -namespace PleromaEntity { - export type Card = { - url: string - title: string - description: string - type: 'link' | 'photo' | 'video' | 'rich' - image: string | null - provider_name: string - provider_url: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/context.ts b/packages/megalodon/src/pleroma/entities/context.ts deleted file mode 100644 index f297bd2c17..0000000000 --- a/packages/megalodon/src/pleroma/entities/context.ts +++ /dev/null @@ -1,8 +0,0 @@ -/// - -namespace PleromaEntity { - export type Context = { - ancestors: Array - descendants: Array - } -} diff --git a/packages/megalodon/src/pleroma/entities/conversation.ts b/packages/megalodon/src/pleroma/entities/conversation.ts deleted file mode 100644 index 624e6da389..0000000000 --- a/packages/megalodon/src/pleroma/entities/conversation.ts +++ /dev/null @@ -1,11 +0,0 @@ -/// -/// - -namespace PleromaEntity { - export type Conversation = { - id: string - accounts: Array - last_status: Status | null - unread: boolean - } -} diff --git a/packages/megalodon/src/pleroma/entities/emoji.ts b/packages/megalodon/src/pleroma/entities/emoji.ts deleted file mode 100644 index 43ea22d770..0000000000 --- a/packages/megalodon/src/pleroma/entities/emoji.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace PleromaEntity { - export type Emoji = { - shortcode: string - static_url: string - url: string - visible_in_picker: boolean - } -} diff --git a/packages/megalodon/src/pleroma/entities/featured_tag.ts b/packages/megalodon/src/pleroma/entities/featured_tag.ts deleted file mode 100644 index a42e27f9d0..0000000000 --- a/packages/megalodon/src/pleroma/entities/featured_tag.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace PleromaEntity { - export type FeaturedTag = { - id: string - name: string - statuses_count: number - last_status_at: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/field.ts b/packages/megalodon/src/pleroma/entities/field.ts deleted file mode 100644 index 01803078a9..0000000000 --- a/packages/megalodon/src/pleroma/entities/field.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace PleromaEntity { - export type Field = { - name: string - value: string - verified_at: string | null - } -} diff --git a/packages/megalodon/src/pleroma/entities/filter.ts b/packages/megalodon/src/pleroma/entities/filter.ts deleted file mode 100644 index 08a18089c2..0000000000 --- a/packages/megalodon/src/pleroma/entities/filter.ts +++ /dev/null @@ -1,12 +0,0 @@ -namespace PleromaEntity { - export type Filter = { - id: string - phrase: string - context: Array - expires_at: string | null - irreversible: boolean - whole_word: boolean - } - - export type FilterContext = string -} diff --git a/packages/megalodon/src/pleroma/entities/history.ts b/packages/megalodon/src/pleroma/entities/history.ts deleted file mode 100644 index 9aaaeb8def..0000000000 --- a/packages/megalodon/src/pleroma/entities/history.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace PleromaEntity { - export type History = { - day: string - uses: number - accounts: number - } -} diff --git a/packages/megalodon/src/pleroma/entities/identity_proof.ts b/packages/megalodon/src/pleroma/entities/identity_proof.ts deleted file mode 100644 index 463fdc6817..0000000000 --- a/packages/megalodon/src/pleroma/entities/identity_proof.ts +++ /dev/null @@ -1,9 +0,0 @@ -namespace PleromaEntity { - export type IdentityProof = { - provider: string - provider_username: string - updated_at: string - proof_url: string - profile_url: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/instance.ts b/packages/megalodon/src/pleroma/entities/instance.ts deleted file mode 100644 index 0b57e805e9..0000000000 --- a/packages/megalodon/src/pleroma/entities/instance.ts +++ /dev/null @@ -1,46 +0,0 @@ -/// -/// -/// - -namespace PleromaEntity { - export type Instance = { - uri: string - title: string - description: string - email: string - version: string - thumbnail: string | null - urls: URLs - stats: Stats - languages: Array - registrations: boolean - approval_required: boolean - max_toot_chars: number - max_media_attachments?: number - pleroma: { - metadata: { - account_activation_required: boolean - birthday_min_age: number - birthday_required: boolean - features: Array - federation: { - enabled: boolean - exclusions: boolean - } - fields_limits: { - max_fields: number - max_remote_fields: number - name_length: number - value_length: number - } - post_formats: Array - } - } - poll_limits: { - max_expiration: number - min_expiration: number - max_option_chars: number - max_options: number - } - } -} diff --git a/packages/megalodon/src/pleroma/entities/list.ts b/packages/megalodon/src/pleroma/entities/list.ts deleted file mode 100644 index a3d4362d9e..0000000000 --- a/packages/megalodon/src/pleroma/entities/list.ts +++ /dev/null @@ -1,6 +0,0 @@ -namespace PleromaEntity { - export type List = { - id: string - title: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/marker.ts b/packages/megalodon/src/pleroma/entities/marker.ts deleted file mode 100644 index 720d4a9055..0000000000 --- a/packages/megalodon/src/pleroma/entities/marker.ts +++ /dev/null @@ -1,12 +0,0 @@ -namespace PleromaEntity { - export type Marker = { - notifications: { - last_read_id: string - version: number - updated_at: string - pleroma: { - unread_count: number - } - } - } -} diff --git a/packages/megalodon/src/pleroma/entities/mention.ts b/packages/megalodon/src/pleroma/entities/mention.ts deleted file mode 100644 index 0d68b4ec21..0000000000 --- a/packages/megalodon/src/pleroma/entities/mention.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace PleromaEntity { - export type Mention = { - id: string - username: string - url: string - acct: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/notification.ts b/packages/megalodon/src/pleroma/entities/notification.ts deleted file mode 100644 index edfa456deb..0000000000 --- a/packages/megalodon/src/pleroma/entities/notification.ts +++ /dev/null @@ -1,16 +0,0 @@ -/// -/// - -namespace PleromaEntity { - export type Notification = { - account: Account - created_at: string - id: string - status?: Status - emoji?: string - type: NotificationType - target?: Account - } - - export type NotificationType = string -} diff --git a/packages/megalodon/src/pleroma/entities/poll.ts b/packages/megalodon/src/pleroma/entities/poll.ts deleted file mode 100644 index 82e0182adc..0000000000 --- a/packages/megalodon/src/pleroma/entities/poll.ts +++ /dev/null @@ -1,13 +0,0 @@ -/// - -namespace PleromaEntity { - export type Poll = { - id: string - expires_at: string | null - expired: boolean - multiple: boolean - votes_count: number - options: Array - voted: boolean - } -} diff --git a/packages/megalodon/src/pleroma/entities/poll_option.ts b/packages/megalodon/src/pleroma/entities/poll_option.ts deleted file mode 100644 index 69717ca0f3..0000000000 --- a/packages/megalodon/src/pleroma/entities/poll_option.ts +++ /dev/null @@ -1,6 +0,0 @@ -namespace PleromaEntity { - export type PollOption = { - title: string - votes_count: number | null - } -} diff --git a/packages/megalodon/src/pleroma/entities/preferences.ts b/packages/megalodon/src/pleroma/entities/preferences.ts deleted file mode 100644 index 99f8d6bca1..0000000000 --- a/packages/megalodon/src/pleroma/entities/preferences.ts +++ /dev/null @@ -1,9 +0,0 @@ -namespace PleromaEntity { - export type Preferences = { - 'posting:default:visibility': 'public' | 'unlisted' | 'private' | 'direct' - 'posting:default:sensitive': boolean - 'posting:default:language': string | null - 'reading:expand:media': 'default' | 'show_all' | 'hide_all' - 'reading:expand:spoilers': boolean - } -} diff --git a/packages/megalodon/src/pleroma/entities/push_subscription.ts b/packages/megalodon/src/pleroma/entities/push_subscription.ts deleted file mode 100644 index b3e14e68a3..0000000000 --- a/packages/megalodon/src/pleroma/entities/push_subscription.ts +++ /dev/null @@ -1,16 +0,0 @@ -namespace PleromaEntity { - export type Alerts = { - follow: boolean - favourite: boolean - mention: boolean - reblog: boolean - poll: boolean - } - - export type PushSubscription = { - id: string - endpoint: string - server_key: string - alerts: Alerts - } -} diff --git a/packages/megalodon/src/pleroma/entities/reaction.ts b/packages/megalodon/src/pleroma/entities/reaction.ts deleted file mode 100644 index 662600f252..0000000000 --- a/packages/megalodon/src/pleroma/entities/reaction.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// - -namespace PleromaEntity { - export type Reaction = { - count: number - me: boolean - name: string - accounts?: Array - } -} diff --git a/packages/megalodon/src/pleroma/entities/relationship.ts b/packages/megalodon/src/pleroma/entities/relationship.ts deleted file mode 100644 index 039f8ec74b..0000000000 --- a/packages/megalodon/src/pleroma/entities/relationship.ts +++ /dev/null @@ -1,18 +0,0 @@ -namespace PleromaEntity { - export type Relationship = { - id: string - following: boolean - followed_by: boolean - blocking: boolean - blocked_by: boolean - muting: boolean - muting_notifications: boolean - requested: boolean - domain_blocking: boolean - showing_reblogs: boolean - endorsed: boolean - subscribing: boolean - notifying: boolean - note: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/report.ts b/packages/megalodon/src/pleroma/entities/report.ts deleted file mode 100644 index 5b9c650a16..0000000000 --- a/packages/megalodon/src/pleroma/entities/report.ts +++ /dev/null @@ -1,6 +0,0 @@ -namespace PleromaEntity { - export type Report = { - id: string - action_taken: boolean - } -} diff --git a/packages/megalodon/src/pleroma/entities/results.ts b/packages/megalodon/src/pleroma/entities/results.ts deleted file mode 100644 index cd42e3b090..0000000000 --- a/packages/megalodon/src/pleroma/entities/results.ts +++ /dev/null @@ -1,11 +0,0 @@ -/// -/// -/// - -namespace PleromaEntity { - export type Results = { - accounts: Array - statuses: Array - hashtags: Array - } -} diff --git a/packages/megalodon/src/pleroma/entities/scheduled_status.ts b/packages/megalodon/src/pleroma/entities/scheduled_status.ts deleted file mode 100644 index 547d35fd8f..0000000000 --- a/packages/megalodon/src/pleroma/entities/scheduled_status.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// -/// -namespace PleromaEntity { - export type ScheduledStatus = { - id: string - scheduled_at: string - params: StatusParams - media_attachments: Array | null - } -} diff --git a/packages/megalodon/src/pleroma/entities/source.ts b/packages/megalodon/src/pleroma/entities/source.ts deleted file mode 100644 index f2fa74ab70..0000000000 --- a/packages/megalodon/src/pleroma/entities/source.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// -namespace PleromaEntity { - export type Source = { - privacy: string | null - sensitive: boolean | null - language: string | null - note: string - fields: Array - } -} diff --git a/packages/megalodon/src/pleroma/entities/stats.ts b/packages/megalodon/src/pleroma/entities/stats.ts deleted file mode 100644 index ab3e778454..0000000000 --- a/packages/megalodon/src/pleroma/entities/stats.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace PleromaEntity { - export type Stats = { - user_count: number - status_count: number - domain_count: number - } -} diff --git a/packages/megalodon/src/pleroma/entities/status.ts b/packages/megalodon/src/pleroma/entities/status.ts deleted file mode 100644 index 7c2b887e53..0000000000 --- a/packages/megalodon/src/pleroma/entities/status.ts +++ /dev/null @@ -1,65 +0,0 @@ -/// -/// -/// -/// -/// -/// -/// -/// - -namespace PleromaEntity { - export type Status = { - id: string - uri: string - url: string - account: Account - in_reply_to_id: string | null - in_reply_to_account_id: string | null - reblog: Status | null - content: string - created_at: string - edited_at?: string | null - emojis: Emoji[] - replies_count: number - reblogs_count: number - favourites_count: number - reblogged: boolean | null - favourited: boolean | null - muted: boolean | null - sensitive: boolean - spoiler_text: string - visibility: 'public' | 'unlisted' | 'private' | 'direct' - media_attachments: Array - mentions: Array - tags: Array - card: Card | null - poll: Poll | null - application: Application | null - language: string | null - pinned: boolean | null - bookmarked?: boolean - // Reblogged status contains only local parameter. - pleroma: { - content?: { - 'text/plain': string - } - spoiler_text?: { - 'text/plain': string - } - conversation_id?: number - direct_conversation_id?: number | null - emoji_reactions?: Array - expires_at?: string - in_reply_to_account_acct?: string - local: boolean - parent_visible?: boolean - pinned_at?: string - thread_muted?: boolean - } - } - - export type StatusTag = { - name: string - url: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/status_params.ts b/packages/megalodon/src/pleroma/entities/status_params.ts deleted file mode 100644 index eda13a0b9b..0000000000 --- a/packages/megalodon/src/pleroma/entities/status_params.ts +++ /dev/null @@ -1,11 +0,0 @@ -namespace PleromaEntity { - export type StatusParams = { - text: string - in_reply_to_id: string | null - media_ids?: Array | null - sensitive: boolean | null - spoiler_text: string | null - visibility: 'public' | 'unlisted' | 'private' | 'direct' | null - scheduled_at: string | null - } -} diff --git a/packages/megalodon/src/pleroma/entities/status_source.ts b/packages/megalodon/src/pleroma/entities/status_source.ts deleted file mode 100644 index 57d2bea781..0000000000 --- a/packages/megalodon/src/pleroma/entities/status_source.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace PleromaEntity { - export type StatusSource = { - id: string - text: string - spoiler_text: string - } -} diff --git a/packages/megalodon/src/pleroma/entities/tag.ts b/packages/megalodon/src/pleroma/entities/tag.ts deleted file mode 100644 index e323ec72c3..0000000000 --- a/packages/megalodon/src/pleroma/entities/tag.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// - -namespace PleromaEntity { - export type Tag = { - name: string - url: string - history: Array - following?: boolean - } -} diff --git a/packages/megalodon/src/pleroma/entities/token.ts b/packages/megalodon/src/pleroma/entities/token.ts deleted file mode 100644 index 0ac565b517..0000000000 --- a/packages/megalodon/src/pleroma/entities/token.ts +++ /dev/null @@ -1,8 +0,0 @@ -namespace PleromaEntity { - export type Token = { - access_token: string - token_type: string - scope: string - created_at: number - } -} diff --git a/packages/megalodon/src/pleroma/entities/urls.ts b/packages/megalodon/src/pleroma/entities/urls.ts deleted file mode 100644 index 7ad6faf2b0..0000000000 --- a/packages/megalodon/src/pleroma/entities/urls.ts +++ /dev/null @@ -1,5 +0,0 @@ -namespace PleromaEntity { - export type URLs = { - streaming_api: string - } -} diff --git a/packages/megalodon/src/pleroma/entity.ts b/packages/megalodon/src/pleroma/entity.ts deleted file mode 100644 index bd486f62bd..0000000000 --- a/packages/megalodon/src/pleroma/entity.ts +++ /dev/null @@ -1,39 +0,0 @@ -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// > -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// -/// - -export default PleromaEntity diff --git a/packages/megalodon/src/pleroma/notification.ts b/packages/megalodon/src/pleroma/notification.ts deleted file mode 100644 index 2dad51a6e3..0000000000 --- a/packages/megalodon/src/pleroma/notification.ts +++ /dev/null @@ -1,15 +0,0 @@ -import PleromaEntity from './entity' - -namespace PleromaNotificationType { - export const Mention: PleromaEntity.NotificationType = 'mention' - export const Reblog: PleromaEntity.NotificationType = 'reblog' - export const Favourite: PleromaEntity.NotificationType = 'favourite' - export const Follow: PleromaEntity.NotificationType = 'follow' - export const Poll: PleromaEntity.NotificationType = 'poll' - export const PleromaEmojiReaction: PleromaEntity.NotificationType = 'pleroma:emoji_reaction' - export const FollowRequest: PleromaEntity.NotificationType = 'follow_request' - export const Update: PleromaEntity.NotificationType = 'update' - export const Move: PleromaEntity.NotificationType = 'move' -} - -export default PleromaNotificationType diff --git a/packages/megalodon/src/pleroma/web_socket.ts b/packages/megalodon/src/pleroma/web_socket.ts deleted file mode 100644 index f96ea5dc56..0000000000 --- a/packages/megalodon/src/pleroma/web_socket.ts +++ /dev/null @@ -1,349 +0,0 @@ -import WS from 'ws' -import dayjs, { Dayjs } from 'dayjs' -import { EventEmitter } from 'events' - -import proxyAgent, { ProxyConfig } from '../proxy_config' -import { WebSocketInterface } from '../megalodon' -import PleromaAPI from './api_client' -import { UnknownNotificationTypeError } from '../notification' - -/** - * WebSocket - * Pleroma is not support streaming. It is support websocket instead of streaming. - * So this class connect to Phoenix websocket for Pleroma. - */ -export default class WebSocket extends EventEmitter implements WebSocketInterface { - public url: string - public stream: string - public params: string | null - public parser: Parser - public headers: { [key: string]: string } - public proxyConfig: ProxyConfig | false = false - private _accessToken: string - private _reconnectInterval: number - private _reconnectMaxAttempts: number - private _reconnectCurrentAttempts: number - private _connectionClosed: boolean - private _client: WS | null - private _pongReceivedTimestamp: Dayjs - private _heartbeatInterval: number = 60000 - private _pongWaiting: boolean = false - - /** - * @param url Full url of websocket: e.g. https://pleroma.io/api/v1/streaming - * @param stream Stream name, please refer: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/mastodon_api/mastodon_socket.ex#L19-28 - * @param accessToken The access token. - * @param userAgent The specified User Agent. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - */ - constructor( - url: string, - stream: string, - params: string | undefined, - accessToken: string, - userAgent: string, - proxyConfig: ProxyConfig | false = false - ) { - super() - this.url = url - this.stream = stream - if (params === undefined) { - this.params = null - } else { - this.params = params - } - this.parser = new Parser() - this.headers = { - 'User-Agent': userAgent - } - this.proxyConfig = proxyConfig - this._accessToken = accessToken - this._reconnectInterval = 10000 - this._reconnectMaxAttempts = Infinity - this._reconnectCurrentAttempts = 0 - this._connectionClosed = false - this._client = null - this._pongReceivedTimestamp = dayjs() - } - - /** - * Start websocket connection. - */ - public start() { - this._connectionClosed = false - this._resetRetryParams() - this._startWebSocketConnection() - } - - /** - * Reset connection and start new websocket connection. - */ - private _startWebSocketConnection() { - this._resetConnection() - this._setupParser() - this._client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers, this.proxyConfig) - this._bindSocket(this._client) - } - - /** - * Stop current connection. - */ - public stop() { - this._connectionClosed = true - this._resetConnection() - this._resetRetryParams() - } - - /** - * Clean up current connection, and listeners. - */ - private _resetConnection() { - if (this._client) { - this._client.close(1000) - this._client.removeAllListeners() - this._client = null - } - - if (this.parser) { - this.parser.removeAllListeners() - } - } - - /** - * Resets the parameters used in reconnect. - */ - private _resetRetryParams() { - this._reconnectCurrentAttempts = 0 - } - - /** - * Reconnects to the same endpoint. - */ - private _reconnect() { - setTimeout(() => { - // Skip reconnect when client is connecting. - // https://github.com/websockets/ws/blob/7.2.1/lib/websocket.js#L365 - if (this._client && this._client.readyState === WS.CONNECTING) { - return - } - - if (this._reconnectCurrentAttempts < this._reconnectMaxAttempts) { - this._reconnectCurrentAttempts++ - this._clearBinding() - if (this._client) { - // In reconnect, we want to close the connection immediately, - // because recoonect is necessary when some problems occur. - this._client.terminate() - } - // Call connect methods - console.log('Reconnecting') - this._client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers, this.proxyConfig) - this._bindSocket(this._client) - } - }, this._reconnectInterval) - } - - /** - * @param url Base url of streaming endpoint. - * @param stream The specified stream name. - * @param accessToken Access token. - * @param headers The specified headers. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - * @return A WebSocket instance. - */ - private _connect( - url: string, - stream: string, - params: string | null, - accessToken: string, - headers: { [key: string]: string }, - proxyConfig: ProxyConfig | false - ): WS { - const parameter: Array = [`stream=${stream}`] - - if (params) { - parameter.push(params) - } - - if (accessToken !== null) { - parameter.push(`access_token=${accessToken}`) - } - const requestURL: string = `${url}/?${parameter.join('&')}` - let options: WS.ClientOptions = { - headers: headers - } - if (proxyConfig) { - options = Object.assign(options, { - agent: proxyAgent(proxyConfig) - }) - } - - const cli: WS = new WS(requestURL, options) - return cli - } - - /** - * Clear binding event for web socket client. - */ - private _clearBinding() { - if (this._client) { - this._client.removeAllListeners('close') - this._client.removeAllListeners('pong') - this._client.removeAllListeners('open') - this._client.removeAllListeners('message') - this._client.removeAllListeners('error') - } - } - - /** - * Bind event for web socket client. - * @param client A WebSocket instance. - */ - private _bindSocket(client: WS) { - client.on('close', (code: number, _reason: Buffer) => { - // Refer the code: https://tools.ietf.org/html/rfc6455#section-7.4 - if (code === 1000) { - this.emit('close', {}) - } else { - console.log(`Closed connection with ${code}`) - // If already called close method, it does not retry. - if (!this._connectionClosed) { - this._reconnect() - } - } - }) - client.on('pong', () => { - this._pongWaiting = false - this.emit('pong', {}) - this._pongReceivedTimestamp = dayjs() - // It is required to anonymous function since get this scope in checkAlive. - setTimeout(() => this._checkAlive(this._pongReceivedTimestamp), this._heartbeatInterval) - }) - client.on('open', () => { - this.emit('connect', {}) - // Call first ping event. - setTimeout(() => { - client.ping('') - }, 10000) - }) - client.on('message', (data: WS.Data, isBinary: boolean) => { - this.parser.parse(data, isBinary) - }) - client.on('error', (err: Error) => { - this.emit('error', err) - }) - } - - /** - * Set up parser when receive message. - */ - private _setupParser() { - this.parser.on('update', (status: PleromaAPI.Entity.Status) => { - this.emit('update', PleromaAPI.Converter.status(status)) - }) - this.parser.on('notification', (notification: PleromaAPI.Entity.Notification) => { - const n = PleromaAPI.Converter.notification(notification) - if (n instanceof UnknownNotificationTypeError) { - console.warn(`Unknown notification event has received: ${notification}`) - } else { - this.emit('notification', n) - } - }) - this.parser.on('delete', (id: string) => { - this.emit('delete', id) - }) - this.parser.on('conversation', (conversation: PleromaAPI.Entity.Conversation) => { - this.emit('conversation', PleromaAPI.Converter.conversation(conversation)) - }) - this.parser.on('status_update', (status: PleromaAPI.Entity.Status) => { - this.emit('status_update', PleromaAPI.Converter.status(status)) - }) - this.parser.on('error', (err: Error) => { - this.emit('parser-error', err) - }) - this.parser.on('heartbeat', _ => { - this.emit('heartbeat', 'heartbeat') - }) - } - - /** - * Call ping and wait to pong. - */ - private _checkAlive(timestamp: Dayjs) { - const now: Dayjs = dayjs() - // Block multiple calling, if multiple pong event occur. - // It the duration is less than interval, through ping. - if (now.diff(timestamp) > this._heartbeatInterval - 1000 && !this._connectionClosed) { - // Skip ping when client is connecting. - // https://github.com/websockets/ws/blob/7.2.1/lib/websocket.js#L289 - if (this._client && this._client.readyState !== WS.CONNECTING) { - this._pongWaiting = true - this._client.ping('') - setTimeout(() => { - if (this._pongWaiting) { - this._pongWaiting = false - this._reconnect() - } - }, 10000) - } - } - } -} - -/** - * Parser - * This class provides parser for websocket message. - */ -export class Parser extends EventEmitter { - /** - * @param message Message body of websocket. - */ - public parse(data: WS.Data, isBinary: boolean) { - const message = isBinary ? data : data.toString() - if (typeof message !== 'string') { - this.emit('heartbeat', {}) - return - } - - if (message === '') { - this.emit('heartbeat', {}) - return - } - - let event = '' - let payload = '' - let mes = {} - try { - const obj = JSON.parse(message) - event = obj.event - payload = obj.payload - mes = JSON.parse(payload) - } catch (err) { - // delete event does not have json object - if (event !== 'delete') { - this.emit('error', new Error(`Error parsing websocket reply: ${message}, error message: ${err}`)) - return - } - } - - switch (event) { - case 'update': - this.emit('update', mes as PleromaAPI.Entity.Status) - break - case 'notification': - this.emit('notification', mes as PleromaAPI.Entity.Notification) - break - case 'conversation': - this.emit('conversation', mes as PleromaAPI.Entity.Conversation) - break - case 'delete': - this.emit('delete', payload) - break - case 'status.update': - this.emit('status_update', mes as PleromaAPI.Entity.Status) - break - default: - this.emit('error', new Error(`Unknown event has received: ${message}`)) - } - } -} diff --git a/packages/megalodon/test/integration/cancel.spec.ts b/packages/megalodon/test/integration/cancel.spec.ts deleted file mode 100644 index efc9d49770..0000000000 --- a/packages/megalodon/test/integration/cancel.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import MastodonAPI from '@/mastodon/api_client' -import { Worker } from 'jest-worker' - -jest.mock('axios', () => { - const mockAxios = jest.requireActual('axios') - mockAxios.get = (_path: string) => { - return new Promise(resolve => { - setTimeout(() => { - console.log('hoge') - resolve({ - data: 'hoge', - status: 200, - statusText: '200OK', - headers: [], - config: {} - }) - }, 5000) - }) - } - return mockAxios -}) - -const worker = async (client: MastodonAPI.Client) => { - const w: any = new Worker(require.resolve('./cancelWorker.ts')) - await w.cancel(client) -} - -// Could not use jest-worker under typescript. -// I'm waiting for resolve this issue. -// https://github.com/facebook/jest/issues/8872 -describe.skip('cancel', () => { - const client = new MastodonAPI.Client('testToken', 'https://pleroma.io/api/v1') - it('should be raised', async () => { - const getPromise = client.get<{}>('/timelines/home') - worker(client) - await expect(getPromise).rejects.toThrow() - }) -}) diff --git a/packages/megalodon/test/integration/cancelWorker.ts b/packages/megalodon/test/integration/cancelWorker.ts deleted file mode 100644 index 17a0722780..0000000000 --- a/packages/megalodon/test/integration/cancelWorker.ts +++ /dev/null @@ -1,5 +0,0 @@ -import MastodonAPI from '@/mastodon/api_client' - -export function cancel(client: MastodonAPI.Client) { - return client.cancel() -} diff --git a/packages/megalodon/test/integration/mastodon.spec.ts b/packages/megalodon/test/integration/mastodon.spec.ts deleted file mode 100644 index 172d11a863..0000000000 --- a/packages/megalodon/test/integration/mastodon.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -import MastodonEntity from '@/mastodon/entity' -import MastodonNotificationType from '@/mastodon/notification' -import Mastodon from '@/mastodon' -import MegalodonNotificationType from '@/notification' -import axios, { AxiosResponse, InternalAxiosRequestConfig, AxiosHeaders } from 'axios' - -jest.mock('axios') - -const account: MastodonEntity.Account = { - id: '1', - username: 'h3poteto', - acct: 'h3poteto@pleroma.io', - display_name: 'h3poteto', - locked: false, - group: false, - noindex: false, - suspended: false, - limited: false, - created_at: '2019-03-26T21:30:32', - followers_count: 10, - following_count: 10, - statuses_count: 100, - note: 'engineer', - url: 'https://pleroma.io', - avatar: '', - avatar_static: '', - header: '', - header_static: '', - emojis: [], - moved: null, - fields: [], - bot: false, - source: { - privacy: null, - sensitive: false, - language: null, - note: 'test', - fields: [] - } -} - -const status: MastodonEntity.Status = { - id: '1', - uri: 'http://example.com', - url: 'http://example.com', - account: account, - in_reply_to_id: null, - in_reply_to_account_id: null, - reblog: null, - content: 'hoge', - created_at: '2019-03-26T21:40:32', - emojis: [], - replies_count: 0, - reblogs_count: 0, - favourites_count: 0, - reblogged: null, - favourited: null, - muted: null, - sensitive: false, - spoiler_text: '', - visibility: 'public', - media_attachments: [], - mentions: [], - tags: [], - card: null, - poll: null, - application: { - name: 'Web' - } as MastodonEntity.Application, - language: null, - pinned: null, - bookmarked: false -} - -const follow: MastodonEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '1', - type: MastodonNotificationType.Follow -} - -const favourite: MastodonEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '2', - status: status, - type: MastodonNotificationType.Favourite -} - -const mention: MastodonEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '3', - status: status, - type: MastodonNotificationType.Mention -} - -const reblog: MastodonEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '4', - status: status, - type: MastodonNotificationType.Reblog -} - -const poll: MastodonEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '5', - type: MastodonNotificationType.Poll -} - -const followRequest: MastodonEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '6', - type: MastodonNotificationType.FollowRequest -} - -const toot: MastodonEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '7', - status: status, - type: MastodonNotificationType.Status -} - -const unknownEvent: MastodonEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '8', - type: 'unknown' -} - -;(axios.CancelToken.source as any).mockImplementation(() => { - return { - token: { - throwIfRequested: () => {}, - promise: { - then: () => {}, - catch: () => {} - } - } - } -}) - -describe('getNotifications', () => { - const client = new Mastodon('http://localhost', 'sample token') - const cases: Array<{ event: MastodonEntity.Notification; expected: Entity.NotificationType; title: string }> = [ - { - event: follow, - expected: MegalodonNotificationType.Follow, - title: 'follow' - }, - { - event: favourite, - expected: MegalodonNotificationType.Favourite, - title: 'favourite' - }, - { - event: mention, - expected: MegalodonNotificationType.Mention, - title: 'mention' - }, - { - event: reblog, - expected: MegalodonNotificationType.Reblog, - title: 'reblog' - }, - { - event: poll, - expected: MegalodonNotificationType.PollExpired, - title: 'poll' - }, - { - event: followRequest, - expected: MegalodonNotificationType.FollowRequest, - title: 'followRequest' - }, - { - event: toot, - expected: MegalodonNotificationType.Status, - title: 'status' - } - ] - cases.forEach(c => { - it(`should be ${c.title} event`, async () => { - const config: InternalAxiosRequestConfig = { - headers: new AxiosHeaders() - } - const mockResponse: AxiosResponse> = { - data: [c.event], - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - ;(axios.get as any).mockResolvedValue(mockResponse) - const res = await client.getNotifications() - expect(res.data[0].type).toEqual(c.expected) - }) - }) - it('UnknownEvent should be ignored', async () => { - const config: InternalAxiosRequestConfig = { - headers: new AxiosHeaders() - } - const mockResponse: AxiosResponse> = { - data: [unknownEvent], - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - ;(axios.get as any).mockResolvedValue(mockResponse) - const res = await client.getNotifications() - expect(res.data).toEqual([]) - }) -}) diff --git a/packages/megalodon/test/integration/mastodon/api_client.spec.ts b/packages/megalodon/test/integration/mastodon/api_client.spec.ts deleted file mode 100644 index 51caf4e227..0000000000 --- a/packages/megalodon/test/integration/mastodon/api_client.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import MastodonAPI from '@/mastodon/api_client' -import Entity from '@/entity' -import Response from '@/response' -import axios, { AxiosResponse, InternalAxiosRequestConfig, AxiosHeaders } from 'axios' - -jest.mock('axios') - -const account: Entity.Account = { - id: '1', - username: 'h3poteto', - acct: 'h3poteto@pleroma.io', - display_name: 'h3poteto', - locked: false, - group: false, - noindex: false, - suspended: false, - limited: false, - created_at: '2019-03-26T21:30:32', - followers_count: 10, - following_count: 10, - statuses_count: 100, - note: 'engineer', - url: 'https://pleroma.io', - avatar: '', - avatar_static: '', - header: '', - header_static: '', - emojis: [], - moved: null, - fields: [], - bot: false, - source: { - privacy: null, - sensitive: false, - language: null, - note: 'test', - fields: [] - } -} - -const status: Entity.Status = { - id: '1', - uri: 'http://example.com', - url: 'http://example.com', - account: account, - in_reply_to_id: null, - in_reply_to_account_id: null, - reblog: null, - content: 'hoge', - plain_content: null, - created_at: '2019-03-26T21:40:32', - edited_at: null, - emojis: [], - replies_count: 0, - reblogs_count: 0, - favourites_count: 0, - reblogged: null, - favourited: null, - muted: null, - sensitive: false, - spoiler_text: '', - visibility: 'public', - media_attachments: [], - mentions: [], - tags: [], - card: null, - poll: null, - application: { - name: 'Web' - } as Entity.Application, - language: null, - pinned: null, - emoji_reactions: [], - bookmarked: false, - quote: false -} -;(axios.CancelToken.source as any).mockImplementation(() => { - return { - token: { - throwIfRequested: () => {}, - promise: { - then: () => {}, - catch: () => {} - } - } - } -}) - -const config: InternalAxiosRequestConfig = { - headers: new AxiosHeaders() -} - -describe('get', () => { - const client = new MastodonAPI.Client('testToken', 'https://pleroma.io/api/v1') - const mockResponse: AxiosResponse> = { - data: [status], - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - it('should be responsed', async () => { - ;(axios.get as any).mockResolvedValue(mockResponse) - const response: Response> = await client.get>('/timelines/home') - expect(response.data).toEqual([status]) - }) -}) - -describe('put', () => { - const client = new MastodonAPI.Client('testToken', 'https://pleroma.io/api/v1') - const mockResponse: AxiosResponse = { - data: account, - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - it('should be responsed', async () => { - ;(axios.put as any).mockResolvedValue(mockResponse) - const response: Response = await client.put('/accounts/update_credentials', { - display_name: 'hoge' - }) - expect(response.data).toEqual(account) - }) -}) - -describe('patch', () => { - const client = new MastodonAPI.Client('testToken', 'https://pleroma.io/api/v1') - const mockResponse: AxiosResponse = { - data: account, - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - it('should be responsed', async () => { - ;(axios.patch as any).mockResolvedValue(mockResponse) - const response: Response = await client.patch('/accounts/update_credentials', { - display_name: 'hoge' - }) - expect(response.data).toEqual(account) - }) -}) - -describe('post', () => { - const client = new MastodonAPI.Client('testToken', 'https://pleroma.io/api/v1') - const mockResponse: AxiosResponse = { - data: status, - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - it('should be responsed', async () => { - ;(axios.post as any).mockResolvedValue(mockResponse) - const response: Response = await client.post('/statuses', { - status: 'hoge' - }) - expect(response.data).toEqual(status) - }) -}) - -describe('del', () => { - const client = new MastodonAPI.Client('testToken', 'https://pleroma.io/api/v1') - const mockResponse: AxiosResponse<{}> = { - data: {}, - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - it('should be responsed', async () => { - ;(axios.delete as any).mockResolvedValue(mockResponse) - const response: Response<{}> = await client.del<{}>('/statuses/12asdf34') - expect(response.data).toEqual({}) - }) -}) diff --git a/packages/megalodon/test/integration/pleroma.spec.ts b/packages/megalodon/test/integration/pleroma.spec.ts deleted file mode 100644 index 1e1f449e17..0000000000 --- a/packages/megalodon/test/integration/pleroma.spec.ts +++ /dev/null @@ -1,222 +0,0 @@ -import PleromaEntity from '@/pleroma/entity' -import Pleroma from '@/pleroma' -import MegalodonNotificationType from '@/notification' -import PleromaNotificationType from '@/pleroma/notification' -import axios, { AxiosResponse, InternalAxiosRequestConfig, AxiosHeaders } from 'axios' - -jest.mock('axios') - -const account: PleromaEntity.Account = { - id: '1', - username: 'h3poteto', - acct: 'h3poteto@pleroma.io', - display_name: 'h3poteto', - locked: false, - noindex: null, - suspended: null, - limited: null, - created_at: '2019-03-26T21:30:32', - followers_count: 10, - following_count: 10, - statuses_count: 100, - note: 'engineer', - url: 'https://pleroma.io', - avatar: '', - avatar_static: '', - header: '', - header_static: '', - emojis: [], - moved: null, - fields: [], - bot: false, - source: { - privacy: null, - sensitive: false, - language: null, - note: 'test', - fields: [] - } -} - -const status: PleromaEntity.Status = { - id: '1', - uri: 'http://example.com', - url: 'http://example.com', - account: account, - in_reply_to_id: null, - in_reply_to_account_id: null, - reblog: null, - content: 'hoge', - created_at: '2019-03-26T21:40:32', - emojis: [], - replies_count: 0, - reblogs_count: 0, - favourites_count: 0, - reblogged: null, - favourited: null, - muted: null, - sensitive: false, - spoiler_text: '', - visibility: 'public', - media_attachments: [], - mentions: [], - tags: [], - card: null, - poll: null, - application: { - name: 'Web' - } as MastodonEntity.Application, - language: null, - pinned: null, - bookmarked: false, - pleroma: { - local: false - } -} - -const follow: PleromaEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '1', - type: PleromaNotificationType.Follow -} - -const favourite: PleromaEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '2', - type: PleromaNotificationType.Favourite, - status: status -} - -const mention: PleromaEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '3', - type: PleromaNotificationType.Mention, - status: status -} - -const reblog: PleromaEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '4', - type: PleromaNotificationType.Reblog, - status: status -} - -const poll: PleromaEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '5', - type: PleromaNotificationType.Poll, - status: status -} - -const emojiReaction: PleromaEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '6', - type: PleromaNotificationType.PleromaEmojiReaction, - status: status, - emoji: '♥' -} - -const unknownEvent: PleromaEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '8', - type: 'unknown' -} - -const followRequest: PleromaEntity.Notification = { - account: account, - created_at: '2021-01-31T23:33:26', - id: '7', - type: PleromaNotificationType.FollowRequest -} - -;(axios.CancelToken.source as any).mockImplementation(() => { - return { - token: { - throwIfRequested: () => {}, - promise: { - then: () => {}, - catch: () => {} - } - } - } -}) - -describe('getNotifications', () => { - const client = new Pleroma('http://localhost', 'sample token') - const cases: Array<{ event: PleromaEntity.Notification; expected: Entity.NotificationType; title: string }> = [ - { - event: follow, - expected: MegalodonNotificationType.Follow, - title: 'follow' - }, - { - event: favourite, - expected: MegalodonNotificationType.Favourite, - title: 'favourite' - }, - { - event: mention, - expected: MegalodonNotificationType.Mention, - title: 'mention' - }, - { - event: reblog, - expected: MegalodonNotificationType.Reblog, - title: 'reblog' - }, - { - event: poll, - expected: MegalodonNotificationType.PollExpired, - title: 'poll' - }, - { - event: emojiReaction, - expected: MegalodonNotificationType.EmojiReaction, - title: 'emojiReaction' - }, - { - event: followRequest, - expected: MegalodonNotificationType.FollowRequest, - title: 'followRequest' - } - ] - cases.forEach(c => { - it(`should be ${c.title} event`, async () => { - const config: InternalAxiosRequestConfig = { - headers: new AxiosHeaders() - } - const mockResponse: AxiosResponse> = { - data: [c.event], - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - ;(axios.get as any).mockResolvedValue(mockResponse) - const res = await client.getNotifications() - expect(res.data[0].type).toEqual(c.expected) - }) - }) - it('UnknownEvent should be ignored', async () => { - const config: InternalAxiosRequestConfig = { - headers: new AxiosHeaders() - } - const mockResponse: AxiosResponse> = { - data: [unknownEvent], - status: 200, - statusText: '200OK', - headers: {}, - config: config - } - ;(axios.get as any).mockResolvedValue(mockResponse) - const res = await client.getNotifications() - expect(res.data).toEqual([]) - }) -}) diff --git a/packages/megalodon/test/unit/mastodon.spec.ts b/packages/megalodon/test/unit/mastodon.spec.ts deleted file mode 100644 index 311f60d128..0000000000 --- a/packages/megalodon/test/unit/mastodon.spec.ts +++ /dev/null @@ -1,6 +0,0 @@ -describe('test', () => { - it('should be true', () => { - const res = true - expect(res).toEqual(true) - }) -}) diff --git a/packages/megalodon/test/unit/mastodon/api_client.spec.ts b/packages/megalodon/test/unit/mastodon/api_client.spec.ts deleted file mode 100644 index 1e3c6b5237..0000000000 --- a/packages/megalodon/test/unit/mastodon/api_client.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import MastodonAPI from '@/mastodon/api_client' -import MegalodonEntity from '@/entity' -import MastodonEntity from '@/mastodon/entity' -import MegalodonNotificationType from '@/notification' -import MastodonNotificationType from '@/mastodon/notification' - -describe('api_client', () => { - describe('notification', () => { - describe('encode', () => { - it('megalodon notification type should be encoded to mastodon notification type', () => { - const cases: Array<{ src: MegalodonEntity.NotificationType; dist: MastodonEntity.NotificationType }> = [ - { - src: MegalodonNotificationType.Follow, - dist: MastodonNotificationType.Follow - }, - { - src: MegalodonNotificationType.Favourite, - dist: MastodonNotificationType.Favourite - }, - { - src: MegalodonNotificationType.Reblog, - dist: MastodonNotificationType.Reblog - }, - { - src: MegalodonNotificationType.Mention, - dist: MastodonNotificationType.Mention - }, - { - src: MegalodonNotificationType.PollExpired, - dist: MastodonNotificationType.Poll - }, - { - src: MegalodonNotificationType.FollowRequest, - dist: MastodonNotificationType.FollowRequest - }, - { - src: MegalodonNotificationType.Status, - dist: MastodonNotificationType.Status - } - ] - cases.forEach(c => { - expect(MastodonAPI.Converter.encodeNotificationType(c.src)).toEqual(c.dist) - }) - }) - }) - describe('decode', () => { - it('mastodon notification type should be decoded to megalodon notification type', () => { - const cases: Array<{ src: MastodonEntity.NotificationType; dist: MegalodonEntity.NotificationType }> = [ - { - src: MastodonNotificationType.Follow, - dist: MegalodonNotificationType.Follow - }, - { - src: MastodonNotificationType.Favourite, - dist: MegalodonNotificationType.Favourite - }, - { - src: MastodonNotificationType.Mention, - dist: MegalodonNotificationType.Mention - }, - { - src: MastodonNotificationType.Reblog, - dist: MegalodonNotificationType.Reblog - }, - { - src: MastodonNotificationType.Poll, - dist: MegalodonNotificationType.PollExpired - }, - { - src: MastodonNotificationType.FollowRequest, - dist: MegalodonNotificationType.FollowRequest - } - ] - cases.forEach(c => { - expect(MastodonAPI.Converter.decodeNotificationType(c.src)).toEqual(c.dist) - }) - }) - }) - }) -}) diff --git a/packages/megalodon/test/unit/pleroma/api_client.spec.ts b/packages/megalodon/test/unit/pleroma/api_client.spec.ts deleted file mode 100644 index 98c9ec8e4c..0000000000 --- a/packages/megalodon/test/unit/pleroma/api_client.spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -import PleromaAPI from '@/pleroma/api_client' -import MegalodonEntity from '@/entity' -import PleromaEntity from '@/pleroma/entity' -import MegalodonNotificationType from '@/notification' -import PleromaNotificationType from '@/pleroma/notification' - -const account: PleromaEntity.Account = { - id: '1', - username: 'h3poteto', - acct: 'h3poteto@pleroma.io', - display_name: 'h3poteto', - locked: false, - noindex: null, - suspended: null, - limited: null, - created_at: '2019-03-26T21:30:32', - followers_count: 10, - following_count: 10, - statuses_count: 100, - note: 'engineer', - url: 'https://pleroma.io', - avatar: '', - avatar_static: '', - header: '', - header_static: '', - emojis: [], - moved: null, - fields: [], - bot: false, - source: { - privacy: null, - sensitive: false, - language: null, - note: 'test', - fields: [] - } -} - -describe('api_client', () => { - describe('notification', () => { - describe('encode', () => { - it('megalodon notification type should be encoded to pleroma notification type', () => { - const cases: Array<{ src: MegalodonEntity.NotificationType; dist: PleromaEntity.NotificationType }> = [ - { - src: MegalodonNotificationType.Follow, - dist: PleromaNotificationType.Follow - }, - { - src: MegalodonNotificationType.Favourite, - dist: PleromaNotificationType.Favourite - }, - { - src: MegalodonNotificationType.Reblog, - dist: PleromaNotificationType.Reblog - }, - { - src: MegalodonNotificationType.Mention, - dist: PleromaNotificationType.Mention - }, - { - src: MegalodonNotificationType.PollExpired, - dist: PleromaNotificationType.Poll - }, - { - src: MegalodonNotificationType.EmojiReaction, - dist: PleromaNotificationType.PleromaEmojiReaction - }, - { - src: MegalodonNotificationType.FollowRequest, - dist: PleromaNotificationType.FollowRequest - }, - { - src: MegalodonNotificationType.Update, - dist: PleromaNotificationType.Update - }, - { - src: MegalodonNotificationType.Move, - dist: PleromaNotificationType.Move - } - ] - cases.forEach(c => { - expect(PleromaAPI.Converter.encodeNotificationType(c.src)).toEqual(c.dist) - }) - }) - }) - describe('decode', () => { - it('pleroma notification type should be decoded to megalodon notification type', () => { - const cases: Array<{ src: PleromaEntity.NotificationType; dist: MegalodonEntity.NotificationType }> = [ - { - src: PleromaNotificationType.Follow, - dist: MegalodonNotificationType.Follow - }, - { - src: PleromaNotificationType.Favourite, - dist: MegalodonNotificationType.Favourite - }, - { - src: PleromaNotificationType.Mention, - dist: MegalodonNotificationType.Mention - }, - { - src: PleromaNotificationType.Reblog, - dist: MegalodonNotificationType.Reblog - }, - { - src: PleromaNotificationType.Poll, - dist: MegalodonNotificationType.PollExpired - }, - { - src: PleromaNotificationType.PleromaEmojiReaction, - dist: MegalodonNotificationType.EmojiReaction - }, - { - src: PleromaNotificationType.FollowRequest, - dist: MegalodonNotificationType.FollowRequest - }, - { - src: PleromaNotificationType.Update, - dist: MegalodonNotificationType.Update - }, - { - src: PleromaNotificationType.Move, - dist: MegalodonNotificationType.Move - } - ] - cases.forEach(c => { - expect(PleromaAPI.Converter.decodeNotificationType(c.src)).toEqual(c.dist) - }) - }) - }) - }) - - describe('status', () => { - describe('plain content is included', () => { - it('plain content in pleroma entity should be exported in plain_content column', () => { - const plainContent = 'hoge\nfuga\nfuga' - const content = '

hoge
fuga
fuga

' - const pleromaStatus: PleromaEntity.Status = { - id: '1', - uri: 'https://pleroma.io/notice/1', - url: 'https://pleroma.io/notice/1', - account: account, - in_reply_to_id: null, - in_reply_to_account_id: null, - reblog: null, - content: content, - created_at: '2019-03-26T21:40:32', - emojis: [], - replies_count: 0, - reblogs_count: 0, - favourites_count: 0, - reblogged: null, - favourited: null, - muted: null, - sensitive: false, - spoiler_text: '', - visibility: 'public', - media_attachments: [], - mentions: [], - tags: [], - card: null, - poll: null, - application: { - name: 'Web' - } as MastodonEntity.Application, - language: null, - pinned: null, - bookmarked: false, - pleroma: { - content: { - 'text/plain': plainContent - }, - local: false - } - } - const megalodonStatus = PleromaAPI.Converter.status(pleromaStatus) - expect(megalodonStatus.plain_content).toEqual(plainContent) - expect(megalodonStatus.content).toEqual(content) - }) - }) - - describe('plain content is not included', () => { - it('plain_content should be null', () => { - const content = '

hoge
fuga
fuga

' - const pleromaStatus: PleromaEntity.Status = { - id: '1', - uri: 'https://pleroma.io/notice/1', - url: 'https://pleroma.io/notice/1', - account: account, - in_reply_to_id: null, - in_reply_to_account_id: null, - reblog: null, - content: content, - created_at: '2019-03-26T21:40:32', - emojis: [], - replies_count: 0, - reblogs_count: 0, - favourites_count: 0, - reblogged: null, - favourited: null, - muted: null, - sensitive: false, - spoiler_text: '', - visibility: 'public', - media_attachments: [], - mentions: [], - tags: [], - card: null, - poll: null, - application: { - name: 'Web' - } as MastodonEntity.Application, - language: null, - pinned: null, - bookmarked: false, - pleroma: { - local: false - } - } - const megalodonStatus = PleromaAPI.Converter.status(pleromaStatus) - expect(megalodonStatus.plain_content).toBeNull() - expect(megalodonStatus.content).toEqual(content) - }) - }) - }) -}) diff --git a/packages/megalodon/test/unit/webo_socket.spec.ts b/packages/megalodon/test/unit/webo_socket.spec.ts deleted file mode 100644 index b3b684efb4..0000000000 --- a/packages/megalodon/test/unit/webo_socket.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Parser } from '@/mastodon/web_socket' -import Entity from '@/entity' - -const account: Entity.Account = { - id: '1', - username: 'h3poteto', - acct: 'h3poteto@pleroma.io', - display_name: 'h3poteto', - locked: false, - group: false, - noindex: null, - suspended: null, - limited: null, - created_at: '2019-03-26T21:30:32', - followers_count: 10, - following_count: 10, - statuses_count: 100, - note: 'engineer', - url: 'https://pleroma.io', - avatar: '', - avatar_static: '', - header: '', - header_static: '', - emojis: [], - moved: null, - fields: [], - bot: false -} -const status: Entity.Status = { - id: '1', - uri: 'http://example.com', - url: 'http://example.com', - account: account, - in_reply_to_id: null, - in_reply_to_account_id: null, - reblog: null, - content: 'hoge', - plain_content: 'hoge', - created_at: '2019-03-26T21:40:32', - edited_at: null, - emojis: [], - replies_count: 0, - reblogs_count: 0, - favourites_count: 0, - reblogged: null, - favourited: null, - muted: null, - sensitive: false, - spoiler_text: '', - visibility: 'public', - media_attachments: [], - mentions: [], - tags: [], - card: null, - poll: null, - application: { - name: 'Web' - } as Entity.Application, - language: null, - pinned: null, - emoji_reactions: [], - bookmarked: false, - quote: false -} - -const notification: Entity.Notification = { - id: '1', - account: account, - status: status, - type: 'favourite', - created_at: '2019-04-01T17:01:32' -} - -const conversation: Entity.Conversation = { - id: '1', - accounts: [account], - last_status: status, - unread: true -} - -describe('Parser', () => { - let parser: Parser - - beforeEach(() => { - parser = new Parser() - }) - - describe('parse', () => { - describe('message is heartbeat', () => { - describe('message is an object', () => { - const message = Buffer.alloc(0) - - it('should be called', () => { - const spy = jest.fn() - parser.once('heartbeat', spy) - parser.parse(message, true) - expect(spy).toHaveBeenCalledWith({}) - }) - }) - describe('message is empty string', () => { - const message: string = '' - - it('should be called', () => { - const spy = jest.fn() - parser.once('heartbeat', spy) - parser.parse(Buffer.from(message), false) - expect(spy).toHaveBeenCalledWith({}) - }) - }) - }) - - describe('message is not json', () => { - describe('event is delete', () => { - const message = JSON.stringify({ - event: 'delete', - payload: '12asdf34' - }) - - it('should be called', () => { - const spy = jest.fn() - parser.once('delete', spy) - parser.parse(Buffer.from(message), false) - expect(spy).toHaveBeenCalledWith('12asdf34') - }) - }) - describe('event is not delete', () => { - const message = JSON.stringify({ - event: 'event', - payload: '12asdf34' - }) - - it('should be called', () => { - const error = jest.fn() - const deleted = jest.fn() - parser.once('error', error) - parser.once('delete', deleted) - parser.parse(Buffer.from(message), false) - expect(error).toHaveBeenCalled() - expect(deleted).not.toHaveBeenCalled() - }) - }) - }) - - describe('message is json', () => { - describe('event is update', () => { - const message = JSON.stringify({ - event: 'update', - payload: JSON.stringify(status) - }) - it('should be called', () => { - const spy = jest.fn() - parser.once('update', spy) - parser.parse(Buffer.from(message), false) - expect(spy).toHaveBeenCalledWith(status) - }) - }) - - describe('event is notification', () => { - const message = JSON.stringify({ - event: 'notification', - payload: JSON.stringify(notification) - }) - it('should be called', () => { - const spy = jest.fn() - parser.once('notification', spy) - parser.parse(Buffer.from(message), false) - expect(spy).toHaveBeenCalledWith(notification) - }) - }) - - describe('event is conversation', () => { - const message = JSON.stringify({ - event: 'conversation', - payload: JSON.stringify(conversation) - }) - it('should be called', () => { - const spy = jest.fn() - parser.once('conversation', spy) - parser.parse(Buffer.from(message), false) - expect(spy).toHaveBeenCalledWith(conversation) - }) - }) - }) - }) -}) From 67e57ab50af284e6ef45bd005d85dfca53a3eab4 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 21:47:32 -0400 Subject: [PATCH 06/37] fix several mastodon converters --- .../src/server/api/mastodon/converters.ts | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index d1bd92b618..9d8e031831 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -196,7 +196,7 @@ export class MastoConverters { }); } - public async getEdits(id: string, me?: MiLocalUser | null) { + public async getEdits(id: string, me?: MiLocalUser | null): Promise { const note = await this.mastodonDataService.getNote(id, me); if (!note) { return []; @@ -280,6 +280,7 @@ export class MastoConverters { : ''; const reblogged = await this.mastodonDataService.hasReblog(note.id, me); + const reactions = await Promise.all(status.emoji_reactions.map(r => this.convertReaction(r))); // noinspection ES6MissingAwait return await awaitAll({ @@ -312,8 +313,8 @@ export class MastoConverters { application: null, //FIXME language: null, //FIXME pinned: false, //FIXME - reactions: status.emoji_reactions, - emoji_reactions: status.emoji_reactions, + reactions, + emoji_reactions: reactions, bookmarked: false, //FIXME quote: isQuote ? await this.convertReblog(status.reblog, me) : null, edited_at: note.updatedAt?.toISOString() ?? null, @@ -338,6 +339,13 @@ export class MastoConverters { type: notification.type, }; } + + public async convertReaction(reaction: Entity.Reaction): Promise { + if (reaction.accounts) { + reaction.accounts = await Promise.all(reaction.accounts.map(a => this.convertAccount(a))); + } + return reaction; + } } function simpleConvert(data: T): T { @@ -345,12 +353,13 @@ function simpleConvert(data: T): T { return Object.assign({}, data); } -export function convertAccount(account: Entity.Account) { - return simpleConvert(account); -} -export function convertAnnouncement(announcement: Entity.Announcement) { - return simpleConvert(announcement); +export function convertAnnouncement(announcement: Entity.Announcement): MastodonEntity.Announcement { + return { + ...announcement, + updated_at: announcement.updated_at ?? announcement.published_at, + }; } + export function convertAttachment(attachment: Entity.Attachment): MastodonEntity.Attachment { const { width, height } = attachment.meta?.original ?? attachment.meta ?? {}; const size = (width && height) ? `${width}x${height}` : undefined; @@ -376,28 +385,24 @@ export function convertAttachment(attachment: Entity.Attachment): MastodonEntity } : null, }; } -export function convertFilter(filter: Entity.Filter) { +export function convertFilter(filter: Entity.Filter): MastodonEntity.Filter { return simpleConvert(filter); } -export function convertList(list: Entity.List) { - return simpleConvert(list); +export function convertList(list: Entity.List): MastodonEntity.List { + return { + id: list.id, + title: list.title, + replies_policy: list.replies_policy ?? 'followed', + }; } -export function convertFeaturedTag(tag: Entity.FeaturedTag) { +export function convertFeaturedTag(tag: Entity.FeaturedTag): MastodonEntity.FeaturedTag { return simpleConvert(tag); } -export function convertPoll(poll: Entity.Poll) { +export function convertPoll(poll: Entity.Poll): MastodonEntity.Poll { return simpleConvert(poll); } -// noinspection JSUnusedGlobalSymbols -export function convertReaction(reaction: Entity.Reaction) { - if (reaction.accounts) { - reaction.accounts = reaction.accounts.map(convertAccount); - } - return reaction; -} - // Megalodon sometimes returns broken / stubbed relationship data export function convertRelationship(relationship: Partial & { id: string }): MastodonEntity.Relationship { return { @@ -420,6 +425,6 @@ export function convertRelationship(relationship: Partial & } // noinspection JSUnusedGlobalSymbols -export function convertStatusSource(status: Entity.StatusSource) { +export function convertStatusSource(status: Entity.StatusSource): MastodonEntity.StatusSource { return simpleConvert(status); } From 4a1dd7165edab1984313b8198b163ba3cccafbb5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 21:48:25 -0400 Subject: [PATCH 07/37] normalize mastodon BAD_REQUEST errors --- .../api/mastodon/MastodonApiServerService.ts | 8 +-- .../server/api/mastodon/endpoints/account.ts | 29 +++++------ .../src/server/api/mastodon/endpoints/apps.ts | 6 +-- .../server/api/mastodon/endpoints/filter.ts | 14 +++--- .../api/mastodon/endpoints/notifications.ts | 4 +- .../server/api/mastodon/endpoints/search.ts | 5 +- .../server/api/mastodon/endpoints/status.ts | 50 +++++++++---------- .../server/api/mastodon/endpoints/timeline.ts | 24 ++++----- .../src/server/oauth/OAuth2ProviderService.ts | 4 +- 9 files changed, 73 insertions(+), 71 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index eca0883e65..2735856139 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -110,7 +110,7 @@ export class MastodonApiServerService { }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); + if (!_request.body.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "id"' }); const client = this.clientService.getClient(_request); const data = await client.dismissInstanceAnnouncement(_request.body.id); @@ -222,7 +222,7 @@ export class MastodonApiServerService { }); fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.acceptFollowRequest(_request.params.id); @@ -232,7 +232,7 @@ export class MastodonApiServerService { }); fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.rejectFollowRequest(_request.params.id); @@ -253,7 +253,7 @@ export class MastodonApiServerService { is_sensitive?: string, }, }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const options = { ..._request.body, diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 6ae6ea7c6a..d25f43193a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -143,7 +143,7 @@ export class ApiAccountMastodon { }); fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { - if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); + if (!_request.query.acct) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "acct"' }); const client = this.clientService.getClient(_request); const data = await client.search(_request.query.acct, { type: 'accounts' }); @@ -168,7 +168,7 @@ export class ApiAccountMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccount(_request.params.id); @@ -178,17 +178,18 @@ export class ApiAccountMastodon { }); fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getAccountStatuses(_request.params.id, parseTimelineArgs(_request.query)); + const args = parseTimelineArgs(_request.query); + const data = await client.getAccountStatuses(_request.params.id, args); const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getFeaturedTags(); @@ -198,7 +199,7 @@ export class ApiAccountMastodon { }); fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccountFollowers( @@ -211,7 +212,7 @@ export class ApiAccountMastodon { }); fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccountFollowing( @@ -224,7 +225,7 @@ export class ApiAccountMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccountLists(_request.params.id); @@ -234,7 +235,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.followAccount(_request.params.id); @@ -245,7 +246,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.unfollowAccount(_request.params.id); @@ -256,7 +257,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.blockAccount(_request.params.id); @@ -266,7 +267,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.unblockAccount(_request.params.id); @@ -276,7 +277,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.muteAccount( @@ -289,7 +290,7 @@ export class ApiAccountMastodon { }); fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.unmuteAccount(_request.params.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index e1c5f27739..dbef3b7d35 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -65,9 +65,9 @@ export class ApiAppsMastodon { public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { const body = _request.body ?? _request.query; - if (!body.scopes) return reply.code(400).send({ error: 'Missing required payload "scopes"' }); - if (!body.redirect_uris) return reply.code(400).send({ error: 'Missing required payload "redirect_uris"' }); - if (!body.client_name) return reply.code(400).send({ error: 'Missing required payload "client_name"' }); + if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' }); + if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' }); + if (!body.client_name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "client_name"' }); let scope = body.scopes; if (typeof scope === 'string') { diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 7f986974fc..d02ddd1999 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -40,7 +40,7 @@ export class ApiFilterMastodon { }); fastify.get('/v1/filters/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getFilter(_request.params.id); @@ -50,8 +50,8 @@ export class ApiFilterMastodon { }); fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); - if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); const options = { phrase: _request.body.phrase, @@ -69,9 +69,9 @@ export class ApiFilterMastodon { }); fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.phrase) return reply.code(400).send({ error: 'Missing required payload "phrase"' }); - if (!_request.body.context) return reply.code(400).send({ error: 'Missing required payload "context"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); + if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); const options = { phrase: _request.body.phrase, @@ -89,7 +89,7 @@ export class ApiFilterMastodon { }); fastify.delete('/v1/filters/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.deleteFilter(_request.params.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 27e6cbcd0d..5b03c21d6f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -40,7 +40,7 @@ export class ApiNotificationsMastodon { }); fastify.get('/v1/notification/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getNotification(_request.params.id); @@ -53,7 +53,7 @@ export class ApiNotificationsMastodon { }); fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.dismissNotification(_request.params.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 814e2cf776..34d82096ba 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -27,7 +27,8 @@ export class ApiSearchMastodon { public register(fastify: FastifyInstance): void { fastify.get('/v1/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (!_request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); const query = parseTimelineArgs(_request.query); const client = this.clientService.getClient(_request); @@ -37,7 +38,7 @@ export class ApiSearchMastodon { }); fastify.get('/v2/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'Missing required property "q"' }); + if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); const query = parseTimelineArgs(_request.query); const type = _request.query.type; diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 8b9ccf44b6..e64df3d74c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -26,7 +26,7 @@ export class ApiStatusMastodon { public register(fastify: FastifyInstance): void { fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getStatus(_request.params.id); @@ -36,7 +36,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getStatusSource(_request.params.id); @@ -45,7 +45,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); @@ -57,7 +57,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const user = await this.clientService.getAuth(_request); const edits = await this.mastoConverters.getEdits(_request.params.id, user); @@ -66,7 +66,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getStatusRebloggedBy(_request.params.id); @@ -76,7 +76,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getStatusFavouritedBy(_request.params.id); @@ -86,7 +86,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getMedia(_request.params.id); @@ -96,7 +96,7 @@ export class ApiStatusMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getPoll(_request.params.id); @@ -106,8 +106,8 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.choices) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "choices"' }); const client = this.clientService.getClient(_request); const data = await client.votePoll(_request.params.id, _request.body.choices); @@ -168,10 +168,10 @@ export class ApiStatusMastodon { if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; if (body.poll && !body.poll.options) { - return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.options"' }); } if (body.poll && !body.poll.expires_in) { - return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.expires_in"' }); } const options = { @@ -231,7 +231,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, '❤'); @@ -241,7 +241,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, '❤'); @@ -251,7 +251,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.reblogStatus(_request.params.id); @@ -261,7 +261,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unreblogStatus(_request.params.id); @@ -271,7 +271,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.bookmarkStatus(_request.params.id); @@ -281,7 +281,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unbookmarkStatus(_request.params.id); @@ -290,7 +290,7 @@ export class ApiStatusMastodon { reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.pinStatus(_request.params.id); @@ -300,7 +300,7 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.unpinStatus(_request.params.id); @@ -310,8 +310,8 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, _request.params.name); @@ -321,8 +321,8 @@ export class ApiStatusMastodon { }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' }); const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); @@ -332,7 +332,7 @@ export class ApiStatusMastodon { }); fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.deleteStatus(_request.params.id); diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 7dee9a062c..975aa9d04b 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -39,7 +39,7 @@ export class ApiTimelineMastodon { }); fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); + if (!_request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); const { client, me } = await this.clientService.getAuthClient(_request); const query = parseTimelineArgs(_request.query); @@ -50,7 +50,7 @@ export class ApiTimelineMastodon { }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const { client, me } = await this.clientService.getAuthClient(_request); const query = parseTimelineArgs(_request.query); @@ -70,7 +70,7 @@ export class ApiTimelineMastodon { }); fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getList(_request.params.id); @@ -88,7 +88,7 @@ export class ApiTimelineMastodon { }); fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.getAccountsInList(_request.params.id, _request.query); @@ -98,8 +98,8 @@ export class ApiTimelineMastodon { }); fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' }); const client = this.clientService.getClient(_request); const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); @@ -108,8 +108,8 @@ export class ApiTimelineMastodon { }); fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' }); const client = this.clientService.getClient(_request); const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); @@ -118,7 +118,7 @@ export class ApiTimelineMastodon { }); fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' }); const client = this.clientService.getClient(_request); const data = await client.createList(_request.body.title); @@ -128,8 +128,8 @@ export class ApiTimelineMastodon { }); fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); - if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' }); const client = this.clientService.getClient(_request); const data = await client.updateList(_request.params.id, _request.body.title); @@ -139,7 +139,7 @@ export class ApiTimelineMastodon { }); fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); await client.deleteList(_request.params.id); diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 86d903f223..87c09abaf4 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -128,7 +128,7 @@ export class OAuth2ProviderService { for (const url of ['/authorize', '/authorize/']) { fastify.get<{ Querystring: Record }>(url, async (request, reply) => { - if (typeof(request.query.client_id) !== 'string') return reply.code(400).send({ error: 'Missing required query "client_id"' }); + if (typeof(request.query.client_id) !== 'string') return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required query "client_id"' }); const redirectUri = new URL(Buffer.from(request.query.client_id, 'base64').toString()); redirectUri.searchParams.set('mastodon', 'true'); @@ -153,7 +153,7 @@ export class OAuth2ProviderService { } try { - if (!body.client_secret) return reply.code(400).send({ error: 'Missing required query "client_secret"' }); + if (!body.client_secret) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required query "client_secret"' }); const clientId = body.client_id ? String(body.clientId) : null; const secret = String(body.client_secret); From 8b0555cab899dbccde46ba1f73d91d9fe547e2d7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 21:48:51 -0400 Subject: [PATCH 08/37] fix /api/v1/instance response --- .../src/server/api/mastodon/endpoints/instance.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index bc7ef69100..37f64979b4 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -11,7 +11,9 @@ import { DI } from '@/di-symbols.js'; import type { MiMeta, UsersRepository } from '@/models/_.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { RoleService } from '@/core/RoleService.js'; import type { FastifyInstance } from 'fastify'; +import type { MastodonEntity } from 'megalodon'; @Injectable() export class ApiInstanceMastodon { @@ -27,11 +29,12 @@ export class ApiInstanceMastodon { private readonly mastoConverters: MastoConverters, private readonly clientService: MastodonClientService, + private readonly roleService: RoleService, ) {} public register(fastify: FastifyInstance): void { fastify.get('/v1/instance', async (_request, reply) => { - const client = this.clientService.getClient(_request); + const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getInstance(); const instance = data.data; const admin = await this.usersRepository.findOne({ @@ -44,11 +47,11 @@ export class ApiInstanceMastodon { order: { id: 'ASC' }, }); const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); + const roles = await this.roleService.getUserPolicies(me?.id ?? null); - const response = { + const response: MastodonEntity.Instance = { uri: this.config.url, title: this.meta.name || 'Sharkey', - short_description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', email: instance.email || '', version: `3.0.0 (compatible; Sharkey ${this.config.version})`, @@ -66,6 +69,7 @@ export class ApiInstanceMastodon { configuration: { accounts: { max_featured_tags: 20, + max_pinned_statuses: roles.pinLimit, }, statuses: { max_characters: this.config.maxNoteLength, @@ -77,7 +81,7 @@ export class ApiInstanceMastodon { image_size_limit: 10485760, image_matrix_limit: 16777216, video_size_limit: 41943040, - video_frame_rate_limit: 60, + video_frame_limit: 60, video_matrix_limit: 2304000, }, polls: { @@ -91,7 +95,7 @@ export class ApiInstanceMastodon { }, }, contact_account: contact, - rules: [], + rules: instance.rules ?? [], }; reply.send(response); From f5be341accd0e3cbfb7d5da113468b58947cfd1d Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 22:50:28 -0400 Subject: [PATCH 09/37] normalize mastodon API query parameters to strip `[]` suffix --- .../api/mastodon/MastodonApiServerService.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 2735856139..c35f318ac7 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -74,6 +74,50 @@ export class MastodonApiServerService { payload.on('error', done); }); + // Remove trailing "[]" from query params + fastify.addHook('preValidation', (request, _reply, done) => { + if (!request.query || typeof(request.query) !== 'object') { + return done(); + } + + // Same object aliased with a different type + const query = request.query as Record; + + for (const key of Object.keys(query)) { + if (!key.endsWith('[]')) { + continue; + } + if (query[key] == null) { + continue; + } + + const newKey = key.substring(0, key.length - 2); + const newValue = query[key]; + const oldValue = query[newKey]; + + // Move the value to the correct key + if (oldValue != null) { + if (Array.isArray(oldValue)) { + // Works for both array and single values + query[newKey] = oldValue.concat(newValue); + } else if (Array.isArray(newValue)) { + // Preserve order + query[newKey] = [oldValue, ...newValue]; + } else { + // Preserve order + query[newKey] = [oldValue, newValue]; + } + } else { + query[newKey] = newValue; + } + + // Remove the invalid key + delete query[key]; + } + + return done(); + }); + fastify.setErrorHandler((error, request, reply) => { const data = getErrorData(error); const status = getErrorStatus(error); From de26ffd60babc22a15b190c1a38af242140887b9 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 22:51:33 -0400 Subject: [PATCH 10/37] improve performance of /v1/accounts/relationships --- .../src/server/api/mastodon/endpoints/account.ts | 9 +++------ packages/megalodon/src/megalodon.ts | 2 +- packages/megalodon/src/misskey.ts | 15 ++++++++++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index d25f43193a..17ec9a97dd 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -154,14 +154,11 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; - if (typeof ids === 'string') { - ids = [ids]; - } + fastify.get('/v1/accounts/relationships', async (_request, reply) => { + if (!_request.query.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "id"' }); const client = this.clientService.getClient(_request); - const data = await client.getRelationships(ids); + const data = await client.getRelationships(_request.query.id); const response = data.data.map(relationship => convertRelationship(relationship)); reply.send(response); diff --git a/packages/megalodon/src/megalodon.ts b/packages/megalodon/src/megalodon.ts index 4328f41f1c..6032c351c9 100644 --- a/packages/megalodon/src/megalodon.ts +++ b/packages/megalodon/src/megalodon.ts @@ -342,7 +342,7 @@ export interface MegalodonInterface { * @param ids Array of account IDs. * @return Array of Relationship. */ - getRelationships(ids: Array): Promise>> + getRelationships(ids: string | Array): Promise>> /** * Search for matching accounts by username or display name. * diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index c9a33e3130..cf6adbb70d 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -606,11 +606,16 @@ export default class Misskey implements MegalodonInterface { * * @param ids 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) - })) + public async getRelationships(ids: string | Array): Promise>> { + return this.client + .post('/api/users/relation', { + userId: ids + }) + .then(res => { + return Object.assign(res, { + data: res.data.map(r => MisskeyAPI.Converter.relation(r)) + }) + }) } /** From 759e7f05c4624e2c197ef9fa4859c14cc2391bf6 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 22:52:00 -0400 Subject: [PATCH 11/37] fix megalodon getRelationship function --- packages/megalodon/src/misskey.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index cf6adbb70d..78ebbae496 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -591,12 +591,12 @@ export default class Misskey implements MegalodonInterface { */ public async getRelationship(id: string): Promise> { return this.client - .post('/api/users/relation', { + .post('/api/users/relation', { userId: id }) .then(res => { return Object.assign(res, { - data: MisskeyAPI.Converter.relation(res.data) + data: MisskeyAPI.Converter.relation(res.data[0]) }) }) } From f00a0fee4508a692d06a4216dd35049a6b9f6c85 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 22:52:42 -0400 Subject: [PATCH 12/37] minor fixes to /v1/accounts/verify_credentials --- .../backend/src/server/api/mastodon/endpoints/account.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 17ec9a97dd..6f6999f2e1 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -35,15 +35,14 @@ export class ApiAccountMastodon { public register(fastify: FastifyInstance, upload: ReturnType): void { fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - const client = await this.clientService.getClient(_request); + const client = this.clientService.getClient(_request); const data = await client.verifyAccountCredentials(); const acct = await this.mastoConverters.convertAccount(data.data); const response = Object.assign({}, acct, { source: { - // TODO move these into the convertAccount logic directly note: acct.note, fields: acct.fields, - privacy: '', + privacy: 'public', sensitive: false, language: '', }, From 2b03f513154ef10bc799d52a2497456e420acfd6 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 23:24:02 -0400 Subject: [PATCH 13/37] don't return httpStatusCode in mastodon errors --- packages/backend/src/server/api/mastodon/MastodonLogger.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index c7bca22922..5412a27dc9 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -90,6 +90,7 @@ function convertApiError(apiError: ApiError): MastodonError { delete mastoError.code; delete mastoError.message; + delete mastoError.httpStatusCode; return mastoError; } From c69f7b87f0b3e517c110d51ea16d2083a6529368 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 23:24:29 -0400 Subject: [PATCH 14/37] fix empty response from /api/v1/mutes --- .../backend/src/server/api/mastodon/MastodonApiServerService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index c35f318ac7..601dd2b950 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -241,7 +241,7 @@ export class MastodonApiServerService { const client = this.clientService.getClient(_request); const data = await client.getMutes(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); reply.send(response); }); From 178fe16f681edbf01c77763092e3d1ed81d7b482 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 23:24:38 -0400 Subject: [PATCH 15/37] fix empty response from /api/v1/blocks --- .../backend/src/server/api/mastodon/MastodonApiServerService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 601dd2b950..94345027ed 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -250,7 +250,7 @@ export class MastodonApiServerService { const client = this.clientService.getClient(_request); const data = await client.getBlocks(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); + const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); reply.send(response); }); From cac8377e4ea6e0af507684528d386ae4f1ffd94e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 23:24:55 -0400 Subject: [PATCH 16/37] fix empty response from /api/v1/notifications --- .../backend/src/server/api/mastodon/endpoints/notifications.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 5b03c21d6f..3b2833bf86 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -28,7 +28,7 @@ export class ApiNotificationsMastodon { fastify.get('/v1/notifications', async (_request, reply) => { const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getNotifications(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map(async n => { + const response = await Promise.all(data.data.map(async n => { const converted = await this.mastoConverters.convertNotification(n, me); if (converted.type === 'reaction') { converted.type = 'favourite'; From aaf49eadee0bffc9fe0ffbd1355f08cc44b49394 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 23:25:40 -0400 Subject: [PATCH 17/37] implement /api/v1/bookmarks --- packages/megalodon/src/misskey.ts | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index 78ebbae496..643082dff0 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -657,16 +657,38 @@ export default class Misskey implements MegalodonInterface { // ====================================== // accounts/bookmarks // ====================================== - public async getBookmarks(_options?: { + /** + * POST /api/i/favorites + */ + public async getBookmarks(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) - }) + let params = {} + 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.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + } + return this.client.post>('/api/i/favorites', params).then(res => { + return Object.assign(res, { + data: res.data.map(fav => MisskeyAPI.Converter.note(fav.note, this.baseUrl)) + }) + }) } // ====================================== From 3d8930f07006fac3e8e5caff2118d057dbbc987c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Mar 2025 23:26:12 -0400 Subject: [PATCH 18/37] implement /api/v1/favourites --- .../api/mastodon/MastodonApiServerService.ts | 18 +++++- packages/megalodon/src/misskey.ts | 62 +++++++++++-------- packages/megalodon/src/misskey/api_client.ts | 1 + .../src/misskey/entities/reaction.ts | 5 ++ 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 94345027ed..517beb4f44 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -19,6 +19,7 @@ import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js'; import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js'; import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js'; import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; +import { ApiError } from '@/server/api/error.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js'; import { convertAnnouncement, convertAttachment, MastoConverters, convertRelationship } from './converters.js'; import type { Entity } from 'megalodon'; @@ -231,8 +232,21 @@ export class MastodonApiServerService { fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => { const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getFavourites(parseTimelineArgs(_request.query)); - const response = Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); + if (!me) { + throw new ApiError({ + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401, + }); + } + + const args = { + ...parseTimelineArgs(_request.query), + userId: me.id, + }; + const data = await client.getFavourites(args); + const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); reply.send(response); }); diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index 643082dff0..dce0fb21b7 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -691,36 +691,48 @@ export default class Misskey implements MegalodonInterface { }) } + /** + * POST /api/users/reactions + */ + public async getReactions(userId: string, options?: { limit?: number; max_id?: string; min_id?: string }): Promise> { + let params = { + userId, + }; + 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.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + } + return this.client.post('/api/users/reactions', params); + } + // ====================================== // accounts/favourites // ====================================== /** - * POST /api/i/favorites + * POST /api/users/reactions */ - public async getFavourites(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { - let params = {} - 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.min_id) { - params = Object.assign(params, { - sinceId: options.min_id - }) - } - } - return this.client.post>('/api/i/favorites', params).then(res => { - return Object.assign(res, { - data: res.data.map(fav => MisskeyAPI.Converter.note(fav.note, this.baseUrl)) - }) - }) + public async getFavourites(options?: { limit?: number; max_id?: string; min_id?: string; userId?: string }): Promise>> { + const userId = options?.userId ?? (await this.verifyAccountCredentials()).data.id; + + const response = await this.getReactions(userId, options); + + return { + ...response, + data: response.data.map(r => MisskeyAPI.Converter.note(r.note, this.baseUrl)), + }; } // ====================================== diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index a4352613eb..a9a592b28c 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -32,6 +32,7 @@ namespace MisskeyAPI { export type Notification = MisskeyEntity.Notification export type Poll = MisskeyEntity.Poll export type Reaction = MisskeyEntity.Reaction + export type NoteReaction = MisskeyEntity.NoteReaction export type Relation = MisskeyEntity.Relation export type User = MisskeyEntity.User export type UserDetail = MisskeyEntity.UserDetail diff --git a/packages/megalodon/src/misskey/entities/reaction.ts b/packages/megalodon/src/misskey/entities/reaction.ts index 270ca6eab1..de959b2627 100644 --- a/packages/megalodon/src/misskey/entities/reaction.ts +++ b/packages/megalodon/src/misskey/entities/reaction.ts @@ -1,4 +1,5 @@ /// +/// namespace MisskeyEntity { export type Reaction = { @@ -7,4 +8,8 @@ namespace MisskeyEntity { user: User type: string } + + export type NoteReaction = Reaction & { + note: Note + } } From fc1d0c958c0fa9eb4a495ef9138d4250f2ba81a0 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 22 Mar 2025 14:19:32 -0400 Subject: [PATCH 19/37] support Mastodon v4 "link header" pagination --- .../api/mastodon/MastodonClientService.ts | 14 +- .../server/api/mastodon/endpoints/account.ts | 36 ++-- .../api/mastodon/endpoints/notifications.ts | 8 +- .../server/api/mastodon/endpoints/search.ts | 111 +++++++++--- .../server/api/mastodon/endpoints/timeline.ts | 68 +++---- .../src/server/api/mastodon/pagination.ts | 170 ++++++++++++++++++ 6 files changed, 323 insertions(+), 84 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/pagination.ts diff --git a/packages/backend/src/server/api/mastodon/MastodonClientService.ts b/packages/backend/src/server/api/mastodon/MastodonClientService.ts index 474aaefb35..d7b74bb751 100644 --- a/packages/backend/src/server/api/mastodon/MastodonClientService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonClientService.ts @@ -51,12 +51,14 @@ export class MastodonClientService { return new Misskey(baseUrl, accessToken, userAgent); } - /** - * Gets the base URL (origin) of the incoming request - */ - public getBaseUrl(request: FastifyRequest): string { - return `${request.protocol}://${request.host}`; - } + readonly getBaseUrl = getBaseUrl; +} + +/** + * Gets the base URL (origin) of the incoming request + */ +export function getBaseUrl(request: FastifyRequest): string { + return `${request.protocol}://${request.host}`; } /** diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 6f6999f2e1..f669b71efb 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -9,6 +9,7 @@ import { MastodonClientService } from '@/server/api/mastodon/MastodonClientServi import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastoConverters, convertRelationship, convertFeaturedTag, convertList } from '../converters.js'; import type multer from 'fastify-multer'; import type { FastifyInstance } from 'fastify'; @@ -173,14 +174,15 @@ export class ApiAccountMastodon { reply.send(account); }); - fastify.get('/v1/accounts/:id/statuses', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/statuses', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const args = parseTimelineArgs(_request.query); - const data = await client.getAccountStatuses(_request.params.id, args); + const { client, me } = await this.clientService.getAuthClient(request); + const args = parseTimelineArgs(request.query); + const data = await client.getAccountStatuses(request.params.id, args); const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); @@ -194,29 +196,31 @@ export class ApiAccountMastodon { reply.send(response); }); - fastify.get('/v1/accounts/:id/followers', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/followers', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(request); const data = await client.getAccountFollowers( - _request.params.id, - parseTimelineArgs(_request.query), + request.params.id, + parseTimelineArgs(request.query), ); const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get('/v1/accounts/:id/following', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get('/v1/accounts/:id/following', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); + const client = this.clientService.getClient(request); const data = await client.getAccountFollowing( - _request.params.id, - parseTimelineArgs(_request.query), + request.params.id, + parseTimelineArgs(request.query), ); const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); @@ -236,7 +240,7 @@ export class ApiAccountMastodon { const client = this.clientService.getClient(_request); const data = await client.followAccount(_request.params.id); const acct = convertRelationship(data.data); - acct.following = true; + acct.following = true; // TODO this is wrong, follow may not have processed immediately reply.send(acct); }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 3b2833bf86..6acb9edd6b 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonClientService } from '../MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; @@ -25,9 +26,9 @@ export class ApiNotificationsMastodon { ) {} public register(fastify: FastifyInstance, upload: ReturnType): void { - fastify.get('/v1/notifications', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const data = await client.getNotifications(parseTimelineArgs(_request.query)); + fastify.get('/v1/notifications', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const data = await client.getNotifications(parseTimelineArgs(request.query)); const response = await Promise.all(data.data.map(async n => { const converted = await this.mastoConverters.convertNotification(n, me); if (converted.type === 'reaction') { @@ -36,6 +37,7 @@ export class ApiNotificationsMastodon { return converted; })); + attachMinMaxPagination(request, reply, response); reply.send(response); }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 34d82096ba..997a585077 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -5,16 +5,18 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js'; import { MastoConverters } from '../converters.js'; -import { parseTimelineArgs, TimelineArgs } from '../argsUtils.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; import Account = Entity.Account; import Status = Entity.Status; import type { FastifyInstance } from 'fastify'; interface ApiSearchMastodonRoute { Querystring: TimelineArgs & { - type?: 'accounts' | 'hashtags' | 'statuses'; + type?: string; q?: string; + resolve?: string; } } @@ -26,66 +28,116 @@ export class ApiSearchMastodon { ) {} public register(fastify: FastifyInstance): void { - fastify.get('/v1/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); - if (!_request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); + fastify.get('/v1/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (!request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' }); - const query = parseTimelineArgs(_request.query); - const client = this.clientService.getClient(_request); - const data = await client.search(_request.query.q, { type: _request.query.type, ...query }); + const type = request.query.type; + if (type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } - reply.send(data.data); - }); + const { client, me } = await this.clientService.getAuthClient(request); - fastify.get('/v2/search', async (_request, reply) => { - if (!_request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } - const query = parseTimelineArgs(_request.query); - const type = _request.query.type; - const { client, me } = await this.clientService.getAuthClient(_request); - const acct = !type || type === 'accounts' ? await client.search(_request.query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await client.search(_request.query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await client.search(_request.query.q, { type: 'hashtags', ...query }) : null; + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const { data } = await client.search(request.query.q, { type, ...query }); const response = { - accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, me)) ?? []), - hashtags: tags?.data.hashtags ?? [], + ...data, + accounts: await Promise.all(data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account))), + statuses: await Promise.all(data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me))), }; + if (type === 'hashtags') { + attachOffsetPagination(request, reply, response.hashtags); + } else { + attachMinMaxPagination(request, reply, response[type]); + } + reply.send(response); }); - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const baseUrl = this.clientService.getBaseUrl(_request); + fastify.get('/v2/search', async (request, reply) => { + if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' }); + + const type = request.query.type; + if (type !== undefined && type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') { + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' }); + } + + const { client, me } = await this.clientService.getAuthClient(request); + + if (toBoolean(request.query.resolve) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' }); + } + if (toInt(request.query.offset) && !me) { + return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' }); + } + + // TODO implement resolve + + const query = parseTimelineArgs(request.query); + const acct = !type || type === 'accounts' ? await client.search(request.query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await client.search(request.query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await client.search(request.query.q, { type: 'hashtags', ...query }) : null; + const response = { + accounts: await Promise.all(acct?.data.accounts.map((account: Account) => this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map((status: Status) => this.mastoConverters.convertStatus(status, me)) ?? []), + hashtags: tags?.data.hashtags ?? [], + }; + + // Pagination hack, based on "best guess" expected behavior. + // Mastodon doesn't document this part at all! + const longestResult = [response.statuses, response.hashtags] + .reduce((longest: unknown[], current: unknown[]) => current.length > longest.length ? current : longest, response.accounts); + + // Ignore min/max pagination because how TF would that work with multiple result sets?? + // Offset pagination is the only possible option + attachOffsetPagination(request, reply, longestResult); + + reply.send(response); + }); + + fastify.get('/v1/trends/statuses', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); const res = await fetch(`${baseUrl}/api/notes/featured`, { method: 'POST', headers: { - ..._request.headers as HeadersInit, + ...request.headers as HeadersInit, 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: '{}', }); const data = await res.json() as Status[]; - const me = await this.clientService.getAuth(_request); + const me = await this.clientService.getAuth(request); const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get('/v2/suggestions', async (_request, reply) => { - const baseUrl = this.clientService.getBaseUrl(_request); + fastify.get('/v2/suggestions', async (request, reply) => { + const baseUrl = this.clientService.getBaseUrl(request); const res = await fetch(`${baseUrl}/api/users`, { method: 'POST', headers: { - ..._request.headers as HeadersInit, + ...request.headers as HeadersInit, 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ - limit: parseTimelineArgs(_request.query).limit ?? 20, + limit: parseTimelineArgs(request.query).limit ?? 20, origin: 'local', sort: '+follower', state: 'alive', @@ -99,6 +151,7 @@ export class ApiSearchMastodon { }; })); + attachOffsetPagination(request, reply, response); reply.send(response); }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 975aa9d04b..a333e77c3e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { convertList, MastoConverters } from '../converters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; @@ -18,55 +19,60 @@ export class ApiTimelineMastodon { ) {} public register(fastify: FastifyInstance): void { - fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = toBoolean(_request.query.local) + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = toBoolean(request.query.local) ? await client.getLocalTimeline(query) : await client.getPublicTimeline(query); const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); + fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); const data = await client.getHomeTimeline(query); const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - if (!_request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); + fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => { + if (!request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getTagTimeline(_request.params.hashtag, query); + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getTagTimeline(request.params.hashtag, query); const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); - const data = await client.getListTimeline(_request.params.id, query); + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); + const data = await client.getListTimeline(request.params.id, query); const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { - const { client, me } = await this.clientService.getAuthClient(_request); - const query = parseTimelineArgs(_request.query); + fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => { + const { client, me } = await this.clientService.getAuthClient(request); + const query = parseTimelineArgs(request.query); const data = await client.getConversationTimeline(query); - const conversations = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); + const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); - reply.send(conversations); + attachMinMaxPagination(request, reply, response); + reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -79,22 +85,24 @@ export class ApiTimelineMastodon { reply.send(response); }); - fastify.get('/v1/lists', async (_request, reply) => { - const client = this.clientService.getClient(_request); + fastify.get('/v1/lists', async (request, reply) => { + const client = this.clientService.getClient(request); const data = await client.getLists(); const response = data.data.map((list: Entity.List) => convertList(list)); + attachMinMaxPagination(request, reply, response); reply.send(response); }); - fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); + fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => { + if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); - const client = this.clientService.getClient(_request); - const data = await client.getAccountsInList(_request.params.id, _request.query); - const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + const client = this.clientService.getClient(request); + const data = await client.getAccountsInList(request.params.id, parseTimelineArgs(request.query)); + const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(accounts); + attachMinMaxPagination(request, reply, response); + reply.send(response); }); fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { diff --git a/packages/backend/src/server/api/mastodon/pagination.ts b/packages/backend/src/server/api/mastodon/pagination.ts new file mode 100644 index 0000000000..2cf24cfb24 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/pagination.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FastifyReply, FastifyRequest } from 'fastify'; +import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js'; + +interface AnyEntity { + readonly id: string; +} + +/** + * Attaches Mastodon's pagination headers to a response that is paginated by min_id / max_id parameters. + * Results must be sorted, but can be in ascending or descending order. + * Attached headers will always be in descending order. + * + * @param request Fastify request object + * @param reply Fastify reply object + * @param results Results array, ordered in ascending or descending order + */ +export function attachMinMaxPagination(request: FastifyRequest, reply: FastifyReply, results: AnyEntity[]): void { + // No results, nothing to do + if (!hasItems(results)) return; + + // "next" link - older results + const oldest = findOldest(results); + const nextUrl = createPaginationUrl(request, { max_id: oldest }); // Next page (older) has IDs less than the oldest of this page + const next = `<${nextUrl}>; rel="next"`; + + // "prev" link - newer results + const newest = findNewest(results); + const prevUrl = createPaginationUrl(request, { min_id: newest }); // Previous page (newer) has IDs greater than the newest of this page + const prev = `<${prevUrl}>; rel="prev"`; + + // https://docs.joinmastodon.org/api/guidelines/#pagination + const link = `${next}, ${prev}`; + reply.header('link', link); +} + +/** + * Attaches Mastodon's pagination headers to a response that is paginated by limit / offset parameters. + * Results must be sorted, but can be in ascending or descending order. + * Attached headers will always be in descending order. + * + * @param request Fastify request object + * @param reply Fastify reply object + * @param results Results array, ordered in ascending or descending order + */ +export function attachOffsetPagination(request: FastifyRequest, reply: FastifyReply, results: unknown[]): void { + const links: string[] = []; + + // Find initial offset + const offset = findOffset(request); + const limit = findLimit(request); + + // "next" link - older results + if (hasItems(results)) { + const oldest = offset + results.length; + const nextUrl = createPaginationUrl(request, { offset: oldest }); // Next page (older) has entries less than the oldest of this page + links.push(`<${nextUrl}>; rel="next"`); + } + + // "prev" link - newer results + // We can only paginate backwards if a limit is specified + if (limit) { + // Make sure we don't cross below 0, as that will produce an API error + if (limit <= offset) { + const newest = offset - limit; + const prevUrl = createPaginationUrl(request, { offset: newest }); // Previous page (newer) has entries greater than the newest of this page + links.push(`<${prevUrl}>; rel="prev"`); + } else { + const prevUrl = createPaginationUrl(request, { offset: 0, limit: offset }); // Previous page (newer) has entries greater than the newest of this page + links.push(`<${prevUrl}>; rel="prev"`); + } + } + + // https://docs.joinmastodon.org/api/guidelines/#pagination + if (links.length > 0) { + const link = links.join(', '); + reply.header('link', link); + } +} + +function hasItems(items: T[]): items is [T, ...T[]] { + return items.length > 0; +} + +function findOffset(request: FastifyRequest): number { + if (typeof(request.query) !== 'object') return 0; + + const query = request.query as Record; + if (!query.offset) return 0; + + if (Array.isArray(query.offset)) { + const offsets = query.offset + .map(o => parseInt(o)) + .filter(o => !isNaN(o)); + const offset = Math.max(...offsets); + return isNaN(offset) ? 0 : offset; + } + + const offset = parseInt(query.offset); + return isNaN(offset) ? 0 : offset; +} + +function findLimit(request: FastifyRequest): number | null { + if (typeof(request.query) !== 'object') return null; + + const query = request.query as Record; + if (!query.limit) return null; + + if (Array.isArray(query.limit)) { + const limits = query.limit + .map(l => parseInt(l)) + .filter(l => !isNaN(l)); + const limit = Math.max(...limits); + return isNaN(limit) ? null : limit; + } + + const limit = parseInt(query.limit); + return isNaN(limit) ? null : limit; +} + +function findOldest(items: [AnyEntity, ...AnyEntity[]]): string { + const first = items[0].id; + const last = items[items.length - 1].id; + + return isOlder(first, last) ? first : last; +} + +function findNewest(items: [AnyEntity, ...AnyEntity[]]): string { + const first = items[0].id; + const last = items[items.length - 1].id; + + return isOlder(first, last) ? last : first; +} + +function isOlder(a: string, b: string): boolean { + if (a === b) return false; + + if (a.length !== b.length) { + return a.length < b.length; + } + + return a < b; +} + +function createPaginationUrl(request: FastifyRequest, data: { + min_id?: string; + max_id?: string; + offset?: number; + limit?: number; +}): string { + const baseUrl = getBaseUrl(request); + const requestUrl = new URL(request.url, baseUrl); + + // Remove any existing pagination + requestUrl.searchParams.delete('min_id'); + requestUrl.searchParams.delete('max_id'); + requestUrl.searchParams.delete('since_id'); + requestUrl.searchParams.delete('offset'); + + if (data.min_id) requestUrl.searchParams.set('min_id', data.min_id); + if (data.max_id) requestUrl.searchParams.set('max_id', data.max_id); + if (data.offset) requestUrl.searchParams.set('offset', String(data.offset)); + if (data.limit) requestUrl.searchParams.set('limit', String(data.limit)); + + return requestUrl.href; +} From 8d67a8c9ae0e10c9e0b261c456d414aece80f044 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 22 Mar 2025 18:16:48 -0400 Subject: [PATCH 20/37] don't log query parameters from mastodon API --- packages/backend/src/server/api/mastodon/MastodonLogger.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index 5412a27dc9..ed3bff5214 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -4,11 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; import Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import { ApiError } from '@/server/api/error.js'; import { EnvService } from '@/core/EnvService.js'; -import { FastifyRequest } from 'fastify'; +import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js'; @Injectable() export class MastodonLogger { @@ -25,7 +26,8 @@ export class MastodonLogger { public error(request: FastifyRequest, error: MastodonError, status: number): void { if ((status < 400 && status > 499) || this.envService.env.NODE_ENV === 'development') { - this.logger.error(`Error in mastodon endpoint ${request.method} ${request.url}:`, error); + const path = new URL(request.url, getBaseUrl(request)).pathname; + this.logger.error(`Error in mastodon endpoint ${request.method} ${path}:`, error); } } } From fbdee815dabd5444c9e6b0b91d2a9b5b21b1ca6e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 22 Mar 2025 18:18:10 -0400 Subject: [PATCH 21/37] remove unused async from toMastoApiHtml / fromMastoApiHtml --- packages/backend/src/core/MfmService.ts | 55 ++++++++++--------- .../src/server/api/mastodon/converters.ts | 19 +++---- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 6c2f673217..dcec71805e 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -179,7 +179,7 @@ export class MfmService { break; } - // this is here only to catch upstream changes! + // this is here only to catch upstream changes! case 'ruby--': { let ruby: [string, string][] = []; for (const child of node.childNodes) { @@ -584,9 +584,10 @@ export class MfmService { } // the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version + // additionally modified by hazelnoot to remove async @bindThis - public async toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) { + public toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) { if (nodes == null) { return null; } @@ -597,50 +598,50 @@ export class MfmService { const body = doc.createElement('p'); - async function appendChildren(children: mfm.MfmNode[], targetElement: any): Promise { + function appendChildren(children: mfm.MfmNode[], targetElement: any): void { if (children) { - for (const child of await Promise.all(children.map(async (x) => await (handlers as any)[x.type](x)))) targetElement.appendChild(child); + for (const child of children.map((x) => (handlers as any)[x.type](x))) targetElement.appendChild(child); } } const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any; } = { - async bold(node) { + bold(node) { const el = doc.createElement('span'); el.textContent = '**'; - await appendChildren(node.children, el); + appendChildren(node.children, el); el.textContent += '**'; return el; }, - async small(node) { + small(node) { const el = doc.createElement('small'); - await appendChildren(node.children, el); + appendChildren(node.children, el); return el; }, - async strike(node) { + strike(node) { const el = doc.createElement('span'); el.textContent = '~~'; - await appendChildren(node.children, el); + appendChildren(node.children, el); el.textContent += '~~'; return el; }, - async italic(node) { + italic(node) { const el = doc.createElement('span'); el.textContent = '*'; - await appendChildren(node.children, el); + appendChildren(node.children, el); el.textContent += '*'; return el; }, - async fn(node) { + fn(node) { switch (node.props.name) { case 'group': { // hack for ruby const el = doc.createElement('span'); - await appendChildren(node.children, el); + appendChildren(node.children, el); return el; } case 'ruby': { @@ -666,7 +667,7 @@ export class MfmService { if (!rt) { const el = doc.createElement('span'); - await appendChildren(node.children, el); + appendChildren(node.children, el); return el; } @@ -679,7 +680,7 @@ export class MfmService { const rpEndEl = doc.createElement('rp'); rpEndEl.appendChild(doc.createTextNode(')')); - await appendChildren(node.children.slice(0, node.children.length - 1), rubyEl); + appendChildren(node.children.slice(0, node.children.length - 1), rubyEl); rtEl.appendChild(doc.createTextNode(text.trim())); rubyEl.appendChild(rpStartEl); rubyEl.appendChild(rtEl); @@ -691,7 +692,7 @@ export class MfmService { default: { const el = doc.createElement('span'); el.textContent = '*'; - await appendChildren(node.children, el); + appendChildren(node.children, el); el.textContent += '*'; return el; } @@ -714,9 +715,9 @@ export class MfmService { return pre; }, - async center(node) { + center(node) { const el = doc.createElement('div'); - await appendChildren(node.children, el); + appendChildren(node.children, el); return el; }, @@ -755,16 +756,16 @@ export class MfmService { return el; }, - async link(node) { + link(node) { const a = doc.createElement('a'); a.setAttribute('rel', 'nofollow noopener noreferrer'); a.setAttribute('target', '_blank'); a.setAttribute('href', node.props.url); - await appendChildren(node.children, a); + appendChildren(node.children, a); return a; }, - async mention(node) { + mention(node) { const { username, host, acct } = node.props; const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); @@ -787,9 +788,9 @@ export class MfmService { return el; }, - async quote(node) { + quote(node) { const el = doc.createElement('blockquote'); - await appendChildren(node.children, el); + appendChildren(node.children, el); return el; }, @@ -822,14 +823,14 @@ export class MfmService { return a; }, - async plain(node) { + plain(node) { const el = doc.createElement('span'); - await appendChildren(node.children, el); + appendChildren(node.children, el); return el; }, }; - await appendChildren(nodes, body); + appendChildren(nodes, body); if (quoteUri !== null) { const a = doc.createElement('a'); diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 9d8e031831..0e468f9377 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -135,10 +135,10 @@ export class MastoConverters { }); } - private async encodeField(f: Entity.Field): Promise { + private encodeField(f: Entity.Field): MastodonEntity.Field { return { name: f.name, - value: await this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), + value: this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), verified_at: null, }; } @@ -186,7 +186,7 @@ export class MastoConverters { header_static: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png', emojis: emoji, moved: null, //FIXME - fields: Promise.all(profile?.fields.map(async p => this.encodeField(p)) ?? []), + fields: profile?.fields.map(p => this.encodeField(p)) ?? [], bot: user.isBot, discoverable: user.isExplorable, noindex: user.noindex, @@ -203,23 +203,23 @@ export class MastoConverters { } const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p)); const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } }); - const history: Promise[] = []; + const history: StatusEdit[] = []; // TODO this looks wrong, according to mastodon docs let lastDate = this.idService.parse(note.id).date; for (const edit of edits) { - const files = this.driveFileEntityService.packManyByIds(edit.fileIds); + const files = await this.driveFileEntityService.packManyByIds(edit.fileIds); const item = { account: noteUser, - content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''), + content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)) ?? '', created_at: lastDate.toISOString(), emojis: [], sensitive: edit.cw != null && edit.cw.length > 0, spoiler_text: edit.cw ?? '', - media_attachments: files.then(files => files.length > 0 ? files.map((f) => this.encodeFile(f)) : []), + media_attachments: files.length > 0 ? files.map((f) => this.encodeFile(f)) : [], }; lastDate = edit.updatedAt; - history.push(awaitAll(item)); + history.push(item); } return await Promise.all(history); @@ -275,8 +275,7 @@ export class MastoConverters { const text = note.text; const content = text !== null ? quoteUri - .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri)) - .then(p => p ?? escapeMFM(text)) + .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri) ?? escapeMFM(text)) : ''; const reblogged = await this.mastodonDataService.hasReblog(note.id, me); From 3c5468086047a29cc56c90ab6547484364c22326 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 22 Mar 2025 18:18:54 -0400 Subject: [PATCH 22/37] support reactions in mastodon API --- .../src/server/api/mastodon/converters.ts | 62 ++++++++++--------- .../api/mastodon/endpoints/notifications.ts | 23 +++---- packages/megalodon/src/entities/reaction.ts | 2 + .../src/mastodon/entities/reaction.ts | 16 +++++ .../megalodon/src/mastodon/entities/status.ts | 3 + packages/megalodon/src/mastodon/entity.ts | 1 + packages/megalodon/src/misskey.ts | 1 + packages/megalodon/src/misskey/api_client.ts | 28 ++++----- 8 files changed, 82 insertions(+), 54 deletions(-) create mode 100644 packages/megalodon/src/mastodon/entities/reaction.ts diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 0e468f9377..1adbd95642 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -180,10 +180,10 @@ export class MastoConverters { note: profile?.description ?? '', url: user.uri ?? acctUrl, uri: user.uri ?? acctUri, - avatar: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png', - avatar_static: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png', - header: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png', - header_static: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png', + avatar: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', + avatar_static: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', + header: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png', + header_static: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png', emojis: emoji, moved: null, //FIXME fields: profile?.fields.map(p => this.encodeField(p)) ?? [], @@ -196,7 +196,7 @@ export class MastoConverters { }); } - public async getEdits(id: string, me?: MiLocalUser | null): Promise { + public async getEdits(id: string, me: MiLocalUser | null): Promise { const note = await this.mastodonDataService.getNote(id, me); if (!note) { return []; @@ -213,7 +213,7 @@ export class MastoConverters { account: noteUser, content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)) ?? '', created_at: lastDate.toISOString(), - emojis: [], + emojis: [], //FIXME sensitive: edit.cw != null && edit.cw.length > 0, spoiler_text: edit.cw ?? '', media_attachments: files.length > 0 ? files.map((f) => this.encodeFile(f)) : [], @@ -222,15 +222,15 @@ export class MastoConverters { history.push(item); } - return await Promise.all(history); + return history; } - private async convertReblog(status: Entity.Status | null, me?: MiLocalUser | null): Promise { + private async convertReblog(status: Entity.Status | null, me: MiLocalUser | null): Promise { if (!status) return null; return await this.convertStatus(status, me); } - public async convertStatus(status: Entity.Status, me?: MiLocalUser | null): Promise { + public async convertStatus(status: Entity.Status, me: MiLocalUser | null): Promise { const convertedAccount = this.convertAccount(status.account); const note = await this.mastodonDataService.requireNote(status.id, me); const noteUser = await this.getUser(status.account.id); @@ -279,7 +279,6 @@ export class MastoConverters { : ''; const reblogged = await this.mastodonDataService.hasReblog(note.id, me); - const reactions = await Promise.all(status.emoji_reactions.map(r => this.convertReaction(r))); // noinspection ES6MissingAwait return await awaitAll({ @@ -289,11 +288,12 @@ export class MastoConverters { account: convertedAccount, in_reply_to_id: note.replyId, in_reply_to_account_id: note.replyUserId, - reblog: !isQuote ? await this.convertReblog(status.reblog, me) : null, + reblog: !isQuote ? this.convertReblog(status.reblog, me) : null, content: content, content_type: 'text/x.misskeymarkdown', text: note.text, created_at: status.created_at, + edited_at: note.updatedAt?.toISOString() ?? null, emojis: emoji, replies_count: note.repliesCount, reblogs_count: note.renoteCount, @@ -301,7 +301,7 @@ export class MastoConverters { reblogged, favourited: status.favourited, muted: status.muted, - sensitive: status.sensitive, + sensitive: status.sensitive || !!note.cw, spoiler_text: note.cw ?? '', visibility: status.visibility, media_attachments: status.media_attachments.map(a => convertAttachment(a)), @@ -312,15 +312,14 @@ export class MastoConverters { application: null, //FIXME language: null, //FIXME pinned: false, //FIXME - reactions, - emoji_reactions: reactions, bookmarked: false, //FIXME - quote: isQuote ? await this.convertReblog(status.reblog, me) : null, - edited_at: note.updatedAt?.toISOString() ?? null, + quote_id: isQuote ? status.reblog?.id : undefined, + quote: isQuote ? this.convertReblog(status.reblog, me) : null, + reactions: status.emoji_reactions, }); } - public async convertConversation(conversation: Entity.Conversation, me?: MiLocalUser | null): Promise { + public async convertConversation(conversation: Entity.Conversation, me: MiLocalUser | null): Promise { return { id: conversation.id, accounts: await Promise.all(conversation.accounts.map(a => this.convertAccount(a))), @@ -329,7 +328,7 @@ export class MastoConverters { }; } - public async convertNotification(notification: Entity.Notification, me?: MiLocalUser | null): Promise { + public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { return { account: await this.convertAccount(notification.account), created_at: notification.created_at, @@ -339,12 +338,23 @@ export class MastoConverters { }; } - public async convertReaction(reaction: Entity.Reaction): Promise { - if (reaction.accounts) { - reaction.accounts = await Promise.all(reaction.accounts.map(a => this.convertAccount(a))); - } - return reaction; - } + // public convertEmoji(emoji: string): MastodonEntity.Emoji { + // const reaction: MastodonEntity.Reaction = { + // name: emoji, + // count: 1, + // }; + // + // if (emoji.startsWith(':')) { + // const [, name] = emoji.match(/^:([^@:]+(?:@[^@:]+)?):$/) ?? []; + // if (name) { + // const url = `${this.config.url}/emoji/${name}.webp`; + // reaction.url = url; + // reaction.static_url = url; + // } + // } + // + // return reaction; + // } } function simpleConvert(data: T): T { @@ -423,7 +433,3 @@ export function convertRelationship(relationship: Partial & }; } -// noinspection JSUnusedGlobalSymbols -export function convertStatusSource(status: Entity.StatusSource): MastodonEntity.StatusSource { - return simpleConvert(status); -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 6acb9edd6b..120b9ba7f9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -29,13 +29,17 @@ export class ApiNotificationsMastodon { fastify.get('/v1/notifications', async (request, reply) => { const { client, me } = await this.clientService.getAuthClient(request); const data = await client.getNotifications(parseTimelineArgs(request.query)); - const response = await Promise.all(data.data.map(async n => { - const converted = await this.mastoConverters.convertNotification(n, me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; + const notifications = await Promise.all(data.data.map(n => this.mastoConverters.convertNotification(n, me))); + const response: MastodonEntity.Notification[] = []; + for (const notification of notifications) { + response.push(notification); + if (notification.type === 'reaction') { + response.push({ + ...notification, + type: 'favourite', + }); } - return converted; - })); + } attachMinMaxPagination(request, reply, response); reply.send(response); @@ -46,12 +50,9 @@ export class ApiNotificationsMastodon { const { client, me } = await this.clientService.getAuthClient(_request); const data = await client.getNotification(_request.params.id); - const converted = await this.mastoConverters.convertNotification(data.data, me); - if (converted.type === 'reaction') { - converted.type = 'favourite'; - } + const response = await this.mastoConverters.convertNotification(data.data, me); - reply.send(converted); + reply.send(response); }); fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { diff --git a/packages/megalodon/src/entities/reaction.ts b/packages/megalodon/src/entities/reaction.ts index 8c626f9e84..3315eded50 100644 --- a/packages/megalodon/src/entities/reaction.ts +++ b/packages/megalodon/src/entities/reaction.ts @@ -6,5 +6,7 @@ namespace Entity { me: boolean name: string accounts?: Array + url?: string + static_url?: string } } diff --git a/packages/megalodon/src/mastodon/entities/reaction.ts b/packages/megalodon/src/mastodon/entities/reaction.ts new file mode 100644 index 0000000000..370eeb5cbe --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/reaction.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/// + +namespace MastodonEntity { + export type Reaction = { + name: string + count: number + me?: boolean + url?: string + static_url?: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/status.ts b/packages/megalodon/src/mastodon/entities/status.ts index 54b5d3bfe3..76472a8580 100644 --- a/packages/megalodon/src/mastodon/entities/status.ts +++ b/packages/megalodon/src/mastodon/entities/status.ts @@ -6,6 +6,7 @@ /// /// /// +/// namespace MastodonEntity { export type Status = { @@ -41,6 +42,8 @@ namespace MastodonEntity { // These parameters are unique parameters in fedibird.com for quote. quote_id?: string quote?: Status | null + // These parameters are unique to glitch-soc for emoji reactions. + reactions?: Reaction[] } export type StatusTag = { diff --git a/packages/megalodon/src/mastodon/entity.ts b/packages/megalodon/src/mastodon/entity.ts index dcafdfe749..10a3aa71c4 100644 --- a/packages/megalodon/src/mastodon/entity.ts +++ b/packages/megalodon/src/mastodon/entity.ts @@ -22,6 +22,7 @@ /// /// /// +/// /// /// /// diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index dce0fb21b7..eb1e5824b8 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -2555,6 +2555,7 @@ export default class Misskey implements MegalodonInterface { })) } + // TODO implement public async getEmojiReaction(_id: string, _emoji: string): Promise> { return new Promise((_, reject) => { const err = new NoImplementedError('misskey does not support') diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index a9a592b28c..ce6c4aa6cc 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -286,6 +286,7 @@ namespace MisskeyAPI { plain_content: n.text ? n.text : null, created_at: n.createdAt, edited_at: n.updatedAt || null, + // TODO this is probably wrong emojis: mapEmojis(n.emojis).concat(mapReactionEmojis(n.reactionEmojis)), replies_count: n.repliesCount, reblogs_count: n.renoteCount, @@ -304,7 +305,7 @@ namespace MisskeyAPI { application: null, language: null, pinned: null, - emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.myReaction) : [], + emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.reactionEmojis, n.myReaction) : [], bookmarked: false, quote: n.renote && n.text ? note(n.renote, n.user.host ? n.user.host : host ? host : null) : null } @@ -334,23 +335,20 @@ namespace MisskeyAPI { ) : 0; }; - export const mapReactions = (r: { [key: string]: number }, myReaction?: string): Array => { + export const mapReactions = (r: { [key: string]: number }, e: Record, myReaction?: string): Array => { return Object.keys(r).map(key => { - if (myReaction && key === myReaction) { - return { - count: r[key], - me: true, - name: key - } - } - return { - count: r[key], - me: false, - name: key - } + const me = myReaction != null && key === myReaction; + return { + count: r[key], + me, + name: key, + url: e[key], + static_url: e[key], + } }) } + // TODO implement other properties const mapReactionEmojis = (r: { [key: string]: string }): Array => { return Object.keys(r).map(key => ({ shortcode: key, @@ -371,7 +369,7 @@ namespace MisskeyAPI { result.push({ count: 1, me: false, - name: e.type + name: e.type, }) } }) From 984be9e7aa9612fab000cf0f0d8daaf28f42e0ce Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 22 Mar 2025 18:43:08 -0400 Subject: [PATCH 23/37] enable local timeline in Phanpy clients --- packages/backend/src/server/api/mastodon/endpoints/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index 37f64979b4..1f08f0a3b0 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -54,7 +54,7 @@ export class ApiInstanceMastodon { title: this.meta.name || 'Sharkey', description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', email: instance.email || '', - version: `3.0.0 (compatible; Sharkey ${this.config.version})`, + version: `3.0.0 (compatible; Sharkey ${this.config.version}; like Akkoma)`, urls: instance.urls, stats: { user_count: instance.stats.user_count, From 4754942301552ae58fd2b8544eebb6d848102109 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 10:47:10 -0400 Subject: [PATCH 24/37] add additional required CORS headers for masto-api requests --- .../api/mastodon/MastodonApiServerService.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 517beb4f44..d7afc1254e 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -55,7 +55,22 @@ export class MastodonApiServerService { }); fastify.addHook('onRequest', (_, reply, done) => { + // Allow web-based clients to connect from other origins. reply.header('Access-Control-Allow-Origin', '*'); + + // Mastodon uses all types of request methods. + reply.header('Access-Control-Allow-Methods', '*'); + + // Allow web-based clients to access Link header - required for mastodon pagination. + // https://stackoverflow.com/a/54928828 + // https://docs.joinmastodon.org/api/guidelines/#pagination + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers + reply.header('Access-Control-Expose-Headers', 'Link'); + + // Cache to avoid extra pre-flight requests + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age + reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds + done(); }); From a81a00e94dfdf85348ce8f2d843675c93ab9f2f2 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 11:03:20 -0400 Subject: [PATCH 25/37] rename MastodonConverters.ts to matching naming scheme --- packages/backend/src/server/ServerModule.ts | 4 ++-- .../src/server/api/mastodon/MastodonApiServerService.ts | 4 ++-- .../api/mastodon/{converters.ts => MastodonConverters.ts} | 2 +- packages/backend/src/server/api/mastodon/endpoints/account.ts | 4 ++-- packages/backend/src/server/api/mastodon/endpoints/filter.ts | 2 +- .../backend/src/server/api/mastodon/endpoints/instance.ts | 4 ++-- .../src/server/api/mastodon/endpoints/notifications.ts | 4 ++-- packages/backend/src/server/api/mastodon/endpoints/search.ts | 4 ++-- packages/backend/src/server/api/mastodon/endpoints/status.ts | 4 ++-- .../backend/src/server/api/mastodon/endpoints/timeline.ts | 4 ++-- 10 files changed, 18 insertions(+), 18 deletions(-) rename packages/backend/src/server/api/mastodon/{converters.ts => MastodonConverters.ts} (99%) diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 5af41ddd9f..d217c49fa2 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -35,7 +35,7 @@ import { SignupApiService } from './api/SignupApiService.js'; import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; -import { MastoConverters } from './api/mastodon/converters.js'; +import { MastodonConverters } from './api/mastodon/MastodonConverters.js'; import { MastodonLogger } from './api/mastodon/MastodonLogger.js'; import { MastodonDataService } from './api/mastodon/MastodonDataService.js'; import { FeedService } from './web/FeedService.js'; @@ -113,7 +113,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j OpenApiServerService, MastodonApiServerService, OAuth2ProviderService, - MastoConverters, + MastodonConverters, MastodonLogger, MastodonDataService, MastodonClientService, diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index d7afc1254e..b289ad7135 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -21,7 +21,7 @@ import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; import { ApiError } from '@/server/api/error.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js'; -import { convertAnnouncement, convertAttachment, MastoConverters, convertRelationship } from './converters.js'; +import { convertAnnouncement, convertAttachment, MastodonConverters, convertRelationship } from './MastodonConverters.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @@ -31,7 +31,7 @@ export class MastodonApiServerService { @Inject(DI.config) private readonly config: Config, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly logger: MastodonLogger, private readonly clientService: MastodonClientService, private readonly apiAccountMastodon: ApiAccountMastodon, diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts similarity index 99% rename from packages/backend/src/server/api/mastodon/converters.ts rename to packages/backend/src/server/api/mastodon/MastodonConverters.ts index 1adbd95642..11ddcd23da 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -47,7 +47,7 @@ export const escapeMFM = (text: string): string => text .replace(/\r?\n/g, '
'); @Injectable() -export class MastoConverters { +export class MastodonConverters { constructor( @Inject(DI.config) private readonly config: Config, diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index f669b71efb..efb26ca53e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -10,7 +10,7 @@ import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; -import { MastoConverters, convertRelationship, convertFeaturedTag, convertList } from '../converters.js'; +import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js'; import type multer from 'fastify-multer'; import type { FastifyInstance } from 'fastify'; @@ -30,7 +30,7 @@ export class ApiAccountMastodon { private readonly accessTokensRepository: AccessTokensRepository, private readonly clientService: MastodonClientService, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly driveService: DriveService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index d02ddd1999..deac1e9aad 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; -import { convertFilter } from '../converters.js'; +import { convertFilter } from '../MastodonConverters.js'; import type { FastifyInstance } from 'fastify'; import type multer from 'fastify-multer'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index 1f08f0a3b0..d6ee92b466 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -9,7 +9,7 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type { MiMeta, UsersRepository } from '@/models/_.js'; -import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { RoleService } from '@/core/RoleService.js'; import type { FastifyInstance } from 'fastify'; @@ -27,7 +27,7 @@ export class ApiInstanceMastodon { @Inject(DI.config) private readonly config: Config, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, private readonly roleService: RoleService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 120b9ba7f9..c81b3ca236 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js'; -import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonClientService } from '../MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; @@ -21,7 +21,7 @@ interface ApiNotifyMastodonRoute { @Injectable() export class ApiNotificationsMastodon { constructor( - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 997a585077..7277a35220 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js'; -import { MastoConverters } from '../converters.js'; +import { MastodonConverters } from '../MastodonConverters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; import Account = Entity.Account; import Status = Entity.Status; @@ -23,7 +23,7 @@ interface ApiSearchMastodonRoute { @Injectable() export class ApiSearchMastodon { constructor( - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index e64df3d74c..ea796e4f0b 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -8,7 +8,7 @@ import { Injectable } from '@nestjs/common'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; -import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; +import { convertAttachment, convertPoll, MastodonConverters } from '../MastodonConverters.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -20,7 +20,7 @@ function normalizeQuery(data: Record) { @Injectable() export class ApiStatusMastodon { constructor( - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, private readonly clientService: MastodonClientService, ) {} diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index a333e77c3e..b6162d9eb2 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; -import { convertList, MastoConverters } from '../converters.js'; +import { convertList, MastodonConverters } from '../MastodonConverters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; @@ -15,7 +15,7 @@ import type { FastifyInstance } from 'fastify'; export class ApiTimelineMastodon { constructor( private readonly clientService: MastodonClientService, - private readonly mastoConverters: MastoConverters, + private readonly mastoConverters: MastodonConverters, ) {} public register(fastify: FastifyInstance): void { From 971bc6fd3e58ac9e56fd4d507063fa60fa1e58e7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 12:20:18 -0400 Subject: [PATCH 26/37] improve mastodon API error handling --- .../src/server/api/mastodon/MastodonLogger.ts | 90 +++++++++---------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index ed3bff5214..57cf876dff 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -39,48 +39,50 @@ export interface MastodonError { } export function getErrorData(error: unknown): MastodonError { + // Axios wraps errors from the backend + error = unpackAxiosError(error); + + if (!error || typeof(error) !== 'object') { + return { + error: 'UNKNOWN_ERROR', + error_description: String(error), + }; + } + + if (error instanceof ApiError) { + return convertApiError(error); + } + + if ('code' in error && typeof (error.code) === 'string') { + if ('message' in error && typeof (error.message) === 'string') { + return convertApiError(error as ApiError); + } + } + + if (error instanceof Error) { + return convertGenericError(error); + } + + return convertUnknownError(error); +} + +function unpackAxiosError(error: unknown): unknown { if (error && typeof(error) === 'object') { - // AxiosError, comes from the backend - if ('response' in error) { - if (typeof(error.response) === 'object' && error.response) { - if ('data' in error.response) { - if (typeof(error.response.data) === 'object' && error.response.data) { - if ('error' in error.response.data) { - if (typeof(error.response.data.error) === 'object' && error.response.data.error) { - if ('code' in error.response.data.error) { - if (typeof(error.response.data.error.code) === 'string') { - return convertApiError(error.response.data.error as ApiError); - } - } - - return convertUnknownError(error.response.data.error); - } - } - - return convertUnknownError(error.response.data); - } + if ('response' in error && error.response && typeof (error.response) === 'object') { + if ('data' in error.response && error.response.data && typeof (error.response.data) === 'object') { + if ('error' in error.response.data && error.response.data.error && typeof(error.response.data.error) === 'object') { + return error.response.data.error; } + + return error.response.data; } // No data - this is a fallback to avoid leaking request/response details in the error - return convertUnknownError(); + return undefined; } - - if (error instanceof ApiError) { - return convertApiError(error); - } - - if (error instanceof Error) { - return convertGenericError(error); - } - - return convertUnknownError(error); } - return { - error: 'UNKNOWN_ERROR', - error_description: String(error), - }; + return error; } function convertApiError(apiError: ApiError): MastodonError { @@ -121,21 +123,17 @@ function convertGenericError(error: Error): MastodonError { } export function getErrorStatus(error: unknown): number { - // AxiosError, comes from the backend - if (typeof(error) === 'object' && error) { - if ('response' in error) { - if (typeof (error.response) === 'object' && error.response) { - if ('status' in error.response) { - if (typeof(error.response.status) === 'number') { - return error.response.status; - } - } + if (error && typeof(error) === 'object') { + // Axios wraps errors from the backend + if ('response' in error && typeof (error.response) === 'object' && error.response) { + if ('status' in error.response && typeof(error.response.status) === 'number') { + return error.response.status; } } - } - if (error instanceof ApiError && error.httpStatusCode) { - return error.httpStatusCode; + if ('httpStatusCode' in error && typeof(error.httpStatusCode) === 'number') { + return error.httpStatusCode; + } } return 500; From 1fa290c3ebf5aba4f2f81e54a847dc03c3b72b56 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 12:20:31 -0400 Subject: [PATCH 27/37] handle errors in mastodon search endpoints --- .../server/api/mastodon/endpoints/search.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 7277a35220..796f4cd5f7 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -8,6 +8,7 @@ import { MastodonClientService } from '@/server/api/mastodon/MastodonClientServi import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonConverters } from '../MastodonConverters.js'; import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js'; +import { ApiError } from '../../error.js'; import Account = Entity.Account; import Status = Entity.Status; import type { FastifyInstance } from 'fastify'; @@ -118,6 +119,9 @@ export class ApiSearchMastodon { }, body: '{}', }); + + await verifyResponse(res); + const data = await res.json() as Status[]; const me = await this.clientService.getAuth(request); const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); @@ -143,6 +147,9 @@ export class ApiSearchMastodon { state: 'alive', }), }); + + await verifyResponse(res); + const data = await res.json() as Account[]; const response = await Promise.all(data.map(async entry => { return { @@ -156,3 +163,29 @@ export class ApiSearchMastodon { }); } } + +async function verifyResponse(res: Response): Promise { + if (res.ok) return; + + const text = await res.text(); + + if (res.headers.get('content-type') === 'application/json') { + try { + const json = JSON.parse(text); + + if (json && typeof(json) === 'object') { + json.httpStatusCode = res.status; + return json; + } + } catch { /* ignore */ } + } + + // Response is not a JSON object; treat as string + throw new ApiError({ + code: 'INTERNAL_ERROR', + message: text || 'Internal error occurred. Please contact us if the error persists.', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + kind: 'server', + httpStatusCode: res.status, + }); +} From 81f7346f80467e7e715fba18489342079a51088c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 13:01:19 -0400 Subject: [PATCH 28/37] fixes to CW and quote conversion for mastodon --- .../src/misc/append-content-warning.ts | 7 +++- .../server/api/mastodon/MastodonConverters.ts | 38 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/misc/append-content-warning.ts b/packages/backend/src/misc/append-content-warning.ts index 152cd6760e..9f61776b1d 100644 --- a/packages/backend/src/misc/append-content-warning.ts +++ b/packages/backend/src/misc/append-content-warning.ts @@ -14,10 +14,13 @@ * @param additional Content warning to append * @param reverse If true, then the additional CW will be prepended instead of appended. */ -export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string { +export function appendContentWarning(original: string | null | undefined, additional: string, reverse?: boolean): string; +export function appendContentWarning(original: string, additional: string | null | undefined, reverse?: boolean): string; +export function appendContentWarning(original: string | null | undefined, additional: string | null | undefined, reverse?: boolean): string | null; +export function appendContentWarning(original: string | null | undefined, additional: string | null | undefined, reverse = false): string | null { // Easy case - if original is empty, then additional replaces it. if (!original) { - return additional; + return additional ?? null; } // Easy case - if the additional CW is empty, then don't append it. diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index 11ddcd23da..05bf4f6c4d 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -19,6 +19,8 @@ import { IdService } from '@/core/IdService.js'; import type { Packed } from '@/misc/json-schema.js'; import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { appendContentWarning } from '@/misc/append-content-warning.js'; +import { isRenote } from '@/misc/is-renote.js'; // Missing from Megalodon apparently // https://docs.joinmastodon.org/entities/StatusEdit/ @@ -201,21 +203,37 @@ export class MastodonConverters { if (!note) { return []; } - const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p)); + + const noteUser = await this.getUser(note.userId); + const account = await this.convertAccount(noteUser); const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } }); const history: StatusEdit[] = []; + const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers); + const renote = isRenote(note) ? await this.mastodonDataService.requireNote(note.renoteId, me) : null; + // TODO this looks wrong, according to mastodon docs let lastDate = this.idService.parse(note.id).date; + for (const edit of edits) { + // TODO avoid re-packing files for each edit const files = await this.driveFileEntityService.packManyByIds(edit.fileIds); + + const cwMFM = appendContentWarning(edit.cw, noteUser.mandatoryCW); + const cw = (cwMFM && this.mfmService.toMastoApiHtml(mfm.parse(cwMFM), mentionedRemoteUsers, true)) ?? ''; + + const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId); + const quoteUri = isQuote + ? renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}` + : null; + const item = { - account: noteUser, - content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)) ?? '', + account: account, + content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), mentionedRemoteUsers, false, quoteUri) ?? '', created_at: lastDate.toISOString(), emojis: [], //FIXME - sensitive: edit.cw != null && edit.cw.length > 0, - spoiler_text: edit.cw ?? '', + sensitive: !!cw, + spoiler_text: cw, media_attachments: files.length > 0 ? files.map((f) => this.encodeFile(f)) : [], }; lastDate = edit.updatedAt; @@ -274,10 +292,12 @@ export class MastodonConverters { const text = note.text; const content = text !== null - ? quoteUri - .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri) ?? escapeMFM(text)) + ? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text)) : ''; + const cwMFM = appendContentWarning(note.cw, noteUser.mandatoryCW); + const cw = (cwMFM && this.mfmService.toMastoApiHtml(mfm.parse(cwMFM), mentionedRemoteUsers, true)) ?? ''; + const reblogged = await this.mastodonDataService.hasReblog(note.id, me); // noinspection ES6MissingAwait @@ -301,8 +321,8 @@ export class MastodonConverters { reblogged, favourited: status.favourited, muted: status.muted, - sensitive: status.sensitive || !!note.cw, - spoiler_text: note.cw ?? '', + sensitive: status.sensitive || !!cw, + spoiler_text: cw, visibility: status.visibility, media_attachments: status.media_attachments.map(a => convertAttachment(a)), mentions: mentions, From 36dee5ff206a6567f2c6f28998af2660d07de417 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 13:14:02 -0400 Subject: [PATCH 29/37] render profile bios in masto API --- .../backend/src/server/api/mastodon/MastodonConverters.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index 05bf4f6c4d..2fbbbbb7af 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -168,6 +168,9 @@ export class MastodonConverters { acct = `${user.username}@${user.host}`; acctUrl = `https://${user.host}/@${user.username}`; } + + const bioText = profile?.description && this.mfmService.toMastoApiHtml(mfm.parse(profile.description)); + return awaitAll({ id: account.id, username: user.username, @@ -179,7 +182,7 @@ export class MastodonConverters { followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0, following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0, statuses_count: user.notesCount, - note: profile?.description ?? '', + note: bioText ?? '', url: user.uri ?? acctUrl, uri: user.uri ?? acctUri, avatar: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png', From ebc3abea5463fbb70bacb54b6c3df6c0fcad2a9c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 13:27:19 -0400 Subject: [PATCH 30/37] hide sensitive content from Discord previews --- .../backend/src/server/api/mastodon/endpoints/status.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index ea796e4f0b..39c4f44755 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -32,6 +32,12 @@ export class ApiStatusMastodon { const data = await client.getStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); + // Fixup - Discord ignores CWs and renders the entire post. + if (response.sensitive && _request.headers['user-agent']?.match(/\bDiscordbot\//)) { + response.content = '(preview disabled for sensitive content)'; + response.media_attachments = []; + } + reply.send(response); }); From dcdc249e77d9a8402761e594d34f748208006ab2 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 13:47:31 -0400 Subject: [PATCH 31/37] fix reaction emoji mapping in mastodon API --- packages/megalodon/src/misskey/api_client.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index ce6c4aa6cc..4c975c4dfa 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -336,14 +336,18 @@ namespace MisskeyAPI { }; export const mapReactions = (r: { [key: string]: number }, e: Record, myReaction?: string): Array => { - return Object.keys(r).map(key => { + return Object.entries(r).map(([key, count]) => { const me = myReaction != null && key === myReaction; + + // Translate the emoji name - "r" mapping includes a leading/trailing ":" + const [,name] = key.match(/^:([^@:]+(?:@[^:]+)?):$/) ?? [null,key]; + return { - count: r[key], + count, me, - name: key, - url: e[key], - static_url: e[key], + name, + url: e[name], + static_url: e[name], } }) } From 8a9979b3d305e9ec85d89efea0aca8cf98a32eae Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 13:59:28 -0400 Subject: [PATCH 32/37] don't render CW as HTML for mastodon --- .../backend/src/server/api/mastodon/MastodonConverters.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index 2fbbbbb7af..7a1387e4aa 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -222,8 +222,7 @@ export class MastodonConverters { // TODO avoid re-packing files for each edit const files = await this.driveFileEntityService.packManyByIds(edit.fileIds); - const cwMFM = appendContentWarning(edit.cw, noteUser.mandatoryCW); - const cw = (cwMFM && this.mfmService.toMastoApiHtml(mfm.parse(cwMFM), mentionedRemoteUsers, true)) ?? ''; + const cw = appendContentWarning(edit.cw, noteUser.mandatoryCW) ?? ''; const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId); const quoteUri = isQuote @@ -298,8 +297,7 @@ export class MastodonConverters { ? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text)) : ''; - const cwMFM = appendContentWarning(note.cw, noteUser.mandatoryCW); - const cw = (cwMFM && this.mfmService.toMastoApiHtml(mfm.parse(cwMFM), mentionedRemoteUsers, true)) ?? ''; + const cw = appendContentWarning(note.cw, noteUser.mandatoryCW) ?? ''; const reblogged = await this.mastodonDataService.hasReblog(note.id, me); From 58cdee77d5d7a8dceef39683695d33947a614070 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 14:26:25 -0400 Subject: [PATCH 33/37] convert notification types in mastodon API --- .../server/api/mastodon/MastodonConverters.ts | 35 +++++++------- packages/megalodon/src/index.ts | 2 +- .../megalodon/src/mastodon/notification.ts | 33 +++++++++++++ packages/megalodon/src/misskey/api_client.ts | 3 +- packages/megalodon/src/notification.ts | 48 ++++++++++++------- .../test/integration/misskey.spec.ts | 2 +- .../test/unit/misskey/api_client.spec.ts | 2 +- 7 files changed, 84 insertions(+), 41 deletions(-) create mode 100644 packages/megalodon/src/mastodon/notification.ts diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index 7a1387e4aa..0e8ce5a2a7 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -6,6 +6,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Entity } from 'megalodon'; import mfm from '@transfem-org/sfm-js'; +import { MastodonNotificationType } from 'megalodon/lib/src/mastodon/notification.js'; +import { NotificationType } from 'megalodon/lib/src/notification.js'; import { DI } from '@/di-symbols.js'; import { MfmService } from '@/core/MfmService.js'; import type { Config } from '@/config.js'; @@ -355,27 +357,9 @@ export class MastodonConverters { created_at: notification.created_at, id: notification.id, status: notification.status ? await this.convertStatus(notification.status, me) : undefined, - type: notification.type, + type: convertNotificationType(notification.type as NotificationType), }; } - - // public convertEmoji(emoji: string): MastodonEntity.Emoji { - // const reaction: MastodonEntity.Reaction = { - // name: emoji, - // count: 1, - // }; - // - // if (emoji.startsWith(':')) { - // const [, name] = emoji.match(/^:([^@:]+(?:@[^@:]+)?):$/) ?? []; - // if (name) { - // const url = `${this.config.url}/emoji/${name}.webp`; - // reaction.url = url; - // reaction.static_url = url; - // } - // } - // - // return reaction; - // } } function simpleConvert(data: T): T { @@ -383,6 +367,19 @@ function simpleConvert(data: T): T { return Object.assign({}, data); } +function convertNotificationType(type: NotificationType): MastodonNotificationType { + switch (type) { + case 'emoji_reaction': return 'reaction'; + case 'poll_vote': + case 'poll_expired': + return 'poll'; + // Not supported by mastodon + case 'move': + return type as MastodonNotificationType; + default: return type; + } +} + export function convertAnnouncement(announcement: Entity.Announcement): MastodonEntity.Announcement { return { ...announcement, diff --git a/packages/megalodon/src/index.ts b/packages/megalodon/src/index.ts index 621f007ccf..7a4f10ab02 100644 --- a/packages/megalodon/src/index.ts +++ b/packages/megalodon/src/index.ts @@ -6,7 +6,7 @@ import { MegalodonInterface, WebSocketInterface } from './megalodon' import { detector } from './detector' import Misskey from './misskey' import Entity from './entity' -import NotificationType from './notification' +import * as NotificationType from './notification' import FilterContext from './filter_context' import Converter from './converter' import MastodonEntity from './mastodon/entity'; diff --git a/packages/megalodon/src/mastodon/notification.ts b/packages/megalodon/src/mastodon/notification.ts new file mode 100644 index 0000000000..9c51f9698d --- /dev/null +++ b/packages/megalodon/src/mastodon/notification.ts @@ -0,0 +1,33 @@ +export const Mention = 'mention' as const; +export const Reblog = 'reblog' as const; +export const Favourite = 'favourite' as const; +export const Follow = 'follow' as const; +export const Poll = 'poll' as const; +export const FollowRequest = 'follow_request' as const; +export const Status = 'status' as const; +export const Update = 'update' as const; +export const AdminSignup = 'admin.sign_up' as const; +export const AdminReport = 'admin.report' as const; +export const Reaction = 'reaction' as const; +export const ModerationWarning = 'moderation_warning' as const; +export const SeveredRelationships = 'severed_relationships' as const; +export const AnnualReport = 'annual_report' as const; + +export const mastodonNotificationTypes = [ + Mention, + Reblog, + Favourite, + Follow, + Poll, + FollowRequest, + Status, + Update, + AdminSignup, + AdminReport, + Reaction, + ModerationWarning, + SeveredRelationships, + AnnualReport, +]; + +export type MastodonNotificationType = typeof mastodonNotificationTypes[number]; diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index 4c975c4dfa..6dfa59f132 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -9,7 +9,8 @@ import MisskeyEntity from './entity' import MegalodonEntity from '../entity' import WebSocket from './web_socket' import MisskeyNotificationType from './notification' -import NotificationType, { UnknownNotificationTypeError } from '../notification' +import * as NotificationType from '../notification' +import { UnknownNotificationTypeError } from '../notification'; namespace MisskeyAPI { export namespace Entity { diff --git a/packages/megalodon/src/notification.ts b/packages/megalodon/src/notification.ts index 7c08c5d47f..846d79c6d7 100644 --- a/packages/megalodon/src/notification.ts +++ b/packages/megalodon/src/notification.ts @@ -1,20 +1,16 @@ -import Entity from './entity' - -namespace NotificationType { - export const Follow: Entity.NotificationType = 'follow' - export const Favourite: Entity.NotificationType = 'favourite' - export const Reblog: Entity.NotificationType = 'reblog' - export const Mention: Entity.NotificationType = 'mention' - export const EmojiReaction: Entity.NotificationType = 'emoji_reaction' - export const FollowRequest: Entity.NotificationType = 'follow_request' - export const Status: Entity.NotificationType = 'status' - export const PollVote: Entity.NotificationType = 'poll_vote' - export const PollExpired: Entity.NotificationType = 'poll_expired' - export const Update: Entity.NotificationType = 'update' - export const Move: Entity.NotificationType = 'move' - export const AdminSignup: Entity.NotificationType = 'admin.sign_up' - export const AdminReport: Entity.NotificationType = 'admin.report' -} +export const Follow = 'follow' as const; +export const Favourite = 'favourite' as const; +export const Reblog = 'reblog' as const; +export const Mention = 'mention' as const; +export const EmojiReaction = 'emoji_reaction' as const; +export const FollowRequest = 'follow_request' as const; +export const Status = 'status' as const; +export const PollVote = 'poll_vote' as const; +export const PollExpired = 'poll_expired' as const; +export const Update = 'update' as const; +export const Move = 'move' as const; +export const AdminSignup = 'admin.sign_up' as const; +export const AdminReport = 'admin.report' as const; export class UnknownNotificationTypeError extends Error { constructor() { @@ -23,4 +19,20 @@ export class UnknownNotificationTypeError extends Error { } } -export default NotificationType +export const notificationTypes = [ + Follow, + Favourite, + Reblog, + Mention, + EmojiReaction, + FollowRequest, + Status, + PollVote, + PollExpired, + Update, + Move, + AdminSignup, + AdminReport, +]; + +export type NotificationType = typeof notificationTypes[number]; diff --git a/packages/megalodon/test/integration/misskey.spec.ts b/packages/megalodon/test/integration/misskey.spec.ts index ed3b9a40f2..84d85498d6 100644 --- a/packages/megalodon/test/integration/misskey.spec.ts +++ b/packages/megalodon/test/integration/misskey.spec.ts @@ -1,7 +1,7 @@ import MisskeyEntity from '@/misskey/entity' import MisskeyNotificationType from '@/misskey/notification' import Misskey from '@/misskey' -import MegalodonNotificationType from '@/notification' +import * as MegalodonNotificationType from '@/notification' import axios, { AxiosHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios' jest.mock('axios') diff --git a/packages/megalodon/test/unit/misskey/api_client.spec.ts b/packages/megalodon/test/unit/misskey/api_client.spec.ts index 38039385cb..ab40bab6c2 100644 --- a/packages/megalodon/test/unit/misskey/api_client.spec.ts +++ b/packages/megalodon/test/unit/misskey/api_client.spec.ts @@ -1,7 +1,7 @@ import MisskeyAPI from '@/misskey/api_client' import MegalodonEntity from '@/entity' import MisskeyEntity from '@/misskey/entity' -import MegalodonNotificationType from '@/notification' +import * as MegalodonNotificationType from '@/notification' import MisskeyNotificationType from '@/misskey/notification' const user: MisskeyEntity.User = { From 876ecb28f03ddcfee504208f1aa0255f475be999 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 24 Mar 2025 15:54:59 -0400 Subject: [PATCH 34/37] strip "@." from local reaction names --- packages/megalodon/src/misskey/api_client.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index 6dfa59f132..4d97ae497c 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -340,8 +340,21 @@ namespace MisskeyAPI { return Object.entries(r).map(([key, count]) => { const me = myReaction != null && key === myReaction; - // Translate the emoji name - "r" mapping includes a leading/trailing ":" - const [,name] = key.match(/^:([^@:]+(?:@[^:]+)?):$/) ?? [null,key]; + // Name is equal to the key for native emoji reactions, and as a fallback. + let name = key; + + // Custom emoji have a leading / trailing ":", which we need to remove. + const match = key.match(/^:([^@:]+)(@[^:]+)?:$/); + if (match) { + const [, prefix, host] = match; + + // Local custom emoji end in "@.", which we need to remove. + if (host && host !== '@.') { + name = prefix + host; + } else { + name = prefix; + } + } return { count, From a92416904f32ad0595556be3a6baf1137bdfb791 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Mar 2025 20:20:42 -0400 Subject: [PATCH 35/37] use exclusive ranges in api/i/notifications and /api/v1/notifications --- packages/backend/src/server/api/endpoints/i/notifications.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 5e97b90f99..3c1e43303c 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -84,8 +84,8 @@ export default class extends Endpoint { // eslint- const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null; - let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null; + let sinceTime = ps.sinceId ? (this.idService.parse(ps.sinceId).date.getTime() + 1).toString() : null; + let untilTime = ps.untilId ? (this.idService.parse(ps.untilId).date.getTime() - 1).toString() : null; let notifications: MiNotification[]; for (;;) { From 848a07a170322ec18cfcd6b6dd4c1e372656737f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Mar 2025 20:30:04 -0400 Subject: [PATCH 36/37] Ignore notifications that reference missing notes --- .../src/server/api/mastodon/MastodonConverters.ts | 13 +++++++++++-- .../src/server/api/mastodon/MastodonLogger.ts | 2 +- .../server/api/mastodon/endpoints/notifications.ts | 10 ++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index 0e8ce5a2a7..e5d732ed79 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -351,12 +351,21 @@ export class MastodonConverters { }; } - public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { + public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise { + const status = notification.status + ? await this.convertStatus(notification.status, me).catch(() => null) + : null; + + // We sometimes get notifications for inaccessible notes, these should be ignored. + if (!status) { + return null; + } + return { account: await this.convertAccount(notification.account), created_at: notification.created_at, id: notification.id, - status: notification.status ? await this.convertStatus(notification.status, me) : undefined, + status, type: convertNotificationType(notification.type as NotificationType), }; } diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index 57cf876dff..81d3e8f03d 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -35,7 +35,7 @@ export class MastodonLogger { // TODO move elsewhere export interface MastodonError { error: string; - error_description: string; + error_description?: string; } export function getErrorData(error: unknown): MastodonError { diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index c81b3ca236..c3108c8b3e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -32,6 +32,9 @@ export class ApiNotificationsMastodon { const notifications = await Promise.all(data.data.map(n => this.mastoConverters.convertNotification(n, me))); const response: MastodonEntity.Notification[] = []; for (const notification of notifications) { + // Notifications for inaccessible notes will be null and should be ignored + if (!notification) continue; + response.push(notification); if (notification.type === 'reaction') { response.push({ @@ -52,6 +55,13 @@ export class ApiNotificationsMastodon { const data = await client.getNotification(_request.params.id); const response = await this.mastoConverters.convertNotification(data.data, me); + // Notifications for inaccessible notes will be null and should be ignored + if (!response) { + return reply.code(404).send({ + error: 'NOT_FOUND', + }); + } + reply.send(response); }); From 6dc3c36ba52f71c3888bb9a8dafcfa9e0b032209 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Mar 2025 20:39:23 -0400 Subject: [PATCH 37/37] fix megalodon tests --- packages/megalodon/test/unit/misskey/api_client.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/megalodon/test/unit/misskey/api_client.spec.ts b/packages/megalodon/test/unit/misskey/api_client.spec.ts index ab40bab6c2..96e7122ea6 100644 --- a/packages/megalodon/test/unit/misskey/api_client.spec.ts +++ b/packages/megalodon/test/unit/misskey/api_client.spec.ts @@ -269,12 +269,16 @@ describe('api_client', () => { { count: 1, me: false, - name: ':example1@.:' + name: 'example1', + static_url: undefined, + url: undefined, }, { count: 2, me: false, - name: ':example2@example.com:' + name: 'example2@example.com', + static_url: 'https://example.com/emoji.png', + url: 'https://example.com/emoji.png', } ]) })