From 2375d043d16ddd099efdc9a56c075cb9ea580b66 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sat, 23 Sep 2023 18:49:47 +0200 Subject: [PATCH 01/31] add: Megalodon, initial mastodon api --- .gitignore | 3 + Dockerfile | 1 + packages/backend/package.json | 2 + packages/backend/src/server/ServerModule.ts | 2 + packages/backend/src/server/ServerService.ts | 3 + .../api/mastodon/MastodonApiServerService.ts | 192 + .../src/server/api/mastodon/converters.ts | 136 + .../src/server/api/mastodon/endpoints/meta.ts | 63 + packages/megalodon/package.json | 83 + packages/megalodon/src/axios.d.ts | 1 + packages/megalodon/src/cancel.ts | 13 + packages/megalodon/src/converter.ts | 3 + packages/megalodon/src/default.ts | 3 + packages/megalodon/src/entities/account.ts | 27 + packages/megalodon/src/entities/activity.ts | 8 + .../megalodon/src/entities/announcement.ts | 34 + .../megalodon/src/entities/application.ts | 7 + .../src/entities/async_attachment.ts | 14 + packages/megalodon/src/entities/attachment.ts | 49 + packages/megalodon/src/entities/card.ts | 16 + packages/megalodon/src/entities/context.ts | 8 + .../megalodon/src/entities/conversation.ts | 11 + packages/megalodon/src/entities/emoji.ts | 9 + .../megalodon/src/entities/featured_tag.ts | 8 + packages/megalodon/src/entities/field.ts | 7 + packages/megalodon/src/entities/filter.ts | 12 + packages/megalodon/src/entities/history.ts | 7 + .../megalodon/src/entities/identity_proof.ts | 9 + packages/megalodon/src/entities/instance.ts | 41 + packages/megalodon/src/entities/list.ts | 6 + packages/megalodon/src/entities/marker.ts | 15 + packages/megalodon/src/entities/mention.ts | 8 + .../megalodon/src/entities/notification.ts | 15 + packages/megalodon/src/entities/poll.ts | 14 + .../megalodon/src/entities/poll_option.ts | 6 + .../megalodon/src/entities/preferences.ts | 9 + .../src/entities/push_subscription.ts | 16 + packages/megalodon/src/entities/reaction.ts | 12 + .../megalodon/src/entities/relationship.ts | 17 + packages/megalodon/src/entities/report.ts | 9 + packages/megalodon/src/entities/results.ts | 11 + .../src/entities/scheduled_status.ts | 10 + packages/megalodon/src/entities/source.ts | 10 + packages/megalodon/src/entities/stats.ts | 7 + packages/megalodon/src/entities/status.ts | 45 + .../megalodon/src/entities/status_edit.ts | 23 + .../megalodon/src/entities/status_params.ts | 12 + packages/megalodon/src/entities/tag.ts | 10 + packages/megalodon/src/entities/token.ts | 8 + packages/megalodon/src/entities/urls.ts | 5 + packages/megalodon/src/entity.ts | 38 + packages/megalodon/src/filter_context.ts | 11 + packages/megalodon/src/index.ts | 32 + packages/megalodon/src/megalodon.ts | 1532 ++++++++ packages/megalodon/src/misskey.ts | 3436 +++++++++++++++++ packages/megalodon/src/misskey/api_client.ts | 727 ++++ .../megalodon/src/misskey/entities/GetAll.ts | 6 + .../src/misskey/entities/announcement.ts | 10 + .../megalodon/src/misskey/entities/app.ts | 9 + .../src/misskey/entities/blocking.ts | 10 + .../src/misskey/entities/createdNote.ts | 7 + .../megalodon/src/misskey/entities/emoji.ts | 9 + .../src/misskey/entities/favorite.ts | 10 + .../megalodon/src/misskey/entities/field.ts | 7 + .../megalodon/src/misskey/entities/file.ts | 20 + .../src/misskey/entities/followRequest.ts | 9 + .../src/misskey/entities/follower.ts | 11 + .../src/misskey/entities/following.ts | 11 + .../megalodon/src/misskey/entities/hashtag.ts | 7 + .../megalodon/src/misskey/entities/list.ts | 8 + .../megalodon/src/misskey/entities/meta.ts | 18 + .../megalodon/src/misskey/entities/mute.ts | 10 + .../megalodon/src/misskey/entities/note.ts | 32 + .../src/misskey/entities/notification.ts | 17 + .../megalodon/src/misskey/entities/poll.ts | 13 + .../src/misskey/entities/reaction.ts | 11 + .../src/misskey/entities/relation.ts | 12 + .../megalodon/src/misskey/entities/session.ts | 6 + .../megalodon/src/misskey/entities/state.ts | 7 + .../megalodon/src/misskey/entities/stats.ts | 9 + .../megalodon/src/misskey/entities/user.ts | 13 + .../src/misskey/entities/userDetail.ts | 34 + .../src/misskey/entities/userDetailMe.ts | 36 + .../megalodon/src/misskey/entities/userkey.ts | 8 + packages/megalodon/src/misskey/entity.ts | 28 + .../megalodon/src/misskey/notification.ts | 18 + packages/megalodon/src/misskey/web_socket.ts | 458 +++ packages/megalodon/src/notification.ts | 14 + packages/megalodon/src/oauth.ts | 123 + packages/megalodon/src/parser.ts | 94 + packages/megalodon/src/proxy_config.ts | 92 + packages/megalodon/src/response.ts | 8 + .../test/integration/megalodon.spec.ts | 27 + .../test/integration/misskey.spec.ts | 204 + .../test/unit/misskey/api_client.spec.ts | 233 ++ packages/megalodon/test/unit/parser.spec.ts | 152 + packages/megalodon/tsconfig.json | 64 + .../sw/src/scripts/create-notification.ts | 2 +- pnpm-lock.yaml | 871 ++++- pnpm-workspace.yaml | 1 + scripts/clean-all.js | 2 + scripts/clean.js | 1 + scripts/dev.mjs | 6 + 103 files changed, 9492 insertions(+), 82 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/MastodonApiServerService.ts create mode 100644 packages/backend/src/server/api/mastodon/converters.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/meta.ts create mode 100644 packages/megalodon/package.json create mode 100644 packages/megalodon/src/axios.d.ts create mode 100644 packages/megalodon/src/cancel.ts create mode 100644 packages/megalodon/src/converter.ts create mode 100644 packages/megalodon/src/default.ts create mode 100644 packages/megalodon/src/entities/account.ts create mode 100644 packages/megalodon/src/entities/activity.ts create mode 100644 packages/megalodon/src/entities/announcement.ts create mode 100644 packages/megalodon/src/entities/application.ts create mode 100644 packages/megalodon/src/entities/async_attachment.ts create mode 100644 packages/megalodon/src/entities/attachment.ts create mode 100644 packages/megalodon/src/entities/card.ts create mode 100644 packages/megalodon/src/entities/context.ts create mode 100644 packages/megalodon/src/entities/conversation.ts create mode 100644 packages/megalodon/src/entities/emoji.ts create mode 100644 packages/megalodon/src/entities/featured_tag.ts create mode 100644 packages/megalodon/src/entities/field.ts create mode 100644 packages/megalodon/src/entities/filter.ts create mode 100644 packages/megalodon/src/entities/history.ts create mode 100644 packages/megalodon/src/entities/identity_proof.ts create mode 100644 packages/megalodon/src/entities/instance.ts create mode 100644 packages/megalodon/src/entities/list.ts create mode 100644 packages/megalodon/src/entities/marker.ts create mode 100644 packages/megalodon/src/entities/mention.ts create mode 100644 packages/megalodon/src/entities/notification.ts create mode 100644 packages/megalodon/src/entities/poll.ts create mode 100644 packages/megalodon/src/entities/poll_option.ts create mode 100644 packages/megalodon/src/entities/preferences.ts create mode 100644 packages/megalodon/src/entities/push_subscription.ts create mode 100644 packages/megalodon/src/entities/reaction.ts create mode 100644 packages/megalodon/src/entities/relationship.ts create mode 100644 packages/megalodon/src/entities/report.ts create mode 100644 packages/megalodon/src/entities/results.ts create mode 100644 packages/megalodon/src/entities/scheduled_status.ts create mode 100644 packages/megalodon/src/entities/source.ts create mode 100644 packages/megalodon/src/entities/stats.ts create mode 100644 packages/megalodon/src/entities/status.ts create mode 100644 packages/megalodon/src/entities/status_edit.ts create mode 100644 packages/megalodon/src/entities/status_params.ts create mode 100644 packages/megalodon/src/entities/tag.ts create mode 100644 packages/megalodon/src/entities/token.ts create mode 100644 packages/megalodon/src/entities/urls.ts create mode 100644 packages/megalodon/src/entity.ts create mode 100644 packages/megalodon/src/filter_context.ts create mode 100644 packages/megalodon/src/index.ts create mode 100644 packages/megalodon/src/megalodon.ts create mode 100644 packages/megalodon/src/misskey.ts create mode 100644 packages/megalodon/src/misskey/api_client.ts create mode 100644 packages/megalodon/src/misskey/entities/GetAll.ts create mode 100644 packages/megalodon/src/misskey/entities/announcement.ts create mode 100644 packages/megalodon/src/misskey/entities/app.ts create mode 100644 packages/megalodon/src/misskey/entities/blocking.ts create mode 100644 packages/megalodon/src/misskey/entities/createdNote.ts create mode 100644 packages/megalodon/src/misskey/entities/emoji.ts create mode 100644 packages/megalodon/src/misskey/entities/favorite.ts create mode 100644 packages/megalodon/src/misskey/entities/field.ts create mode 100644 packages/megalodon/src/misskey/entities/file.ts create mode 100644 packages/megalodon/src/misskey/entities/followRequest.ts create mode 100644 packages/megalodon/src/misskey/entities/follower.ts create mode 100644 packages/megalodon/src/misskey/entities/following.ts create mode 100644 packages/megalodon/src/misskey/entities/hashtag.ts create mode 100644 packages/megalodon/src/misskey/entities/list.ts create mode 100644 packages/megalodon/src/misskey/entities/meta.ts create mode 100644 packages/megalodon/src/misskey/entities/mute.ts create mode 100644 packages/megalodon/src/misskey/entities/note.ts create mode 100644 packages/megalodon/src/misskey/entities/notification.ts create mode 100644 packages/megalodon/src/misskey/entities/poll.ts create mode 100644 packages/megalodon/src/misskey/entities/reaction.ts create mode 100644 packages/megalodon/src/misskey/entities/relation.ts create mode 100644 packages/megalodon/src/misskey/entities/session.ts create mode 100644 packages/megalodon/src/misskey/entities/state.ts create mode 100644 packages/megalodon/src/misskey/entities/stats.ts create mode 100644 packages/megalodon/src/misskey/entities/user.ts create mode 100644 packages/megalodon/src/misskey/entities/userDetail.ts create mode 100644 packages/megalodon/src/misskey/entities/userDetailMe.ts create mode 100644 packages/megalodon/src/misskey/entities/userkey.ts create mode 100644 packages/megalodon/src/misskey/entity.ts create mode 100644 packages/megalodon/src/misskey/notification.ts create mode 100644 packages/megalodon/src/misskey/web_socket.ts create mode 100644 packages/megalodon/src/notification.ts create mode 100644 packages/megalodon/src/oauth.ts create mode 100644 packages/megalodon/src/parser.ts create mode 100644 packages/megalodon/src/proxy_config.ts create mode 100644 packages/megalodon/src/response.ts create mode 100644 packages/megalodon/test/integration/megalodon.spec.ts create mode 100644 packages/megalodon/test/integration/misskey.spec.ts create mode 100644 packages/megalodon/test/unit/misskey/api_client.spec.ts create mode 100644 packages/megalodon/test/unit/parser.spec.ts create mode 100644 packages/megalodon/tsconfig.json diff --git a/.gitignore b/.gitignore index a66e527db0..11e69b2621 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ ormconfig.json temp /packages/frontend/src/**/*.stories.ts +# Sharkey +/packages/megalodon/lib + # blender backups *.blend1 *.blend2 diff --git a/Dockerfile b/Dockerfile index a417355cfa..76e99a9dd0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/frontend/package.json", "./packages/frontend/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] +COPY --link ["packages/megalodon/package.json", "./packages/megalodon/"] RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output diff --git a/packages/backend/package.json b/packages/backend/package.json index 3d3fc87009..1c2ffcfb6d 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -99,6 +99,7 @@ "date-fns": "2.30.0", "deep-email-validator": "0.1.21", "fastify": "4.23.2", + "fastify-multer": "^2.0.3", "feed": "4.2.2", "file-type": "18.5.0", "fluent-ffmpeg": "2.1.2", @@ -116,6 +117,7 @@ "json5": "2.2.3", "jsonld": "8.3.1", "jsrsasign": "10.8.6", + "megalodon": "workspace:*", "meilisearch": "0.34.2", "mfm-js": "0.23.3", "microformats-parser": "1.5.2", diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index fa81380f01..fc6f019602 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -39,6 +39,7 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; +import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; @@ -84,6 +85,7 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; ServerStatsChannelService, UserListChannelService, OpenApiServerService, + MastodonApiServerService, OAuth2ProviderService, ], exports: [ diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 0e4a5ece3e..a1189e2198 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -30,6 +30,7 @@ import { WellKnownServerService } from './WellKnownServerService.js'; import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; +import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; const _dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -56,6 +57,7 @@ export class ServerService implements OnApplicationShutdown { private userEntityService: UserEntityService, private apiServerService: ApiServerService, private openApiServerService: OpenApiServerService, + private mastodonApiServerService: MastodonApiServerService, private streamingApiServerService: StreamingApiServerService, private activityPubServerService: ActivityPubServerService, private wellKnownServerService: WellKnownServerService, @@ -95,6 +97,7 @@ export class ServerService implements OnApplicationShutdown { fastify.register(this.apiServerService.createServer, { prefix: '/api' }); fastify.register(this.openApiServerService.createServer); + fastify.register(this.mastodonApiServerService.createServer, { prefix: '/api' }); fastify.register(this.fileServerService.createServer); fastify.register(this.activityPubServerService.createServer); fastify.register(this.nodeinfoServerService.createServer); diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts new file mode 100644 index 0000000000..b79489d18d --- /dev/null +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -0,0 +1,192 @@ +import { fileURLToPath } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import type { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import megalodon, { MegalodonInterface } from "megalodon"; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment } from './converters.js'; +import { IsNull } from 'typeorm'; +import type { Config } from '@/config.js'; +import { getInstance } from './endpoints/meta.js'; +import { MetaService } from '@/core/MetaService.js'; +import multer from 'fastify-multer'; + +const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); + +export 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(BASE_URL, accessToken) as MegalodonInterface; + return client; +} + +@Injectable() +export class MastodonApiServerService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.config) + private config: Config, + private metaService: MetaService, + ) { } + + @bindThis + public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { + const upload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: this.config.maxFileSize || 262144000, + files: 1, + }, + }); + + fastify.register(multer.contentParser); + + fastify.get("/v1/custom_emojis", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getInstanceCustomEmojis(); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/instance", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 : convertAccount((await client.getAccount(admin.id)).data); + reply.send(await getInstance(data.data, contact, this.config, await this.metaService.fetch())); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/announcements", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getInstanceAnnouncements(); + reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Body: { id: string } }>("/v1/announcements/:id/dismiss", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.dismissInstanceAnnouncement( + convertId(_request.body['id'], IdType.SharkeyId) + ); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }, + ); + + fastify.post("/v1/media", { preHandler: upload.single('file') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 data = await client.uploadMedia(multipartData); + reply.send(convertAttachment(data.data as Entity.Attachment)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.post("/v2/media", { preHandler: upload.single('file') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 data = await client.uploadMedia(multipartData, _request.body!); + reply.send(convertAttachment(data.data as Entity.Attachment)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/filters", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/trends", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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.getInstanceTrends(); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/preferences", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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.getPreferences(); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + done(); + } +} \ No newline at end of file diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts new file mode 100644 index 0000000000..94b70230d8 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -0,0 +1,136 @@ +import { Entity } from "megalodon"; + +const CHAR_COLLECTION: string = "0123456789abcdefghijklmnopqrstuvwxyz"; + +export enum IdConvertType { + MastodonId, + SharkeyId, +} + +export function convertId(in_id: string, id_convert_type: IdConvertType): string { + switch (id_convert_type) { + case IdConvertType.MastodonId: + let out: bigint = BigInt(0); + const lowerCaseId = in_id.toLowerCase(); + for (let i = 0; i < lowerCaseId.length; i++) { + const charValue = numFromChar(lowerCaseId.charAt(i)); + out += BigInt(charValue) * BigInt(36) ** BigInt(i); + } + return out.toString(); + + case IdConvertType.SharkeyId: + let input: bigint = BigInt(in_id); + let outStr = ''; + while (input > BigInt(0)) { + const remainder = Number(input % BigInt(36)); + outStr = charFromNum(remainder) + outStr; + input /= BigInt(36); + } + return outStr; + + default: + throw new Error('Invalid ID conversion type'); + } +} + +function numFromChar(character: string): number { + for (let i = 0; i < CHAR_COLLECTION.length; i++) { + if (CHAR_COLLECTION.charAt(i) === character) { + return i; + } + } + + throw new Error('Invalid character in parsed base36 id'); +} + +function charFromNum(number: number): string { + if (number >= 0 && number < CHAR_COLLECTION.length) { + return CHAR_COLLECTION.charAt(number); + } else { + throw new Error('Invalid number for base-36 encoding'); + } +} + +function simpleConvert(data: any) { + // copy the object to bypass weird pass by reference bugs + const result = Object.assign({}, data); + result.id = convertId(data.id, IdConvertType.MastodonId); + return result; +} + +export function convertAccount(account: Entity.Account) { + return simpleConvert(account); +} +export function convertAnnouncement(announcement: Entity.Announcement) { + return simpleConvert(announcement); +} +export function convertAttachment(attachment: Entity.Attachment) { + return simpleConvert(attachment); +} +export function convertFilter(filter: Entity.Filter) { + return simpleConvert(filter); +} +export function convertList(list: Entity.List) { + return simpleConvert(list); +} +export function convertFeaturedTag(tag: Entity.FeaturedTag) { + return simpleConvert(tag); +} + +export function convertNotification(notification: Entity.Notification) { + notification.account = convertAccount(notification.account); + notification.id = convertId(notification.id, IdConvertType.MastodonId); + if (notification.status) + notification.status = convertStatus(notification.status); + if (notification.reaction) + notification.reaction = convertReaction(notification.reaction); + return notification; +} + +export function convertPoll(poll: Entity.Poll) { + return simpleConvert(poll); +} +export function convertReaction(reaction: Entity.Reaction) { + if (reaction.accounts) { + reaction.accounts = reaction.accounts.map(convertAccount); + } + return reaction; +} +export function convertRelationship(relationship: Entity.Relationship) { + return simpleConvert(relationship); +} + +export function convertStatus(status: Entity.Status) { + status.account = convertAccount(status.account); + status.id = convertId(status.id, IdConvertType.MastodonId); + if (status.in_reply_to_account_id) + status.in_reply_to_account_id = convertId( + status.in_reply_to_account_id, + IdConvertType.MastodonId, + ); + if (status.in_reply_to_id) + status.in_reply_to_id = convertId(status.in_reply_to_id, IdConvertType.MastodonId); + status.media_attachments = status.media_attachments.map((attachment) => + convertAttachment(attachment), + ); + status.mentions = status.mentions.map((mention) => ({ + ...mention, + id: convertId(mention.id, IdConvertType.MastodonId), + })); + if (status.poll) status.poll = convertPoll(status.poll); + if (status.reblog) status.reblog = convertStatus(status.reblog); + if (status.quote) status.quote = convertStatus(status.quote); + status.reactions = status.reactions.map(convertReaction); + + return status; +} + +export function convertConversation(conversation: Entity.Conversation) { + conversation.id = convertId(conversation.id, IdConvertType.MastodonId); + conversation.accounts = conversation.accounts.map(convertAccount); + if (conversation.last_status) { + conversation.last_status = convertStatus(conversation.last_status); + } + + return conversation; +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts new file mode 100644 index 0000000000..a37742a068 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -0,0 +1,63 @@ +import { Entity } from "megalodon"; +import { MAX_NOTE_TEXT_LENGTH, FILE_TYPE_BROWSERSAFE } from "@/const.js"; +import type { Config } from '@/config.js'; +import type { MiMeta } from "@/models/Meta.js"; + +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?.substring(0, 50) || "See real server website", + 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: !response.registrations, + invites_enabled: response.registrations, + configuration: { + accounts: { + max_featured_tags: 20, + }, + statuses: { + max_characters: MAX_NOTE_TEXT_LENGTH, + 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: 50, + min_expiration: 50, + max_expiration: 2629746, + }, + reactions: { + max_reactions: 1, + }, + }, + contact_account: contact, + rules: [], + }; +} \ No newline at end of file diff --git a/packages/megalodon/package.json b/packages/megalodon/package.json new file mode 100644 index 0000000000..3403b94b47 --- /dev/null +++ b/packages/megalodon/package.json @@ -0,0 +1,83 @@ +{ + "name": "megalodon", + "private": true, + "main": "./lib/src/index.js", + "typings": "./lib/src/index.d.ts", + "scripts": { + "build": "tsc -p ./", + "build:debug": "pnpm run build", + "lint": "pnpm biome check **/*.ts --apply", + "format": "pnpm biome format --write src/**/*.ts", + "doc": "typedoc --out ../docs ./src", + "test": "NODE_ENV=test jest -u --maxWorkers=3" + }, + "jest": { + "moduleFileExtensions": [ + "ts", + "js" + ], + "moduleNameMapper": { + "^@/(.+)": "/src/$1", + "^~/(.+)": "/$1" + }, + "testMatch": [ + "**/test/**/*.spec.ts" + ], + "preset": "ts-jest/presets/default", + "transform": { + "^.+\\.(ts|tsx)$": "ts-jest" + }, + "globals": { + "ts-jest": { + "tsconfig": "tsconfig.json" + } + }, + "testEnvironment": "node" + }, + "dependencies": { + "@types/oauth": "^0.9.0", + "@types/ws": "^8.5.4", + "axios": "1.2.2", + "dayjs": "^1.11.7", + "form-data": "^4.0.0", + "https-proxy-agent": "^5.0.1", + "oauth": "^0.10.0", + "object-assign-deep": "^0.4.0", + "parse-link-header": "^2.0.0", + "socks-proxy-agent": "^7.0.0", + "typescript": "4.9.4", + "uuid": "^9.0.0", + "ws": "8.12.0", + "async-lock": "1.4.0" + }, + "devDependencies": { + "@types/core-js": "^2.5.0", + "@types/form-data": "^2.5.0", + "@types/jest": "^29.4.0", + "@types/object-assign-deep": "^0.4.0", + "@types/parse-link-header": "^2.0.0", + "@types/uuid": "^9.0.0", + "@types/node": "18.11.18", + "@typescript-eslint/eslint-plugin": "^5.49.0", + "@typescript-eslint/parser": "^5.49.0", + "@types/async-lock": "1.4.0", + "eslint": "^8.32.0", + "eslint-config-prettier": "^8.6.0", + "eslint-config-standard": "^16.0.3", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-node": "^11.0.0", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-standard": "^5.0.0", + "jest": "^29.4.0", + "jest-worker": "^29.4.0", + "lodash": "^4.17.14", + "prettier": "^2.8.3", + "ts-jest": "^29.0.5", + "typedoc": "^0.23.24" + }, + "directories": { + "lib": "lib", + "test": "test" + } +} diff --git a/packages/megalodon/src/axios.d.ts b/packages/megalodon/src/axios.d.ts new file mode 100644 index 0000000000..f19fe38a2b --- /dev/null +++ b/packages/megalodon/src/axios.d.ts @@ -0,0 +1 @@ +declare module "axios/lib/adapters/http"; diff --git a/packages/megalodon/src/cancel.ts b/packages/megalodon/src/cancel.ts new file mode 100644 index 0000000000..f8e4729b8e --- /dev/null +++ b/packages/megalodon/src/cancel.ts @@ -0,0 +1,13 @@ +export class RequestCanceledError extends Error { + public isCancel: boolean; + + constructor(msg: string) { + super(msg); + this.isCancel = true; + Object.setPrototypeOf(this, RequestCanceledError); + } +} + +export const isCancel = (value: any): boolean => { + return value && value.isCancel; +}; diff --git a/packages/megalodon/src/converter.ts b/packages/megalodon/src/converter.ts new file mode 100644 index 0000000000..93d669fa7d --- /dev/null +++ b/packages/megalodon/src/converter.ts @@ -0,0 +1,3 @@ +import MisskeyAPI from "./misskey/api_client"; + +export default MisskeyAPI.Converter; diff --git a/packages/megalodon/src/default.ts b/packages/megalodon/src/default.ts new file mode 100644 index 0000000000..45bce13e21 --- /dev/null +++ b/packages/megalodon/src/default.ts @@ -0,0 +1,3 @@ +export const NO_REDIRECT = "urn:ietf:wg:oauth:2.0:oob"; +export const DEFAULT_SCOPE = ["read", "write", "follow"]; +export const DEFAULT_UA = "megalodon"; diff --git a/packages/megalodon/src/entities/account.ts b/packages/megalodon/src/entities/account.ts new file mode 100644 index 0000000000..06a85eb98e --- /dev/null +++ b/packages/megalodon/src/entities/account.ts @@ -0,0 +1,27 @@ +/// +/// +/// +namespace Entity { + export type Account = { + id: string; + username: string; + acct: string; + display_name: string; + locked: boolean; + 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 | null; + source?: Source; + }; +} diff --git a/packages/megalodon/src/entities/activity.ts b/packages/megalodon/src/entities/activity.ts new file mode 100644 index 0000000000..6bc0b6d80e --- /dev/null +++ b/packages/megalodon/src/entities/activity.ts @@ -0,0 +1,8 @@ +namespace Entity { + export type Activity = { + week: string; + statuses: string; + logins: string; + registrations: string; + }; +} diff --git a/packages/megalodon/src/entities/announcement.ts b/packages/megalodon/src/entities/announcement.ts new file mode 100644 index 0000000000..7c79831634 --- /dev/null +++ b/packages/megalodon/src/entities/announcement.ts @@ -0,0 +1,34 @@ +/// +/// +/// + +namespace Entity { + 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; + read?: boolean; + 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; + }; +} diff --git a/packages/megalodon/src/entities/application.ts b/packages/megalodon/src/entities/application.ts new file mode 100644 index 0000000000..9b98b12772 --- /dev/null +++ b/packages/megalodon/src/entities/application.ts @@ -0,0 +1,7 @@ +namespace Entity { + export type Application = { + name: string; + website?: string | null; + vapid_key?: string | null; + }; +} diff --git a/packages/megalodon/src/entities/async_attachment.ts b/packages/megalodon/src/entities/async_attachment.ts new file mode 100644 index 0000000000..9cc17acc5c --- /dev/null +++ b/packages/megalodon/src/entities/async_attachment.ts @@ -0,0 +1,14 @@ +/// +namespace Entity { + 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/entities/attachment.ts b/packages/megalodon/src/entities/attachment.ts new file mode 100644 index 0000000000..082c79eddb --- /dev/null +++ b/packages/megalodon/src/entities/attachment.ts @@ -0,0 +1,49 @@ +namespace Entity { + 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/entities/card.ts b/packages/megalodon/src/entities/card.ts new file mode 100644 index 0000000000..356d99aee4 --- /dev/null +++ b/packages/megalodon/src/entities/card.ts @@ -0,0 +1,16 @@ +namespace Entity { + export type Card = { + url: string; + title: string; + description: string; + type: "link" | "photo" | "video" | "rich"; + image?: string; + author_name?: string; + author_url?: string; + provider_name?: string; + provider_url?: string; + html?: string; + width?: number; + height?: number; + }; +} diff --git a/packages/megalodon/src/entities/context.ts b/packages/megalodon/src/entities/context.ts new file mode 100644 index 0000000000..a794a7c5a8 --- /dev/null +++ b/packages/megalodon/src/entities/context.ts @@ -0,0 +1,8 @@ +/// + +namespace Entity { + export type Context = { + ancestors: Array; + descendants: Array; + }; +} diff --git a/packages/megalodon/src/entities/conversation.ts b/packages/megalodon/src/entities/conversation.ts new file mode 100644 index 0000000000..2bdc196661 --- /dev/null +++ b/packages/megalodon/src/entities/conversation.ts @@ -0,0 +1,11 @@ +/// +/// + +namespace Entity { + export type Conversation = { + id: string; + accounts: Array; + last_status: Status | null; + unread: boolean; + }; +} diff --git a/packages/megalodon/src/entities/emoji.ts b/packages/megalodon/src/entities/emoji.ts new file mode 100644 index 0000000000..10c32ab0bd --- /dev/null +++ b/packages/megalodon/src/entities/emoji.ts @@ -0,0 +1,9 @@ +namespace Entity { + export type Emoji = { + shortcode: string; + static_url: string; + url: string; + visible_in_picker: boolean; + category: string; + }; +} diff --git a/packages/megalodon/src/entities/featured_tag.ts b/packages/megalodon/src/entities/featured_tag.ts new file mode 100644 index 0000000000..fc9f8c69cc --- /dev/null +++ b/packages/megalodon/src/entities/featured_tag.ts @@ -0,0 +1,8 @@ +namespace Entity { + export type FeaturedTag = { + id: string; + name: string; + statuses_count: number; + last_status_at: string; + }; +} diff --git a/packages/megalodon/src/entities/field.ts b/packages/megalodon/src/entities/field.ts new file mode 100644 index 0000000000..de4b6b2b72 --- /dev/null +++ b/packages/megalodon/src/entities/field.ts @@ -0,0 +1,7 @@ +namespace Entity { + export type Field = { + name: string; + value: string; + verified_at: string | null; + }; +} diff --git a/packages/megalodon/src/entities/filter.ts b/packages/megalodon/src/entities/filter.ts new file mode 100644 index 0000000000..55b7305cc3 --- /dev/null +++ b/packages/megalodon/src/entities/filter.ts @@ -0,0 +1,12 @@ +namespace Entity { + 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/entities/history.ts b/packages/megalodon/src/entities/history.ts new file mode 100644 index 0000000000..4676357d69 --- /dev/null +++ b/packages/megalodon/src/entities/history.ts @@ -0,0 +1,7 @@ +namespace Entity { + export type History = { + day: string; + uses: number; + accounts: number; + }; +} diff --git a/packages/megalodon/src/entities/identity_proof.ts b/packages/megalodon/src/entities/identity_proof.ts new file mode 100644 index 0000000000..3b42e6f412 --- /dev/null +++ b/packages/megalodon/src/entities/identity_proof.ts @@ -0,0 +1,9 @@ +namespace Entity { + export type IdentityProof = { + provider: string; + provider_username: string; + updated_at: string; + proof_url: string; + profile_url: string; + }; +} diff --git a/packages/megalodon/src/entities/instance.ts b/packages/megalodon/src/entities/instance.ts new file mode 100644 index 0000000000..9c0f572db4 --- /dev/null +++ b/packages/megalodon/src/entities/instance.ts @@ -0,0 +1,41 @@ +/// +/// +/// + +namespace Entity { + export type Instance = { + uri: string; + title: string; + description: string; + email: string; + version: string; + thumbnail: string | null; + urls: URLs; + stats: Stats; + languages: Array; + contact_account: Account | null; + max_toot_chars?: number; + registrations?: boolean; + configuration?: { + statuses: { + max_characters: number; + max_media_attachments: number; + characters_reserved_per_url: number; + }; + media_attachments: { + supported_mime_types: Array; + image_size_limit: number; + image_matrix_limit: number; + video_size_limit: number; + video_frame_limit: number; + video_matrix_limit: number; + }; + polls: { + max_options: number; + max_characters_per_option: number; + min_expiration: number; + max_expiration: number; + }; + }; + }; +} diff --git a/packages/megalodon/src/entities/list.ts b/packages/megalodon/src/entities/list.ts new file mode 100644 index 0000000000..97e75286b2 --- /dev/null +++ b/packages/megalodon/src/entities/list.ts @@ -0,0 +1,6 @@ +namespace Entity { + export type List = { + id: string; + title: string; + }; +} diff --git a/packages/megalodon/src/entities/marker.ts b/packages/megalodon/src/entities/marker.ts new file mode 100644 index 0000000000..7ee99282ca --- /dev/null +++ b/packages/megalodon/src/entities/marker.ts @@ -0,0 +1,15 @@ +namespace Entity { + export type Marker = { + home?: { + last_read_id: string; + version: number; + updated_at: string; + }; + notifications?: { + last_read_id: string; + version: number; + updated_at: string; + unread_count?: number; + }; + }; +} diff --git a/packages/megalodon/src/entities/mention.ts b/packages/megalodon/src/entities/mention.ts new file mode 100644 index 0000000000..4fe36a6553 --- /dev/null +++ b/packages/megalodon/src/entities/mention.ts @@ -0,0 +1,8 @@ +namespace Entity { + export type Mention = { + id: string; + username: string; + url: string; + acct: string; + }; +} diff --git a/packages/megalodon/src/entities/notification.ts b/packages/megalodon/src/entities/notification.ts new file mode 100644 index 0000000000..68eff3347e --- /dev/null +++ b/packages/megalodon/src/entities/notification.ts @@ -0,0 +1,15 @@ +/// +/// + +namespace Entity { + export type Notification = { + account: Account; + created_at: string; + id: string; + status?: Status; + reaction?: Reaction; + type: NotificationType; + }; + + export type NotificationType = string; +} diff --git a/packages/megalodon/src/entities/poll.ts b/packages/megalodon/src/entities/poll.ts new file mode 100644 index 0000000000..2539d68b20 --- /dev/null +++ b/packages/megalodon/src/entities/poll.ts @@ -0,0 +1,14 @@ +/// + +namespace Entity { + export type Poll = { + id: string; + expires_at: string | null; + expired: boolean; + multiple: boolean; + votes_count: number; + options: Array; + voted: boolean; + own_votes: Array; + }; +} diff --git a/packages/megalodon/src/entities/poll_option.ts b/packages/megalodon/src/entities/poll_option.ts new file mode 100644 index 0000000000..e818a8607b --- /dev/null +++ b/packages/megalodon/src/entities/poll_option.ts @@ -0,0 +1,6 @@ +namespace Entity { + export type PollOption = { + title: string; + votes_count: number | null; + }; +} diff --git a/packages/megalodon/src/entities/preferences.ts b/packages/megalodon/src/entities/preferences.ts new file mode 100644 index 0000000000..7994dc568e --- /dev/null +++ b/packages/megalodon/src/entities/preferences.ts @@ -0,0 +1,9 @@ +namespace Entity { + 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/entities/push_subscription.ts b/packages/megalodon/src/entities/push_subscription.ts new file mode 100644 index 0000000000..ad1146a242 --- /dev/null +++ b/packages/megalodon/src/entities/push_subscription.ts @@ -0,0 +1,16 @@ +namespace Entity { + 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/entities/reaction.ts b/packages/megalodon/src/entities/reaction.ts new file mode 100644 index 0000000000..4edbec6a7d --- /dev/null +++ b/packages/megalodon/src/entities/reaction.ts @@ -0,0 +1,12 @@ +/// + +namespace Entity { + export type Reaction = { + count: number; + me: boolean; + name: string; + url?: string; + static_url?: string; + accounts?: Array; + }; +} diff --git a/packages/megalodon/src/entities/relationship.ts b/packages/megalodon/src/entities/relationship.ts new file mode 100644 index 0000000000..91802d5c88 --- /dev/null +++ b/packages/megalodon/src/entities/relationship.ts @@ -0,0 +1,17 @@ +namespace Entity { + export type Relationship = { + id: string; + following: boolean; + followed_by: boolean; + delivery_following?: boolean; + blocking: boolean; + blocked_by: boolean; + muting: boolean; + muting_notifications: boolean; + requested: boolean; + domain_blocking: boolean; + showing_reblogs: boolean; + endorsed: boolean; + notifying: boolean; + }; +} diff --git a/packages/megalodon/src/entities/report.ts b/packages/megalodon/src/entities/report.ts new file mode 100644 index 0000000000..6862a5fabe --- /dev/null +++ b/packages/megalodon/src/entities/report.ts @@ -0,0 +1,9 @@ +namespace Entity { + export type Report = { + id: string; + action_taken: string; + comment: string; + account_id: string; + status_ids: Array; + }; +} diff --git a/packages/megalodon/src/entities/results.ts b/packages/megalodon/src/entities/results.ts new file mode 100644 index 0000000000..4448e53350 --- /dev/null +++ b/packages/megalodon/src/entities/results.ts @@ -0,0 +1,11 @@ +/// +/// +/// + +namespace Entity { + export type Results = { + accounts: Array; + statuses: Array; + hashtags: Array; + }; +} diff --git a/packages/megalodon/src/entities/scheduled_status.ts b/packages/megalodon/src/entities/scheduled_status.ts new file mode 100644 index 0000000000..78dfb8ed26 --- /dev/null +++ b/packages/megalodon/src/entities/scheduled_status.ts @@ -0,0 +1,10 @@ +/// +/// +namespace Entity { + export type ScheduledStatus = { + id: string; + scheduled_at: string; + params: StatusParams; + media_attachments: Array; + }; +} diff --git a/packages/megalodon/src/entities/source.ts b/packages/megalodon/src/entities/source.ts new file mode 100644 index 0000000000..913b02fda7 --- /dev/null +++ b/packages/megalodon/src/entities/source.ts @@ -0,0 +1,10 @@ +/// +namespace Entity { + export type Source = { + privacy: string | null; + sensitive: boolean | null; + language: string | null; + note: string; + fields: Array; + }; +} diff --git a/packages/megalodon/src/entities/stats.ts b/packages/megalodon/src/entities/stats.ts new file mode 100644 index 0000000000..6471df039a --- /dev/null +++ b/packages/megalodon/src/entities/stats.ts @@ -0,0 +1,7 @@ +namespace Entity { + export type Stats = { + user_count: number; + status_count: number; + domain_count: number; + }; +} diff --git a/packages/megalodon/src/entities/status.ts b/packages/megalodon/src/entities/status.ts new file mode 100644 index 0000000000..f27f728b54 --- /dev/null +++ b/packages/megalodon/src/entities/status.ts @@ -0,0 +1,45 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// + +namespace Entity { + 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; + plain_content: string | null; + created_at: string; + 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; + reactions: Array; + quote: Status | null; + bookmarked: boolean; + }; +} diff --git a/packages/megalodon/src/entities/status_edit.ts b/packages/megalodon/src/entities/status_edit.ts new file mode 100644 index 0000000000..4040b4ff90 --- /dev/null +++ b/packages/megalodon/src/entities/status_edit.ts @@ -0,0 +1,23 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// + +namespace Entity { + export type StatusEdit = { + account: Account; + content: string; + plain_content: string | null; + created_at: string; + emojis: Emoji[]; + sensitive: boolean; + spoiler_text: string; + media_attachments: Array; + poll: Poll | null; + }; +} diff --git a/packages/megalodon/src/entities/status_params.ts b/packages/megalodon/src/entities/status_params.ts new file mode 100644 index 0000000000..18908c01c1 --- /dev/null +++ b/packages/megalodon/src/entities/status_params.ts @@ -0,0 +1,12 @@ +namespace Entity { + 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"; + scheduled_at: string | null; + application_id: string; + }; +} diff --git a/packages/megalodon/src/entities/tag.ts b/packages/megalodon/src/entities/tag.ts new file mode 100644 index 0000000000..ccc88aece6 --- /dev/null +++ b/packages/megalodon/src/entities/tag.ts @@ -0,0 +1,10 @@ +/// + +namespace Entity { + export type Tag = { + name: string; + url: string; + history: Array | null; + following?: boolean; + }; +} diff --git a/packages/megalodon/src/entities/token.ts b/packages/megalodon/src/entities/token.ts new file mode 100644 index 0000000000..1583edafb1 --- /dev/null +++ b/packages/megalodon/src/entities/token.ts @@ -0,0 +1,8 @@ +namespace Entity { + export type Token = { + access_token: string; + token_type: string; + scope: string; + created_at: number; + }; +} diff --git a/packages/megalodon/src/entities/urls.ts b/packages/megalodon/src/entities/urls.ts new file mode 100644 index 0000000000..1ee9ed67c9 --- /dev/null +++ b/packages/megalodon/src/entities/urls.ts @@ -0,0 +1,5 @@ +namespace Entity { + export type URLs = { + streaming_api: string; + }; +} diff --git a/packages/megalodon/src/entity.ts b/packages/megalodon/src/entity.ts new file mode 100644 index 0000000000..b73d2b359b --- /dev/null +++ b/packages/megalodon/src/entity.ts @@ -0,0 +1,38 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// + +export default Entity; diff --git a/packages/megalodon/src/filter_context.ts b/packages/megalodon/src/filter_context.ts new file mode 100644 index 0000000000..4c83cb15f2 --- /dev/null +++ b/packages/megalodon/src/filter_context.ts @@ -0,0 +1,11 @@ +import Entity from "./entity"; + +namespace FilterContext { + export const Home: Entity.FilterContext = "home"; + export const Notifications: Entity.FilterContext = "notifications"; + export const Public: Entity.FilterContext = "public"; + export const Thread: Entity.FilterContext = "thread"; + export const Account: Entity.FilterContext = "account"; +} + +export default FilterContext; diff --git a/packages/megalodon/src/index.ts b/packages/megalodon/src/index.ts new file mode 100644 index 0000000000..758d3a46ad --- /dev/null +++ b/packages/megalodon/src/index.ts @@ -0,0 +1,32 @@ +import Response from "./response"; +import OAuth from "./oauth"; +import { isCancel, RequestCanceledError } from "./cancel"; +import { ProxyConfig } from "./proxy_config"; +import generator, { + detector, + MegalodonInterface, + WebSocketInterface, +} from "./megalodon"; +import Misskey from "./misskey"; +import Entity from "./entity"; +import NotificationType from "./notification"; +import FilterContext from "./filter_context"; +import Converter from "./converter"; + +export { + Response, + OAuth, + RequestCanceledError, + isCancel, + ProxyConfig, + detector, + MegalodonInterface, + WebSocketInterface, + NotificationType, + FilterContext, + Misskey, + Entity, + Converter, +}; + +export default generator; diff --git a/packages/megalodon/src/megalodon.ts b/packages/megalodon/src/megalodon.ts new file mode 100644 index 0000000000..33a5790f67 --- /dev/null +++ b/packages/megalodon/src/megalodon.ts @@ -0,0 +1,1532 @@ +import Response from "./response"; +import OAuth from "./oauth"; +import proxyAgent, { ProxyConfig } from "./proxy_config"; +import Entity from "./entity"; +import axios, { AxiosRequestConfig } from "axios"; +import Misskey from "./misskey"; +import { DEFAULT_UA } from "./default"; + +export interface WebSocketInterface { + start(): void; + stop(): void; + // EventEmitter + on(event: string | symbol, listener: (...args: any[]) => void): this; + once(event: string | symbol, listener: (...args: any[]) => void): this; + removeListener( + event: string | symbol, + listener: (...args: any[]) => void, + ): this; + removeAllListeners(event?: string | symbol): this; +} + +export interface MegalodonInterface { + /** + * Cancel all requests in this instance. + * + * @return void + */ + cancel(): void; + + /** + * 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** + */ + registerApp( + client_name: string, + options: Partial<{ + scopes: Array; + redirect_uris: string; + website: string; + }>, + ): Promise; + + /** + * Call /api/v1/apps + * + * Create an application. + * @param client_name your application's name + * @param options Form Data + */ + createApp( + client_name: string, + options: Partial<{ + scopes: Array; + redirect_uris: string; + website: string; + }>, + ): Promise; + + // ====================================== + // apps + // ====================================== + /** + * GET /api/v1/apps/verify_credentials + * + * @return An Application + */ + verifyAppCredentials(): Promise>; + + // ====================================== + // 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 + */ + fetchAccessToken( + client_id: string | null, + client_secret: string, + code: string, + redirect_uri?: string, + ): Promise; + + /** + * 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 + */ + refreshToken( + client_id: string, + client_secret: string, + refresh_token: string, + ): Promise; + + /** + * 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 + */ + revokeToken( + client_id: string, + client_secret: string, + token: string, + ): Promise>; + + // ====================================== + // 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. + */ + registerAccount( + username: string, + email: string, + password: string, + agreement: boolean, + locale: string, + reason?: string | null, + ): Promise>; + /** + * GET /api/v1/accounts/verify_credentials + * + * @return Account. + */ + verifyAccountCredentials(): Promise>; + /** + * PATCH /api/v1/accounts/update_credentials + * + * @return An account. + */ + 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>; + /** + * GET /api/v1/accounts/:id + * + * @param id The account ID. + * @return An account. + */ + getAccount(id: string): Promise>; + /** + * 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. + */ + 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>>; + /** + * 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. + */ + getAccountFavourites( + id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + }, + ): Promise>>; + /** + * POST /api/v1/pleroma/accounts/:id/subscribe + * + * @param id Target account ID. + * @return Relationship. + */ + subscribeAccount(id: string): Promise>; + /** + * POST /api/v1/pleroma/accounts/:id/unsubscribe + * + * @param id Target account ID. + * @return Relationship. + */ + unsubscribeAccount(id: string): Promise>; + /** + * 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. + */ + getAccountFollowers( + id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + get_all?: boolean; + sleep_ms?: number; + }, + ): Promise>>; + + /** + * GET /api/v1/accounts/:id/featured_tags + * + * @param id The account ID. + * @return The array of accounts. + */ + getAccountFeaturedTags( + id: string, + ): Promise>>; + + /** + * 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. + */ + getAccountFollowing( + id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + get_all?: boolean; + sleep_ms?: number; + }, + ): Promise>>; + /** + * GET /api/v1/accounts/:id/lists + * + * @param id The account ID. + * @return The array of lists. + */ + getAccountLists(id: string): Promise>>; + /** + * GET /api/v1/accounts/:id/identity_proofs + * + * @param id The account ID. + * @return Array of IdentityProof + */ + getIdentityProof(id: string): Promise>>; + /** + * POST /api/v1/accounts/:id/follow + * + * @param id The account ID. + * @param reblog Receive this account's reblogs in home timeline. + * @return Relationship + */ + followAccount( + id: string, + options?: { + reblog?: boolean; + }, + ): Promise>; + /** + * POST /api/v1/accounts/:id/unfollow + * + * @param id The account ID. + * @return Relationship + */ + unfollowAccount(id: string): Promise>; + /** + * POST /api/v1/accounts/:id/block + * + * @param id The account ID. + * @return Relationship + */ + blockAccount(id: string): Promise>; + /** + * POST /api/v1/accounts/:id/unblock + * + * @param id The account ID. + * @return RElationship + */ + unblockAccount(id: string): Promise>; + /** + * POST /api/v1/accounts/:id/mute + * + * @param id The account ID. + * @param notifications Mute notifications in addition to statuses. + * @return Relationship + */ + muteAccount( + id: string, + notifications: boolean, + ): Promise>; + /** + * POST /api/v1/accounts/:id/unmute + * + * @param id The account ID. + * @return Relationship + */ + unmuteAccount(id: string): Promise>; + /** + * POST /api/v1/accounts/:id/pin + * + * @param id The account ID. + * @return Relationship + */ + pinAccount(id: string): Promise>; + /** + * POST /api/v1/accounts/:id/unpin + * + * @param id The account ID. + * @return Relationship + */ + unpinAccount(id: string): Promise>; + /** + * GET /api/v1/accounts/relationships + * + * @param id The account ID. + * @return Relationship + */ + getRelationship(id: string): Promise>; + /** + * Get multiple relationships in one method + * + * @param ids Array of account IDs. + * @return Array of Relationship. + */ + getRelationships( + ids: Array, + ): Promise>>; + /** + * 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. + */ + searchAccount( + q: string, + options?: { + following?: boolean; + resolve?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + }, + ): Promise>>; + // ====================================== + // 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. + */ + getBookmarks(options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>>; + // ====================================== + // 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. + */ + getFavourites(options?: { + limit?: number; + max_id?: string; + min_id?: string; + }): Promise>>; + // ====================================== + // 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. + */ + getMutes(options?: { + limit?: number; + max_id?: string; + min_id?: string; + }): Promise>>; + // ====================================== + // 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. + */ + getBlocks(options?: { + limit?: number; + max_id?: string; + min_id?: string; + }): Promise>>; + // ====================================== + // 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. + */ + getDomainBlocks(options?: { + limit?: number; + max_id?: string; + min_id?: string; + }): Promise>>; + /** + * POST/api/v1/domain_blocks + * + * @param domain Domain to block. + */ + blockDomain(domain: string): Promise>; + /** + * DELETE /api/v1/domain_blocks + * + * @param domain Domain to unblock + */ + unblockDomain(domain: string): Promise>; + // ====================================== + // accounts/filters + // ====================================== + /** + * GET /api/v1/filters + * + * @return Array of filters. + */ + getFilters(): Promise>>; + /** + * GET /api/v1/filters/:id + * + * @param id The filter ID. + * @return Filter. + */ + getFilter(id: string): Promise>; + /** + * 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 + */ + createFilter( + phrase: string, + context: Array, + options?: { + irreversible?: boolean; + whole_word?: boolean; + expires_in?: string; + }, + ): Promise>; + /** + * 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 + */ + updateFilter( + id: string, + phrase: string, + context: Array, + options?: { + irreversible?: boolean; + whole_word?: boolean; + expires_in?: string; + }, + ): Promise>; + /** + * DELETE /api/v1/filters/:id + * + * @param id The filter ID. + * @return Removed filter. + */ + deleteFilter(id: string): Promise>; + // ====================================== + // accounts/reports + // ====================================== + /** + * POST /api/v1/reports + * + * @param account_id Target account ID. + * @param comment Reason of the report. + * @param options.status_ids Array of Statuses ids to attach to the report. + * @param options.forward If the account is remote, should the report be forwarded to the remote admin? + * @return Report + */ + report( + account_id: string, + comment: string, + options?: { status_ids?: Array; forward?: boolean }, + ): Promise>; + // ====================================== + // accounts/follow_requests + // ====================================== + /** + * GET /api/v1/follow_requests + * + * @param limit Maximum number of results. + * @return Array of account. + */ + getFollowRequests(limit?: number): Promise>>; + /** + * POST /api/v1/follow_requests/:id/authorize + * + * @param id Target account ID. + * @return Relationship. + */ + acceptFollowRequest(id: string): Promise>; + /** + * POST /api/v1/follow_requests/:id/reject + * + * @param id Target account ID. + * @return Relationship. + */ + rejectFollowRequest(id: string): Promise>; + // ====================================== + // 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. + */ + getEndorsements(options?: { + limit?: number; + max_id?: string; + since_id?: string; + }): Promise>>; + // ====================================== + // accounts/featured_tags + // ====================================== + /** + * GET /api/v1/featured_tags + * + * @return Array of featured tag. + */ + getFeaturedTags(): Promise>>; + /** + * POST /api/v1/featured_tags + * + * @param name Target hashtag name. + * @return FeaturedTag. + */ + createFeaturedTag(name: string): Promise>; + /** + * DELETE /api/v1/featured_tags/:id + * + * @param id Target featured tag id. + * @return Empty + */ + deleteFeaturedTag(id: string): Promise>; + /** + * GET /api/v1/featured_tags/suggestions + * + * @return Array of tag. + */ + getSuggestedTags(): Promise>>; + // ====================================== + // accounts/preferences + // ====================================== + /** + * GET /api/v1/preferences + * + * @return Preferences. + */ + getPreferences(): Promise>; + // ====================================== + // accounts/suggestions + // ====================================== + /** + * GET /api/v1/suggestions + * + * @param limit Maximum number of results. + * @return Array of accounts. + */ + getSuggestions(limit?: number): Promise>>; + // ====================================== + // accounts/tags + // ====================================== + getFollowedTags(): Promise>>; + /** + * GET /api/v1/tags/:id + * + * @param id Target hashtag id. + * @return Tag + */ + getTag(id: string): Promise>; + /** + * POST /api/v1/tags/:id/follow + * + * @param id Target hashtag id. + * @return Tag + */ + followTag(id: string): Promise>; + /** + * POST /api/v1/tags/:id/unfollow + * + * @param id Target hashtag id. + * @return Tag + */ + unfollowTag(id: string): Promise>; + // ====================================== + // 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 + */ + 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>; + /** + * GET /api/v1/statuses/:id + * + * @param id The target status id. + * @return Status + */ + getStatus(id: string): Promise>; + /** + PUT /api/v1/statuses/:id + * + * @param id The target status id. + * @return Status + */ + 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>; + /** + * DELETE /api/v1/statuses/:id + * + * @param id The target status id. + * @return Status + */ + deleteStatus(id: string): Promise>; + /** + * GET /api/v1/statuses/:id/context + * + * Get parent and child statuses. + * @param id The target status id. + * @return Context + */ + getStatusContext( + id: string, + options?: { limit?: number; max_id?: string; since_id?: string }, + ): Promise>; + /** + * GET /api/v1/statuses/:id/history + * + * Get status edit history. + * @param id The target status id. + * @return StatusEdit + */ + getStatusHistory(id: string): Promise>>; + /** + * GET /api/v1/statuses/:id/reblogged_by + * + * @param id The target status id. + * @return Array of accounts. + */ + getStatusRebloggedBy(id: string): Promise>>; + /** + * GET /api/v1/statuses/:id/favourited_by + * + * @param id The target status id. + * @return Array of accounts. + */ + getStatusFavouritedBy(id: string): Promise>>; + /** + * POST /api/v1/statuses/:id/favourite + * + * @param id The target status id. + * @return Status. + */ + favouriteStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/unfavourite + * + * @param id The target status id. + * @return Status. + */ + unfavouriteStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/reblog + * + * @param id The target status id. + * @return Status. + */ + reblogStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/unreblog + * + * @param id The target status id. + * @return Status. + */ + unreblogStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/bookmark + * + * @param id The target status id. + * @return Status. + */ + bookmarkStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/unbookmark + * + * @param id The target status id. + * @return Status. + */ + unbookmarkStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/mute + * + * @param id The target status id. + * @return Status + */ + muteStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/unmute + * + * @param id The target status id. + * @return Status + */ + unmuteStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/pin + * @param id The target status id. + * @return Status + */ + pinStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/unpin + * + * @param id The target status id. + * @return Status + */ + unpinStatus(id: string): Promise>; + /** + * POST /api/v1/statuses/:id/react/:name + * @param id The target status id. + * @param name The name of the emoji reaction to add. + * @return Status + */ + reactStatus(id: string, name: string): Promise>; + /** + * POST /api/v1/statuses/:id/unreact/:name + * + * @param id The target status id. + * @param name The name of the emoji reaction to remove. + * @return Status + */ + unreactStatus(id: string, name: string): Promise>; + // ====================================== + // 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 + */ + uploadMedia( + file: any, + options?: { description?: string; focus?: string }, + ): Promise>; + /** + * GET /api/v1/media/:id + * + * @param id Target media ID. + * @return Attachment + */ + getMedia(id: string): Promise>; + /** + * 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 + */ + updateMedia( + id: string, + options?: { + file?: any; + description?: string; + focus?: string; + is_sensitive?: boolean; + }, + ): Promise>; + // ====================================== + // statuses/polls + // ====================================== + /** + * GET /api/v1/polls/:id + * + * @param id Target poll ID. + * @return Poll + */ + getPoll(id: string): Promise>; + /** + * 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 + */ + votePoll(id: string, choices: Array): Promise>; + // ====================================== + // 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. + */ + getScheduledStatuses(options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>>; + /** + * GET /api/v1/scheduled_statuses/:id + * + * @param id Target status ID. + * @return ScheduledStatus. + */ + getScheduledStatus(id: string): Promise>; + /** + * 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. + */ + scheduleStatus( + id: string, + scheduled_at?: string | null, + ): Promise>; + /** + * DELETE /api/v1/scheduled_statuses/:id + * + * @param id Target scheduled status ID. + */ + cancelScheduledStatus(id: string): Promise>; + // ====================================== + // 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. + */ + getPublicTimeline(options?: { + only_media?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>>; + /** + * 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. + */ + getLocalTimeline(options?: { + only_media?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>>; + /** + * 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. + */ + getTagTimeline( + hashtag: string, + options?: { + local?: boolean; + only_media?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }, + ): Promise>>; + /** + * 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. + */ + getHomeTimeline(options?: { + local?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>>; + /** + * 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. + */ + getListTimeline( + list_id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }, + ): Promise>>; + // ====================================== + // 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. + */ + getConversationTimeline(options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>>; + /** + * DELETE /api/v1/conversations/:id + * + * @param id Target conversation ID. + */ + deleteConversation(id: string): Promise>; + /** + * POST /api/v1/conversations/:id/read + * + * @param id Target conversation ID. + * @return Conversation. + */ + readConversation(id: string): Promise>; + // ====================================== + // timelines/lists + // ====================================== + /** + * GET /api/v1/lists + * + * @return Array of lists. + */ + getLists(): Promise>>; + /** + * GET /api/v1/lists/:id + * + * @param id Target list ID. + * @return List. + */ + getList(id: string): Promise>; + /** + * POST /api/v1/lists + * + * @param title List name. + * @return List. + */ + createList(title: string): Promise>; + /** + * PUT /api/v1/lists/:id + * + * @param id Target list ID. + * @param title New list name. + * @return List. + */ + updateList(id: string, title: string): Promise>; + /** + * DELETE /api/v1/lists/:id + * + * @param id Target list ID. + */ + deleteList(id: string): Promise>; + /** + * 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. + */ + getAccountsInList( + id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + }, + ): Promise>>; + /** + * POST /api/v1/lists/:id/accounts + * + * @param id Target list ID. + * @param account_ids Array of account IDs to add to the list. + */ + addAccountsToList( + id: string, + account_ids: Array, + ): Promise>; + /** + * DELETE /api/v1/lists/:id/accounts + * + * @param id Target list ID. + * @param account_ids Array of account IDs to add to the list. + */ + deleteAccountsFromList( + id: string, + account_ids: Array, + ): Promise>; + // ====================================== + // timelines/markers + // ====================================== + /** + * GET /api/v1/markers + * + * @param timelines Array of timeline names, String enum anyOf home, notifications. + * @return Marker or empty object. + */ + getMarkers(timeline: Array): Promise>; + /** + * 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. + */ + saveMarkers(options?: { + home?: { last_read_id: string }; + notifications?: { last_read_id: string }; + }): Promise>; + // ====================================== + // 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. + */ + getNotifications(options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + exclude_types?: Array; + account_id?: string; + }): Promise>>; + /** + * GET /api/v1/notifications/:id + * + * @param id Target notification ID. + * @return Notification. + */ + getNotification(id: string): Promise>; + /** + * POST /api/v1/notifications/clear + */ + dismissNotifications(): Promise>; + /** + * POST /api/v1/notifications/:id/dismiss + * + * @param id Target notification ID. + */ + dismissNotification(id: string): Promise>; + /** + * 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 + */ + readNotifications(options: { id?: string; max_id?: string }): Promise< + Response> + >; + // ====================================== + // 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. + */ + subscribePushNotification( + subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, + data?: { + alerts: { + follow?: boolean; + favourite?: boolean; + reblog?: boolean; + mention?: boolean; + poll?: boolean; + }; + } | null, + ): Promise>; + /** + * GET /api/v1/push/subscription + * + * @return PushSubscription. + */ + getPushSubscription(): Promise>; + /** + * 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. + */ + updatePushSubscription( + data?: { + alerts: { + follow?: boolean; + favourite?: boolean; + reblog?: boolean; + mention?: boolean; + poll?: boolean; + }; + } | null, + ): Promise>; + /** + * DELETE /api/v1/push/subscription + */ + deletePushSubscription(): Promise>; + // ====================================== + // 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. + */ + search( + q: string, + type: "accounts" | "hashtags" | "statuses", + options?: { + limit?: number; + max_id?: string; + min_id?: string; + resolve?: boolean; + offset?: number; + following?: boolean; + account_id?: string; + exclude_unreviewed?: boolean; + }, + ): Promise>; + + // ====================================== + // instance + // ====================================== + /** + * GET /api/v1/instance + */ + getInstance(): Promise>; + + /** + * GET /api/v1/instance/peers + */ + getInstancePeers(): Promise>>; + + /** + * GET /api/v1/instance/activity + */ + getInstanceActivity(): Promise>>; + + // ====================================== + // instance/trends + // ====================================== + /** + * GET /api/v1/trends + * + * @param limit Maximum number of results to return. Defaults to 10. + */ + getInstanceTrends( + limit?: number | null, + ): Promise>>; + + // ====================================== + // 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. + */ + getInstanceDirectory(options?: { + limit?: number; + offset?: number; + order?: "active" | "new"; + local?: boolean; + }): Promise>>; + + // ====================================== + // instance/custom_emojis + // ====================================== + /** + * GET /api/v1/custom_emojis + * + * @return Array of emojis. + */ + getInstanceCustomEmojis(): Promise>>; + + // ====================================== + // instance/announcements + // ====================================== + /** + * GET /api/v1/announcements + * + * @param with_dismissed Include announcements dismissed by the user. Defaults to false. + * @return Array of announcements. + */ + getInstanceAnnouncements( + with_dismissed?: boolean | null, + ): Promise>>; + + /** + * POST /api/v1/announcements/:id/dismiss + */ + dismissInstanceAnnouncement(id: string): Promise>; + + // ====================================== + // Emoji reactions + // ====================================== + createEmojiReaction( + id: string, + emoji: string, + ): Promise>; + deleteEmojiReaction( + id: string, + emoji: string, + ): Promise>; + getEmojiReactions(id: string): Promise>>; + getEmojiReaction( + id: string, + emoji: string, + ): Promise>; + + // ====================================== + // WebSocket + // ====================================== + userSocket(): WebSocketInterface; + publicSocket(): WebSocketInterface; + localSocket(): WebSocketInterface; + tagSocket(tag: string): WebSocketInterface; + listSocket(list_id: string): WebSocketInterface; + directSocket(): WebSocketInterface; +} + +export class NoImplementedError extends Error { + constructor(err?: string) { + super(err); + + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class ArgumentError extends Error { + constructor(err?: string) { + super(err); + + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class UnexpectedError extends Error { + constructor(err?: string) { + super(err); + + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +type Instance = { + title: string; + uri: string; + urls: { + streaming_api: string; + }; + version: string; +}; + +/** + * Detect SNS type. + * Now support Mastodon, Pleroma and Pixelfed. + * + * @param url Base URL of SNS. + * @param proxyConfig Proxy setting, or set false if don't use proxy. + * @return SNS name. + */ +export const detector = async ( + url: string, + proxyConfig: ProxyConfig | false = false, +): Promise<"mastodon" | "pleroma" | "misskey"> => { + let options: AxiosRequestConfig = { + headers: { + "User-Agent": DEFAULT_UA, + }, + }; + if (proxyConfig) { + options = Object.assign(options, { + httpsAgent: proxyAgent(proxyConfig), + }); + } + try { + const res = await axios.get(url + "/api/v1/instance", options); + if (res.data.version.includes("Pleroma")) { + return "pleroma"; + } else { + return "mastodon"; + } + } catch (err) { + await axios.post<{}>(url + "/api/meta", {}, options); + return "misskey"; + } +}; + +/** + * Get client for each SNS according to megalodon interface. + * + * @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 = ( + baseUrl: string, + accessToken: string | null = null, + userAgent: string | null = null, + proxyConfig: ProxyConfig | false = false, +): MegalodonInterface => + new Misskey(baseUrl, accessToken, userAgent, proxyConfig); + +export default generator; diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts new file mode 100644 index 0000000000..edfaa4f3cb --- /dev/null +++ b/packages/megalodon/src/misskey.ts @@ -0,0 +1,3436 @@ +import FormData from "form-data"; +import AsyncLock from "async-lock"; + +import MisskeyAPI from "./misskey/api_client"; +import { DEFAULT_UA } from "./default"; +import { ProxyConfig } from "./proxy_config"; +import OAuth from "./oauth"; +import Response from "./response"; +import Entity from "./entity"; +import { + MegalodonInterface, + WebSocketInterface, + NoImplementedError, + ArgumentError, + UnexpectedError, +} from "./megalodon"; +import MegalodonEntity from "@/entity"; +import fs from "node:fs"; +import MisskeyNotificationType from "./misskey/notification"; + +type AccountCache = { + locks: AsyncLock; + accounts: Entity.Account[]; +}; + +export default class Misskey implements MegalodonInterface { + public client: MisskeyAPI.Interface; + public converter: MisskeyAPI.Converter; + public baseUrl: string; + public proxyConfig: ProxyConfig | false; + + /** + * @param baseUrl hostname or base URL + * @param accessToken access token from OAuth2 authorization + * @param userAgent UserAgent is specified in header on request. + * @param proxyConfig Proxy setting, or set false if don't use proxy. + */ + constructor( + baseUrl: string, + accessToken: string | null = null, + userAgent: string | null = DEFAULT_UA, + proxyConfig: ProxyConfig | false = false, + ) { + let token = ""; + if (accessToken) { + token = accessToken; + } + let agent: string = DEFAULT_UA; + if (userAgent) { + agent = userAgent; + } + this.converter = new MisskeyAPI.Converter(baseUrl); + this.client = new MisskeyAPI.Client( + baseUrl, + token, + agent, + proxyConfig, + this.converter, + ); + this.baseUrl = baseUrl; + this.proxyConfig = proxyConfig; + } + + private baseUrlToHost(baseUrl: string): string { + return baseUrl.replace("https://", ""); + } + + public cancel(): void { + return this.client.cancel(); + } + + public async registerApp( + client_name: string, + options: Partial<{ + scopes: Array; + redirect_uris: string; + website: string; + }> = { + scopes: MisskeyAPI.DEFAULT_SCOPE, + redirect_uris: this.baseUrl, + }, + ): Promise { + return this.createApp(client_name, options).then(async (appData) => { + return this.generateAuthUrlAndToken(appData.client_secret).then( + (session) => { + appData.url = session.url; + appData.session_token = session.token; + return appData; + }, + ); + }); + } + + /** + * POST /api/app/create + * + * Create an application. + * @param client_name Your application's name. + * @param options Form data. + */ + public async createApp( + client_name: string, + options: Partial<{ + scopes: Array; + redirect_uris: string; + website: string; + }> = { + scopes: MisskeyAPI.DEFAULT_SCOPE, + redirect_uris: this.baseUrl, + }, + ): Promise { + const redirect_uris = options.redirect_uris || this.baseUrl; + const scopes = options.scopes || MisskeyAPI.DEFAULT_SCOPE; + + const params: { + name: string; + description: string; + permission: Array; + callbackUrl: string; + } = { + name: client_name, + description: "", + permission: scopes, + callbackUrl: redirect_uris, + }; + + /** + * The response is: + { + "id": "xxxxxxxxxx", + "name": "string", + "callbackUrl": "string", + "permission": [ + "string" + ], + "secret": "string" + } + */ + return this.client + .post("/api/app/create", params) + .then((res: Response) => { + const appData: OAuth.AppDataFromServer = { + id: res.data.id, + name: res.data.name, + website: null, + redirect_uri: res.data.callbackUrl, + client_id: "", + client_secret: res.data.secret, + }; + return OAuth.AppData.from(appData); + }); + } + + /** + * POST /api/auth/session/generate + */ + public async generateAuthUrlAndToken( + clientSecret: string, + ): Promise { + return this.client + .post("/api/auth/session/generate", { + appSecret: clientSecret, + }) + .then((res: Response) => res.data); + } + + // ====================================== + // apps + // ====================================== + public async verifyAppCredentials(): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // apps/oauth + // ====================================== + /** + * POST /api/auth/session/userkey + * + * @param _client_id This parameter is not used in this method. + * @param client_secret Application secret key which will be provided in createApp. + * @param session_token Session token string which will be provided in generateAuthUrlAndToken. + * @param _redirect_uri This parameter is not used in this method. + */ + public async fetchAccessToken( + _client_id: string | null, + client_secret: string, + session_token: string, + _redirect_uri?: string, + ): Promise { + return this.client + .post("/api/auth/session/userkey", { + appSecret: client_secret, + token: session_token, + }) + .then((res) => { + const token = new OAuth.TokenData( + res.data.accessToken, + "misskey", + "", + 0, + null, + null, + ); + return token; + }); + } + + public async refreshToken( + _client_id: string, + _client_secret: string, + _refresh_token: string, + ): Promise { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async revokeToken( + _client_id: string, + _client_secret: string, + _token: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // accounts + // ====================================== + public async registerAccount( + _username: string, + _email: string, + _password: string, + _agreement: boolean, + _locale: string, + _reason?: string | null, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + /** + * POST /api/i + */ + public async verifyAccountCredentials(): Promise> { + return this.client + .post("/api/i") + .then((res) => { + return Object.assign(res, { + data: this.converter.userDetail( + res.data, + this.baseUrlToHost(this.baseUrl), + ), + }); + }); + } + + /** + * POST /api/i/update + */ + public async updateCredentials(options?: { + discoverable?: boolean; + bot?: boolean; + display_name?: string; + note?: string; + avatar?: string; + header?: string; + locked?: boolean; + source?: { + privacy?: string; + sensitive?: boolean; + language?: string; + } | null; + fields_attributes?: Array<{ name: string; value: string }>; + }): Promise> { + let params = {}; + if (options) { + if (options.bot !== undefined) { + params = Object.assign(params, { + isBot: options.bot, + }); + } + if (options.display_name) { + params = Object.assign(params, { + name: options.display_name, + }); + } + if (options.note) { + params = Object.assign(params, { + description: options.note, + }); + } + if (options.locked !== undefined) { + params = Object.assign(params, { + isLocked: options.locked, + }); + } + if (options.source) { + if (options.source.language) { + params = Object.assign(params, { + lang: options.source.language, + }); + } + if (options.source.sensitive) { + params = Object.assign(params, { + alwaysMarkNsfw: options.source.sensitive, + }); + } + } + } + return this.client + .post("/api/i", params) + .then((res) => { + return Object.assign(res, { + data: this.converter.userDetail( + res.data, + this.baseUrlToHost(this.baseUrl), + ), + }); + }); + } + + /** + * POST /api/users/show + */ + public async getAccount(id: string): Promise> { + return this.client + .post("/api/users/show", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.userDetail( + res.data, + this.baseUrlToHost(this.baseUrl), + ), + }); + }); + } + + public async getAccountByName( + user: string, + host: string | null, + ): Promise> { + return this.client + .post("/api/users/show", { + username: user, + host: host ?? null, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.userDetail( + res.data, + this.baseUrlToHost(this.baseUrl), + ), + }); + }); + } + + /** + * POST /api/users/notes + */ + public async getAccountStatuses( + id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + pinned?: boolean; + exclude_replies: boolean; + exclude_reblogs: boolean; + only_media?: boolean; + }, + ): Promise>> { + const accountCache = this.getFreshAccountCache(); + + if (options?.pinned) { + return this.client + .post("/api/users/show", { + userId: id, + }) + .then(async (res) => { + if (res.data.pinnedNotes) { + return { + ...res, + data: await Promise.all( + res.data.pinnedNotes.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ), + }; + } + return { ...res, data: [] }; + }); + } + + let params = { + userId: id, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + if (options.exclude_replies) { + params = Object.assign(params, { + includeReplies: false, + }); + } + if (options.exclude_reblogs) { + params = Object.assign(params, { + includeMyRenotes: false, + }); + } + if (options.only_media) { + params = Object.assign(params, { + withFiles: options.only_media, + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + return this.client + .post>("/api/users/notes", params) + .then(async (res) => { + const statuses: Array = await Promise.all( + res.data.map((note) => + this.noteWithDetails( + note, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ); + return Object.assign(res, { + data: statuses, + }); + }); + } + + public async getAccountFavourites( + id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + }, + ): Promise>> { + const accountCache = this.getFreshAccountCache(); + + let params = { + userId: id, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit <= 100 ? options.limit : 100, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + } + return this.client + .post>("/api/users/reactions", params) + .then(async (res) => { + return Object.assign(res, { + data: await Promise.all( + res.data.map((fav) => + this.noteWithDetails( + fav.note, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ), + }); + }); + } + + public async subscribeAccount( + _id: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async unsubscribeAccount( + _id: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + /** + * POST /api/users/followers + */ + public async getAccountFollowers( + id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + }, + ): Promise>> { + let params = { + userId: id, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit <= 100 ? options.limit : 100, + }); + } else { + params = Object.assign(params, { + limit: 40, + }); + } + } else { + params = Object.assign(params, { + limit: 40, + }); + } + return this.client + .post>("/api/users/followers", params) + .then(async (res) => { + return Object.assign(res, { + data: await Promise.all( + res.data.map(async (f) => + this.getAccount(f.followerId).then((p) => p.data), + ), + ), + }); + }); + } + + /** + * POST /api/users/following + */ + public async getAccountFollowing( + id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + }, + ): Promise>> { + let params = { + userId: id, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit <= 100 ? options.limit : 100, + }); + } + } + return this.client + .post>("/api/users/following", params) + .then(async (res) => { + return Object.assign(res, { + data: await Promise.all( + res.data.map(async (f) => + this.getAccount(f.followeeId).then((p) => p.data), + ), + ), + }); + }); + } + + public async getAccountLists( + _id: string, + ): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async getIdentityProof( + _id: string, + ): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + /** + * POST /api/following/create + */ + public async followAccount( + id: string, + _options?: { reblog?: boolean }, + ): Promise> { + await this.client.post<{}>("/api/following/create", { + userId: id, + }); + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + /** + * POST /api/following/delete + */ + public async unfollowAccount( + id: string, + ): Promise> { + await this.client.post<{}>("/api/following/delete", { + userId: id, + }); + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + /** + * POST /api/blocking/create + */ + public async blockAccount( + id: string, + ): Promise> { + await this.client.post<{}>("/api/blocking/create", { + userId: id, + }); + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + /** + * POST /api/blocking/delete + */ + public async unblockAccount( + id: string, + ): Promise> { + await this.client.post<{}>("/api/blocking/delete", { + userId: id, + }); + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + /** + * POST /api/mute/create + */ + public async muteAccount( + id: string, + _notifications: boolean, + ): Promise> { + await this.client.post<{}>("/api/mute/create", { + userId: id, + }); + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + /** + * POST /api/mute/delete + */ + public async unmuteAccount( + id: string, + ): Promise> { + await this.client.post<{}>("/api/mute/delete", { + userId: id, + }); + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + public async pinAccount(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async unpinAccount( + _id: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + /** + * POST /api/users/relation + * + * @param id The accountID, for example `'1sdfag'` + */ + public async getRelationship( + id: string, + ): Promise> { + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + /** + * POST /api/users/relation + * + * @param id Array of account ID, for example `['1sdfag', 'ds12aa']`. + */ + public async getRelationships( + ids: Array, + ): Promise>> { + return Promise.all(ids.map((id) => this.getRelationship(id))).then( + (results) => ({ + ...results[0], + data: results.map((r) => r.data), + }), + ); + } + + /** + * POST /api/users/search + */ + public async searchAccount( + q: string, + options?: { + following?: boolean; + resolve?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + }, + ): Promise>> { + let params = { + query: q, + detail: true, + }; + if (options) { + if (options.resolve !== undefined) { + params = Object.assign(params, { + localOnly: options.resolve, + }); + } + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 40, + }); + } + } else { + params = Object.assign(params, { + limit: 40, + }); + } + return this.client + .post>("/api/users/search", params) + .then((res) => { + return Object.assign(res, { + data: res.data.map((u) => + this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), + ), + }); + }); + } + + // ====================================== + // accounts/bookmarks + // ====================================== + /** + * POST /api/i/favorites + */ + public async getBookmarks(options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>> { + const accountCache = this.getFreshAccountCache(); + + let params = {}; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit <= 100 ? options.limit : 100, + }); + } else { + params = Object.assign(params, { + limit: 40, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 40, + }); + } + return this.client + .post>("/api/i/favorites", params) + .then(async (res) => { + return Object.assign(res, { + data: await Promise.all( + res.data.map((s) => + this.noteWithDetails( + s.note, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ), + }); + }); + } + + // ====================================== + // accounts/favourites + // ====================================== + public async getFavourites(options?: { + limit?: number; + max_id?: string; + min_id?: string; + }): Promise>> { + const userId = await this.client + .post("/api/i") + .then((res) => res.data.id); + return this.getAccountFavourites(userId, options); + } + + // ====================================== + // accounts/mutes + // ====================================== + /** + * POST /api/mute/list + */ + public async getMutes(options?: { + limit?: number; + max_id?: string; + min_id?: string; + }): Promise>> { + let params = {}; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 40, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 40, + }); + } + return this.client + .post>("/api/mute/list", params) + .then((res) => { + return Object.assign(res, { + data: res.data.map((mute) => + this.converter.userDetail( + mute.mutee, + this.baseUrlToHost(this.baseUrl), + ), + ), + }); + }); + } + + // ====================================== + // accounts/blocks + // ====================================== + /** + * POST /api/blocking/list + */ + public async getBlocks(options?: { + limit?: number; + max_id?: string; + min_id?: string; + }): Promise>> { + let params = {}; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 40, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 40, + }); + } + return this.client + .post>("/api/blocking/list", params) + .then((res) => { + return Object.assign(res, { + data: res.data.map((blocking) => + this.converter.userDetail( + blocking.blockee, + this.baseUrlToHost(this.baseUrl), + ), + ), + }); + }); + } + + // ====================================== + // accounts/domain_blocks + // ====================================== + public async getDomainBlocks(_options?: { + limit?: number; + max_id?: string; + min_id?: string; + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async blockDomain(_domain: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async unblockDomain(_domain: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // accounts/filters + // ====================================== + public async getFilters(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async getFilter(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async createFilter( + _phrase: string, + _context: Array, + _options?: { + irreversible?: boolean; + whole_word?: boolean; + expires_in?: string; + }, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async updateFilter( + _id: string, + _phrase: string, + _context: Array, + _options?: { + irreversible?: boolean; + whole_word?: boolean; + expires_in?: string; + }, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async deleteFilter(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // accounts/reports + // ====================================== + /** + * POST /api/users/report-abuse + */ + public async report( + account_id: string, + comment: string, + _options?: { + status_ids?: Array; + forward?: boolean; + }, + ): Promise> { + return this.client + .post<{}>("/api/users/report-abuse", { + userId: account_id, + comment: comment, + }) + .then((res) => { + return Object.assign(res, { + data: { + id: "", + action_taken: "", + comment: comment, + account_id: account_id, + status_ids: [], + }, + }); + }); + } + + // ====================================== + // accounts/follow_requests + // ====================================== + /** + * POST /api/following/requests/list + */ + public async getFollowRequests( + _limit?: number, + ): Promise>> { + return this.client + .post>( + "/api/following/requests/list", + ) + .then((res) => { + return Object.assign(res, { + data: res.data.map((r) => this.converter.user(r.follower)), + }); + }); + } + + /** + * POST /api/following/requests/accept + */ + public async acceptFollowRequest( + id: string, + ): Promise> { + await this.client.post<{}>("/api/following/requests/accept", { + userId: id, + }); + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + /** + * POST /api/following/requests/reject + */ + public async rejectFollowRequest( + id: string, + ): Promise> { + await this.client.post<{}>("/api/following/requests/reject", { + userId: id, + }); + return this.client + .post("/api/users/relation", { + userId: id, + }) + .then((res) => { + return Object.assign(res, { + data: this.converter.relation(res.data), + }); + }); + } + + // ====================================== + // accounts/endorsements + // ====================================== + public async getEndorsements(_options?: { + limit?: number; + max_id?: string; + since_id?: string; + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // accounts/featured_tags + // ====================================== + public async getFeaturedTags(): Promise>> { + return this.getAccountFeaturedTags(); + } + + public async getAccountFeaturedTags(): Promise< + Response> + > { + const tags: Entity.FeaturedTag[] = []; + const res: Response = { + headers: undefined, + statusText: "", + status: 200, + data: tags, + }; + return new Promise((resolve) => resolve(res)); + } + + public async createFeaturedTag( + _name: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async deleteFeaturedTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async getSuggestedTags(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // accounts/preferences + // ====================================== + public async getPreferences(): Promise> { + return this.client + .post("/api/i") + .then(async (res) => { + return Object.assign(res, { + data: this.converter.userPreferences( + res.data, + await this.getDefaultPostPrivacy(), + ), + }); + }); + } + + // ====================================== + // accounts/suggestions + // ====================================== + /** + * POST /api/users/recommendation + */ + public async getSuggestions( + limit?: number, + ): Promise>> { + let params = {}; + if (limit) { + params = Object.assign(params, { + limit: limit, + }); + } + return this.client + .post>( + "/api/users/recommendation", + params, + ) + .then((res) => ({ + ...res, + data: res.data.map((u) => + this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), + ), + })); + } + + // ====================================== + // accounts/tags + // ====================================== + public async getFollowedTags(): Promise>> { + const tags: Entity.Tag[] = []; + const res: Response = { + headers: undefined, + statusText: "", + status: 200, + data: tags, + }; + return new Promise((resolve) => resolve(res)); + } + + public async getTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async followTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async unfollowTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // statuses + // ====================================== + public async postStatus( + status: string, + options?: { + media_ids?: Array; + poll?: { + options: Array; + expires_in: number; + multiple?: boolean; + hide_totals?: boolean; + }; + in_reply_to_id?: string; + sensitive?: boolean; + spoiler_text?: string; + visibility?: "public" | "unlisted" | "private" | "direct"; + scheduled_at?: string; + language?: string; + quote_id?: string; + }, + ): Promise> { + let params = { + text: status, + }; + if (options) { + if (options.media_ids) { + params = Object.assign(params, { + fileIds: options.media_ids, + }); + } + if (options.poll) { + let pollParam = { + choices: options.poll.options, + expiresAt: null, + expiredAfter: options.poll.expires_in * 1000, + }; + if (options.poll.multiple !== undefined) { + pollParam = Object.assign(pollParam, { + multiple: options.poll.multiple, + }); + } + params = Object.assign(params, { + poll: pollParam, + }); + } + if (options.in_reply_to_id) { + params = Object.assign(params, { + replyId: options.in_reply_to_id, + }); + } + if (options.sensitive) { + params = Object.assign(params, { + cw: "", + }); + } + if (options.spoiler_text) { + params = Object.assign(params, { + cw: options.spoiler_text, + }); + } + if (options.visibility) { + params = Object.assign(params, { + visibility: this.converter.encodeVisibility(options.visibility), + }); + } + if (options.quote_id) { + params = Object.assign(params, { + renoteId: options.quote_id, + }); + } + } + return this.client + .post("/api/notes/create", params) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data.createdNote, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * POST /api/notes/show + */ + public async getStatus(id: string): Promise> { + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + private getFreshAccountCache(): AccountCache { + return { + locks: new AsyncLock(), + accounts: [], + }; + } + + public async notificationWithDetails( + n: MisskeyAPI.Entity.Notification, + host: string, + cache: AccountCache, + ): Promise { + const notification = this.converter.notification(n, host); + if (n.note) + notification.status = await this.noteWithDetails(n.note, host, cache); + if (notification.account) + notification.account = ( + await this.getAccount(notification.account.id) + ).data; + return notification; + } + + public async noteWithDetails( + n: MisskeyAPI.Entity.Note, + host: string, + cache: AccountCache, + ): Promise { + const status = await this.addUserDetailsToStatus( + this.converter.note(n, host), + cache, + ); + status.bookmarked = await this.isStatusBookmarked(n.id); + return this.addMentionsToStatus(status, cache); + } + + public async isStatusBookmarked(id: string): Promise { + return this.client + .post("/api/notes/state", { + noteId: id, + }) + .then((p) => p.data.isFavorited ?? false); + } + + public async addUserDetailsToStatus( + status: Entity.Status, + cache: AccountCache, + ): Promise { + if ( + status.account.followers_count === 0 && + status.account.followers_count === 0 && + status.account.statuses_count === 0 + ) + status.account = + (await this.getAccountCached( + status.account.id, + status.account.acct, + cache, + )) ?? status.account; + + if (status.reblog != null) + status.reblog = await this.addUserDetailsToStatus(status.reblog, cache); + + if (status.quote != null) + status.quote = await this.addUserDetailsToStatus(status.quote, cache); + + return status; + } + + public async addMentionsToStatus( + status: Entity.Status, + cache: AccountCache, + ): Promise { + if (status.mentions.length > 0) return status; + + if (status.reblog != null) + status.reblog = await this.addMentionsToStatus(status.reblog, cache); + + if (status.quote != null) + status.quote = await this.addMentionsToStatus(status.quote, cache); + + const idx = status.account.acct.indexOf("@"); + const origin = idx < 0 ? null : status.account.acct.substring(idx + 1); + + status.mentions = ( + await this.getMentions(status.plain_content!, origin, cache) + ).filter((p) => p != null); + for (const m of status.mentions.filter( + (value, index, array) => array.indexOf(value) === index, + )) { + const regexFull = new RegExp( + `(?<=^|\\s|>)@${m.acct}(?=[^a-zA-Z0-9]|$)`, + "gi", + ); + const regexLocalUser = new RegExp( + `(?<=^|\\s|>)@${m.acct}@${this.baseUrlToHost( + this.baseUrl, + )}(?=[^a-zA-Z0-9]|$)`, + "gi", + ); + const regexRemoteUser = new RegExp( + `(?<=^|\\s|>)@${m.username}(?=[^a-zA-Z0-9@]|$)`, + "gi", + ); + + if (m.acct == m.username) { + status.content = status.content.replace(regexLocalUser, `@${m.acct}`); + } else if (!status.content.match(regexFull)) { + status.content = status.content.replace(regexRemoteUser, `@${m.acct}`); + } + + status.content = status.content.replace( + regexFull, + `@${m.acct}`, + ); + } + return status; + } + + public async getMentions( + text: string, + origin: string | null, + cache: AccountCache, + ): Promise { + const mentions: Entity.Mention[] = []; + + if (text == undefined) return mentions; + + const mentionMatch = text.matchAll( + /(?<=^|\s)@(?[a-zA-Z0-9_]+)(?:@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)|)(?=[^a-zA-Z0-9]|$)/g, + ); + + for (const m of mentionMatch) { + try { + if (m.groups == null) continue; + + const account = await this.getAccountByNameCached( + m.groups.user, + m.groups.host ?? origin, + cache, + ); + + if (account == null) continue; + + mentions.push({ + id: account.id, + url: account.url, + username: account.username, + acct: account.acct, + }); + } catch {} + } + + return mentions; + } + + public async getAccountByNameCached( + user: string, + host: string | null, + cache: AccountCache, + ): Promise { + const acctToFind = host == null ? user : `${user}@${host}`; + + return await cache.locks.acquire(acctToFind, async () => { + const cacheHit = cache.accounts.find((p) => p.acct === acctToFind); + const account = + cacheHit ?? (await this.getAccountByName(user, host ?? null)).data; + + if (!account) { + return null; + } + + if (cacheHit == null) { + cache.accounts.push(account); + } + + return account; + }); + } + + public async getAccountCached( + id: string, + acct: string, + cache: AccountCache, + ): Promise { + return await cache.locks.acquire(acct, async () => { + const cacheHit = cache.accounts.find((p) => p.id === id); + const account = cacheHit ?? (await this.getAccount(id)).data; + + if (!account) { + return null; + } + + if (cacheHit == null) { + cache.accounts.push(account); + } + + return account; + }); + } + + public async editStatus( + _id: string, + _options: { + status?: string; + spoiler_text?: string; + sensitive?: boolean; + media_ids?: Array; + poll?: { + options?: Array; + expires_in?: number; + multiple?: boolean; + hide_totals?: boolean; + }; + }, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + /** + * POST /api/notes/delete + */ + public async deleteStatus(id: string): Promise> { + return this.client.post<{}>("/api/notes/delete", { + noteId: id, + }); + } + + /** + * POST /api/notes/children + */ + public async getStatusContext( + id: string, + options?: { limit?: number; max_id?: string; since_id?: string }, + ): Promise> { + let params = { + noteId: id, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + depth: 12, + }); + } else { + params = Object.assign(params, { + limit: 30, + depth: 12, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + } else { + params = Object.assign(params, { + limit: 30, + depth: 12, + }); + } + return this.client + .post>("/api/notes/children", params) + .then(async (res) => { + const accountCache = this.getFreshAccountCache(); + const conversation = await this.client.post< + Array + >("/api/notes/conversation", params); + const parents = await Promise.all( + conversation.data.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ); + + const context: Entity.Context = { + ancestors: parents.reverse(), + descendants: this.dfs( + await Promise.all( + res.data.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ), + ), + }; + return { + ...res, + data: context, + }; + }); + } + + private dfs(graph: Entity.Status[]) { + // we don't need to run dfs if we have zero or one elements + if (graph.length <= 1) { + return graph; + } + + // sort the graph first, so we can grab the correct starting point + graph = graph.sort((a, b) => { + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; + }); + + const initialPostId = graph[0].in_reply_to_id; + + // populate stack with all top level replies + const stack = graph + .filter((reply) => reply.in_reply_to_id === initialPostId) + .reverse(); + const visited = new Set(); + const result = []; + + while (stack.length) { + const currentPost = stack.pop(); + + if (currentPost === undefined) return result; + + if (!visited.has(currentPost)) { + visited.add(currentPost); + result.push(currentPost); + + for (const reply of graph + .filter((reply) => reply.in_reply_to_id === currentPost.id) + .reverse()) { + stack.push(reply); + } + } + } + + return result; + } + + public async getStatusHistory(): Promise>> { + // FIXME: stub, implement once we have note edit history in the database + const history: Entity.StatusEdit[] = []; + const res: Response = { + headers: undefined, + statusText: "", + status: 200, + data: history, + }; + return new Promise((resolve) => resolve(res)); + } + + /** + * POST /api/notes/renotes + */ + public async getStatusRebloggedBy( + id: string, + ): Promise>> { + return this.client + .post>("/api/notes/renotes", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: ( + await Promise.all(res.data.map((n) => this.getAccount(n.user.id))) + ).map((p) => p.data), + })); + } + + public async getStatusFavouritedBy( + id: string, + ): Promise>> { + return this.client + .post>("/api/notes/reactions", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: ( + await Promise.all(res.data.map((n) => this.getAccount(n.user.id))) + ).map((p) => p.data), + })); + } + + public async favouriteStatus(id: string): Promise> { + return this.createEmojiReaction(id, await this.getDefaultFavoriteEmoji()); + } + + private async getDefaultFavoriteEmoji(): Promise { + // NOTE: get-unsecure is calckey's extension. + // Misskey doesn't have this endpoint and regular `/i/registry/get` won't work + // unless you have a 'nativeToken', which is reserved for the frontend webapp. + + return await this.client + .post>("/api/i/registry/get-unsecure", { + key: "reactions", + scope: ["client", "base"], + }) + .then((res) => res.data[0] ?? "⭐"); + } + + private async getDefaultPostPrivacy(): Promise< + "public" | "unlisted" | "private" | "direct" + > { + // NOTE: get-unsecure is calckey's extension. + // Misskey doesn't have this endpoint and regular `/i/registry/get` won't work + // unless you have a 'nativeToken', which is reserved for the frontend webapp. + + return this.client + .post("/api/i/registry/get-unsecure", { + key: "defaultNoteVisibility", + scope: ["client", "base"], + }) + .then((res) => { + if ( + !res.data || + (res.data != "public" && + res.data != "home" && + res.data != "followers" && + res.data != "specified") + ) + return "public"; + return this.converter.visibility(res.data); + }) + .catch((_) => "public"); + } + + public async unfavouriteStatus(id: string): Promise> { + // NOTE: Misskey allows only one reaction per status, so we don't need to care what that emoji was. + return this.deleteEmojiReaction(id, ""); + } + + /** + * POST /api/notes/create + */ + public async reblogStatus(id: string): Promise> { + return this.client + .post("/api/notes/create", { + renoteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data.createdNote, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * POST /api/notes/unrenote + */ + public async unreblogStatus(id: string): Promise> { + await this.client.post<{}>("/api/notes/unrenote", { + noteId: id, + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * POST /api/notes/favorites/create + */ + public async bookmarkStatus(id: string): Promise> { + await this.client.post<{}>("/api/notes/favorites/create", { + noteId: id, + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * POST /api/notes/favorites/delete + */ + public async unbookmarkStatus(id: string): Promise> { + await this.client.post<{}>("/api/notes/favorites/delete", { + noteId: id, + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + public async muteStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async unmuteStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + /** + * POST /api/i/pin + */ + public async pinStatus(id: string): Promise> { + await this.client.post<{}>("/api/i/pin", { + noteId: id, + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * POST /api/i/unpin + */ + public async unpinStatus(id: string): Promise> { + await this.client.post<{}>("/api/i/unpin", { + noteId: id, + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * Convert a Unicode emoji or custom emoji name to a Misskey reaction. + * @see Misskey's reaction-lib.ts + */ + private reactionName(name: string): string { + // See: https://github.com/tc39/proposal-regexp-unicode-property-escapes#matching-emoji + const isUnicodeEmoji = + /\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu.test( + name, + ); + if (isUnicodeEmoji) { + return name; + } + return `:${name}:`; + } + + /** + * POST /api/notes/reactions/create + */ + public async reactStatus( + id: string, + name: string, + ): Promise> { + await this.client.post<{}>("/api/notes/reactions/create", { + noteId: id, + reaction: this.reactionName(name), + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * POST /api/notes/reactions/delete + */ + public async unreactStatus( + id: string, + name: string, + ): Promise> { + await this.client.post<{}>("/api/notes/reactions/delete", { + noteId: id, + reaction: this.reactionName(name), + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + // ====================================== + // statuses/media + // ====================================== + /** + * POST /api/drive/files/create + */ + public async uploadMedia( + file: any, + options?: { description?: string; focus?: string }, + ): Promise> { + const formData = new FormData(); + formData.append("file", fs.createReadStream(file.path), { + contentType: file.mimetype, + }); + + if (file.originalname != null && file.originalname !== "file") + formData.append("name", file.originalname); + + if (options?.description != null) + formData.append("comment", options.description); + + let headers: { [key: string]: string } = {}; + if (typeof formData.getHeaders === "function") { + headers = formData.getHeaders(); + } + return this.client + .post( + "/api/drive/files/create", + formData, + headers, + ) + .then((res) => ({ ...res, data: this.converter.file(res.data) })); + } + + public async getMedia(id: string): Promise> { + const res = await this.client.post( + "/api/drive/files/show", + { fileId: id }, + ); + return { ...res, data: this.converter.file(res.data) }; + } + + /** + * POST /api/drive/files/update + */ + public async updateMedia( + id: string, + options?: { + file?: any; + description?: string; + focus?: string; + is_sensitive?: boolean; + }, + ): Promise> { + let params = { + fileId: id, + }; + if (options) { + if (options.is_sensitive !== undefined) { + params = Object.assign(params, { + isSensitive: options.is_sensitive, + }); + } + + if (options.description !== undefined) { + params = Object.assign(params, { + comment: options.description, + }); + } + } + return this.client + .post("/api/drive/files/update", params) + .then((res) => ({ ...res, data: this.converter.file(res.data) })); + } + + // ====================================== + // statuses/polls + // ====================================== + public async getPoll(id: string): Promise> { + const res = await this.getStatus(id); + if (res.data.poll == null) throw new Error("poll not found"); + return { ...res, data: res.data.poll }; + } + + /** + * POST /api/notes/polls/vote + */ + public async votePoll( + id: string, + choices: Array, + ): Promise> { + if (!id) { + return new Promise((_, reject) => { + const err = new ArgumentError("id is required"); + reject(err); + }); + } + + for (const c of choices) { + const params = { + noteId: id, + choice: +c, + }; + await this.client.post<{}>("/api/notes/polls/vote", params); + } + + const res = await this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => { + const note = await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ); + return { ...res, data: note.poll }; + }); + if (!res.data) { + return new Promise((_, reject) => { + const err = new UnexpectedError("poll does not exist"); + reject(err); + }); + } + return { ...res, data: res.data }; + } + + // ====================================== + // statuses/scheduled_statuses + // ====================================== + public async getScheduledStatuses(_options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async getScheduledStatus( + _id: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async scheduleStatus( + _id: string, + _scheduled_at?: string | null, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async cancelScheduledStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // timelines + // ====================================== + /** + * POST /api/notes/global-timeline + */ + public async getPublicTimeline(options?: { + only_media?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>> { + const accountCache = this.getFreshAccountCache(); + + let params = {}; + if (options) { + if (options.only_media !== undefined) { + params = Object.assign(params, { + withFiles: options.only_media, + }); + } + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + return this.client + .post>("/api/notes/global-timeline", params) + .then(async (res) => ({ + ...res, + data: ( + await Promise.all( + res.data.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ) + ).sort(this.sortByIdDesc), + })); + } + + /** + * POST /api/notes/local-timeline + */ + public async getLocalTimeline(options?: { + only_media?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>> { + const accountCache = this.getFreshAccountCache(); + + let params = {}; + if (options) { + if (options.only_media !== undefined) { + params = Object.assign(params, { + withFiles: options.only_media, + }); + } + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + return this.client + .post>("/api/notes/local-timeline", params) + .then(async (res) => ({ + ...res, + data: ( + await Promise.all( + res.data.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ) + ).sort(this.sortByIdDesc), + })); + } + + /** + * POST /api/notes/search-by-tag + */ + public async getTagTimeline( + hashtag: string, + options?: { + local?: boolean; + only_media?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }, + ): Promise>> { + const accountCache = this.getFreshAccountCache(); + + let params = { + tag: hashtag, + }; + if (options) { + if (options.only_media !== undefined) { + params = Object.assign(params, { + withFiles: options.only_media, + }); + } + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + return this.client + .post>("/api/notes/search-by-tag", params) + .then(async (res) => ({ + ...res, + data: ( + await Promise.all( + res.data.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ) + ).sort(this.sortByIdDesc), + })); + } + + /** + * POST /api/notes/timeline + */ + public async getHomeTimeline(options?: { + local?: boolean; + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>> { + const accountCache = this.getFreshAccountCache(); + + let params = { + withFiles: false, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + return this.client + .post>("/api/notes/timeline", params) + .then(async (res) => ({ + ...res, + data: ( + await Promise.all( + res.data.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ) + ).sort(this.sortByIdDesc), + })); + } + + /** + * POST /api/notes/user-list-timeline + */ + public async getListTimeline( + list_id: string, + options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }, + ): Promise>> { + const accountCache = this.getFreshAccountCache(); + + let params = { + listId: list_id, + withFiles: false, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + return this.client + .post>( + "/api/notes/user-list-timeline", + params, + ) + .then(async (res) => ({ + ...res, + data: ( + await Promise.all( + res.data.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ) + ).sort(this.sortByIdDesc), + })); + } + + // ====================================== + // timelines/conversations + // ====================================== + /** + * POST /api/notes/mentions + */ + public async getConversationTimeline(options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + }): Promise>> { + let params = { + visibility: "specified", + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + return this.client + .post>("/api/notes/mentions", params) + .then((res) => ({ + ...res, + data: res.data.map((n) => + this.converter.noteToConversation( + n, + this.baseUrlToHost(this.baseUrl), + ), + ), + })); + // FIXME: ^ this should also parse mentions + } + + public async deleteConversation(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async readConversation( + _id: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + private sortByIdDesc(a: Entity.Status, b: Entity.Status): number { + if (a.id < b.id) return 1; + if (a.id > b.id) return -1; + + return 0; + } + + // ====================================== + // timelines/lists + // ====================================== + /** + * POST /api/users/lists/list + */ + public async getLists(): Promise>> { + return this.client + .post>("/api/users/lists/list") + .then((res) => ({ + ...res, + data: res.data.map((l) => this.converter.list(l)), + })); + } + + /** + * POST /api/users/lists/show + */ + public async getList(id: string): Promise> { + return this.client + .post("/api/users/lists/show", { + listId: id, + }) + .then((res) => ({ ...res, data: this.converter.list(res.data) })); + } + + /** + * POST /api/users/lists/create + */ + public async createList(title: string): Promise> { + return this.client + .post("/api/users/lists/create", { + name: title, + }) + .then((res) => ({ ...res, data: this.converter.list(res.data) })); + } + + /** + * POST /api/users/lists/update + */ + public async updateList( + id: string, + title: string, + ): Promise> { + return this.client + .post("/api/users/lists/update", { + listId: id, + name: title, + }) + .then((res) => ({ ...res, data: this.converter.list(res.data) })); + } + + /** + * POST /api/users/lists/delete + */ + public async deleteList(id: string): Promise> { + return this.client.post<{}>("/api/users/lists/delete", { + listId: id, + }); + } + + /** + * POST /api/users/lists/show + */ + public async getAccountsInList( + id: string, + _options?: { + limit?: number; + max_id?: string; + since_id?: string; + }, + ): Promise>> { + const res = await this.client.post( + "/api/users/lists/show", + { + listId: id, + }, + ); + const promise = res.data.userIds.map((userId) => this.getAccount(userId)); + const accounts = await Promise.all(promise); + return { ...res, data: accounts.map((r) => r.data) }; + } + + /** + * POST /api/users/lists/push + */ + public async addAccountsToList( + id: string, + account_ids: Array, + ): Promise> { + return this.client.post<{}>("/api/users/lists/push", { + listId: id, + userId: account_ids[0], + }); + } + + /** + * POST /api/users/lists/pull + */ + public async deleteAccountsFromList( + id: string, + account_ids: Array, + ): Promise> { + return this.client.post<{}>("/api/users/lists/pull", { + listId: id, + userId: account_ids[0], + }); + } + + // ====================================== + // timelines/markers + // ====================================== + public async getMarkers( + _timeline: Array, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async saveMarkers(_options?: { + home?: { last_read_id: string }; + notifications?: { last_read_id: string }; + }): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // notifications + // ====================================== + /** + * POST /api/i/notifications + */ + public async getNotifications(options?: { + limit?: number; + max_id?: string; + since_id?: string; + min_id?: string; + exclude_type?: Array; + account_id?: string; + }): Promise>> { + let params = {}; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit <= 100 ? options.limit : 100, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + if (options.exclude_type) { + params = Object.assign(params, { + excludeType: options.exclude_type.map((e) => + this.converter.encodeNotificationType(e), + ), + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + const cache = this.getFreshAccountCache(); + return this.client + .post>( + "/api/i/notifications", + params, + ) + .then(async (res) => ({ + ...res, + data: await Promise.all( + res.data + .filter( + (p) => p.type != MisskeyNotificationType.FollowRequestAccepted, + ) // these aren't supported on mastodon + .map((n) => + this.notificationWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + cache, + ), + ), + ), + })); + } + + public async getNotification( + _id: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + /** + * POST /api/notifications/mark-all-as-read + */ + public async dismissNotifications(): Promise> { + return this.client.post<{}>("/api/notifications/mark-all-as-read"); + } + + public async dismissNotification(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async readNotifications(_options: { + id?: string; + max_id?: string; + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("mastodon does not support"); + reject(err); + }); + } + + // ====================================== + // notifications/push + // ====================================== + public async subscribePushNotification( + _subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, + _data?: { + alerts: { + follow?: boolean; + favourite?: boolean; + reblog?: boolean; + mention?: boolean; + poll?: boolean; + }; + } | null, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async getPushSubscription(): Promise< + Response + > { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async updatePushSubscription( + _data?: { + alerts: { + follow?: boolean; + favourite?: boolean; + reblog?: boolean; + mention?: boolean; + poll?: boolean; + }; + } | null, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + /** + * DELETE /api/v1/push/subscription + */ + public async deletePushSubscription(): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // search + // ====================================== + public async search( + q: string, + type: "accounts" | "hashtags" | "statuses", + options?: { + limit?: number; + max_id?: string; + min_id?: string; + resolve?: boolean; + offset?: number; + following?: boolean; + account_id?: string; + exclude_unreviewed?: boolean; + }, + ): Promise> { + const accountCache = this.getFreshAccountCache(); + + switch (type) { + case "accounts": { + if (q.startsWith("http://") || q.startsWith("https://")) { + return this.client + .post("/api/ap/show", { uri: q }) + .then(async (res) => { + if (res.status != 200 || res.data.type != "User") { + res.status = 200; + res.statusText = "OK"; + res.data = { + accounts: [], + statuses: [], + hashtags: [], + }; + + return res; + } + + const account = await this.converter.userDetail( + res.data.object as MisskeyAPI.Entity.UserDetail, + this.baseUrlToHost(this.baseUrl), + ); + + return { + ...res, + data: { + accounts: + options?.max_id && options?.max_id >= account.id + ? [] + : [account], + statuses: [], + hashtags: [], + }, + }; + }); + } + let params = { + query: q, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } else { + params = Object.assign(params, { + limit: 20, + }); + } + if (options.offset) { + params = Object.assign(params, { + offset: options.offset, + }); + } + if (options.resolve) { + params = Object.assign(params, { + localOnly: options.resolve, + }); + } + } else { + params = Object.assign(params, { + limit: 20, + }); + } + + try { + const match = q.match( + /^@(?[a-zA-Z0-9_]+)(?:@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)|)$/, + ); + if (match) { + const lookupQuery = { + username: match.groups?.user, + host: match.groups?.host, + }; + + const result = await this.client + .post( + "/api/users/show", + lookupQuery, + ) + .then((res) => ({ + ...res, + data: { + accounts: [ + this.converter.userDetail( + res.data, + this.baseUrlToHost(this.baseUrl), + ), + ], + statuses: [], + hashtags: [], + }, + })); + + if (result.status !== 200) { + result.status = 200; + result.statusText = "OK"; + result.data = { + accounts: [], + statuses: [], + hashtags: [], + }; + } + + return result; + } + } catch {} + + return this.client + .post>( + "/api/users/search", + params, + ) + .then((res) => ({ + ...res, + data: { + accounts: res.data.map((u) => + this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), + ), + statuses: [], + hashtags: [], + }, + })); + } + case "statuses": { + if (q.startsWith("http://") || q.startsWith("https://")) { + return this.client + .post("/api/ap/show", { uri: q }) + .then(async (res) => { + if (res.status != 200 || res.data.type != "Note") { + res.status = 200; + res.statusText = "OK"; + res.data = { + accounts: [], + statuses: [], + hashtags: [], + }; + + return res; + } + + const post = await this.noteWithDetails( + res.data.object as MisskeyAPI.Entity.Note, + this.baseUrlToHost(this.baseUrl), + accountCache, + ); + + return { + ...res, + data: { + accounts: [], + statuses: + options?.max_id && options.max_id >= post.id ? [] : [post], + hashtags: [], + }, + }; + }); + } + let params = { + query: q, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } + if (options.offset) { + params = Object.assign(params, { + offset: options.offset, + }); + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id, + }); + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id, + }); + } + if (options.account_id) { + params = Object.assign(params, { + userId: options.account_id, + }); + } + } + return this.client + .post>("/api/notes/search", params) + .then(async (res) => ({ + ...res, + data: { + accounts: [], + statuses: await Promise.all( + res.data.map((n) => + this.noteWithDetails( + n, + this.baseUrlToHost(this.baseUrl), + accountCache, + ), + ), + ), + hashtags: [], + }, + })); + } + case "hashtags": { + let params = { + query: q, + }; + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit, + }); + } + if (options.offset) { + params = Object.assign(params, { + offset: options.offset, + }); + } + } + return this.client + .post>("/api/hashtags/search", params) + .then((res) => ({ + ...res, + data: { + accounts: [], + statuses: [], + hashtags: res.data.map((h) => ({ + name: h, + url: h, + history: null, + following: false, + })), + }, + })); + } + } + } + + // ====================================== + // instance + // ====================================== + /** + * POST /api/meta + * POST /api/stats + */ + public async getInstance(): Promise> { + const meta = await this.client + .post("/api/meta", { "detail": true }) + .then((res) => res.data); + return this.client + .post("/api/stats", { "detail": true }) + .then((res) => ({ ...res, data: this.converter.meta(meta, res.data) })); + } + + public async getInstancePeers(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public async getInstanceActivity(): Promise< + Response> + > { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // instance/trends + // ====================================== + /** + * POST /api/hashtags/trend + */ + public async getInstanceTrends( + _limit?: number | null, + ): Promise>> { + return this.client + .post>("/api/hashtags/trend") + .then((res) => ({ + ...res, + data: res.data.map((h) => this.converter.hashtag(h)), + })); + } + + // ====================================== + // instance/directory + // ====================================== + public async getInstanceDirectory(_options?: { + limit?: number; + offset?: number; + order?: "active" | "new"; + local?: boolean; + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + // ====================================== + // instance/custom_emojis + // ====================================== + /** + * POST /api/meta + */ + public async getInstanceCustomEmojis(): Promise< + Response> + > { + return this.client + .post("/api/emojis") + .then((res) => ({ + ...res, + data: res.data.emojis.map((e: any) => this.converter.emoji(e)), + })); + } + + // ====================================== + // instance/announcements + // ====================================== + public async getInstanceAnnouncements( + with_dismissed?: boolean | null, + ): Promise>> { + let params = {}; + if (with_dismissed) { + params = Object.assign(params, { + withUnreads: with_dismissed, + }); + } + return this.client + .post>("/api/announcements", params) + .then((res) => ({ + ...res, + data: res.data.map((t) => this.converter.announcement(t)), + })); + } + + public async dismissInstanceAnnouncement(id: string): Promise> { + return this.client.post<{}>("/api/i/read-announcement", { + announcementId: id, + }); + } + + // ====================================== + // Emoji reactions + // ====================================== + /** + * POST /api/notes/reactions/create + * + * @param {string} id Target note ID. + * @param {string} emoji Reaction emoji string. This string is raw unicode emoji. + */ + public async createEmojiReaction( + id: string, + emoji: string, + ): Promise> { + await this.client.post<{}>("/api/notes/reactions/create", { + noteId: id, + reaction: emoji, + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + /** + * POST /api/notes/reactions/delete + */ + public async deleteEmojiReaction( + id: string, + _emoji: string, + ): Promise> { + await this.client.post<{}>("/api/notes/reactions/delete", { + noteId: id, + }); + return this.client + .post("/api/notes/show", { + noteId: id, + }) + .then(async (res) => ({ + ...res, + data: await this.noteWithDetails( + res.data, + this.baseUrlToHost(this.baseUrl), + this.getFreshAccountCache(), + ), + })); + } + + public async getEmojiReactions( + id: string, + ): Promise>> { + return this.client + .post>("/api/notes/reactions", { + noteId: id, + }) + .then((res) => ({ + ...res, + data: this.converter.reactions(res.data), + })); + } + + public async getEmojiReaction( + _id: string, + _emoji: string, + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError("misskey does not support"); + reject(err); + }); + } + + public userSocket(): WebSocketInterface { + return this.client.socket("user"); + } + + public publicSocket(): WebSocketInterface { + return this.client.socket("globalTimeline"); + } + + public localSocket(): WebSocketInterface { + return this.client.socket("localTimeline"); + } + + public tagSocket(_tag: string): WebSocketInterface { + throw new NoImplementedError("TODO: implement"); + } + + public listSocket(list_id: string): WebSocketInterface { + return this.client.socket("list", list_id); + } + + public directSocket(): WebSocketInterface { + return this.client.socket("conversation"); + } +} diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts new file mode 100644 index 0000000000..a0b01030d8 --- /dev/null +++ b/packages/megalodon/src/misskey/api_client.ts @@ -0,0 +1,727 @@ +import axios, { AxiosResponse, AxiosRequestConfig } from "axios"; +import dayjs from "dayjs"; +import FormData from "form-data"; + +import { DEFAULT_UA } from "../default"; +import proxyAgent, { ProxyConfig } from "../proxy_config"; +import Response from "../response"; +import MisskeyEntity from "./entity"; +import MegalodonEntity from "../entity"; +import WebSocket from "./web_socket"; +import MisskeyNotificationType from "./notification"; +import NotificationType from "../notification"; + +namespace MisskeyAPI { + export namespace Entity { + export type App = MisskeyEntity.App; + export type Announcement = MisskeyEntity.Announcement; + export type Blocking = MisskeyEntity.Blocking; + export type Choice = MisskeyEntity.Choice; + export type CreatedNote = MisskeyEntity.CreatedNote; + export type Emoji = MisskeyEntity.Emoji; + export type Favorite = MisskeyEntity.Favorite; + export type Field = MisskeyEntity.Field; + export type File = MisskeyEntity.File; + export type Follower = MisskeyEntity.Follower; + export type Following = MisskeyEntity.Following; + export type FollowRequest = MisskeyEntity.FollowRequest; + export type Hashtag = MisskeyEntity.Hashtag; + export type List = MisskeyEntity.List; + export type Meta = MisskeyEntity.Meta; + export type Mute = MisskeyEntity.Mute; + export type Note = MisskeyEntity.Note; + export type Notification = MisskeyEntity.Notification; + export type Poll = MisskeyEntity.Poll; + export type Reaction = MisskeyEntity.Reaction; + export type Relation = MisskeyEntity.Relation; + export type User = MisskeyEntity.User; + export type UserDetail = MisskeyEntity.UserDetail; + export type UserDetailMe = MisskeyEntity.UserDetailMe; + export type GetAll = MisskeyEntity.GetAll; + export type UserKey = MisskeyEntity.UserKey; + export type Session = MisskeyEntity.Session; + export type Stats = MisskeyEntity.Stats; + export type State = MisskeyEntity.State; + export type APIEmoji = { emojis: Emoji[] }; + } + + export class Converter { + private baseUrl: string; + private instanceHost: string; + private plcUrl: string; + private modelOfAcct = { + id: "1", + username: "none", + acct: "none", + display_name: "none", + locked: true, + bot: true, + discoverable: false, + group: false, + created_at: "1971-01-01T00:00:00.000Z", + note: "", + url: "plc", + avatar: "plc", + avatar_static: "plc", + header: "plc", + header_static: "plc", + followers_count: -1, + following_count: 0, + statuses_count: 0, + last_status_at: "1971-01-01T00:00:00.000Z", + noindex: true, + emojis: [], + fields: [], + moved: null, + }; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + this.instanceHost = baseUrl.substring(baseUrl.indexOf("//") + 2); + this.plcUrl = `${baseUrl}/static-assets/transparent.png`; + this.modelOfAcct.url = this.plcUrl; + this.modelOfAcct.avatar = this.plcUrl; + this.modelOfAcct.avatar_static = this.plcUrl; + this.modelOfAcct.header = this.plcUrl; + this.modelOfAcct.header_static = this.plcUrl; + } + + // FIXME: Properly render MFM instead of just escaping HTML characters. + escapeMFM = (text: string): string => + text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/`/g, "`") + .replace(/\r?\n/g, "
"); + + emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => { + return { + shortcode: e.name, + static_url: e.url, + url: e.url, + visible_in_picker: true, + category: e.category, + }; + }; + + field = (f: Entity.Field): MegalodonEntity.Field => ({ + name: f.name, + value: this.escapeMFM(f.value), + verified_at: null, + }); + + user = (u: Entity.User): MegalodonEntity.Account => { + let acct = u.username; + let acctUrl = `https://${u.host || this.instanceHost}/@${u.username}`; + if (u.host) { + acct = `${u.username}@${u.host}`; + acctUrl = `https://${u.host}/@${u.username}`; + } + return { + id: u.id, + username: u.username, + acct: acct, + display_name: u.name || u.username, + locked: false, + created_at: new Date().toISOString(), + followers_count: 0, + following_count: 0, + statuses_count: 0, + note: "", + url: acctUrl, + avatar: u.avatarUrl, + avatar_static: u.avatarUrl, + header: this.plcUrl, + header_static: this.plcUrl, + emojis: u.emojis.map((e) => this.emoji(e)), + moved: null, + fields: [], + bot: false, + }; + }; + + userDetail = ( + u: Entity.UserDetail, + host: string, + ): MegalodonEntity.Account => { + let acct = u.username; + host = host.replace("https://", ""); + let acctUrl = `https://${host || u.host || this.instanceHost}/@${ + u.username + }`; + if (u.host) { + acct = `${u.username}@${u.host}`; + acctUrl = `https://${u.host}/@${u.username}`; + } + return { + id: u.id, + username: u.username, + acct: acct, + display_name: u.name || u.username, + locked: u.isLocked, + created_at: u.createdAt, + followers_count: u.followersCount, + following_count: u.followingCount, + statuses_count: u.notesCount, + note: u.description?.replace(/\n|\\n/g, "
") ?? "", + url: acctUrl, + avatar: u.avatarUrl, + avatar_static: u.avatarUrl, + header: u.bannerUrl ?? this.plcUrl, + header_static: u.bannerUrl ?? this.plcUrl, + emojis: u.emojis && u.emojis.length > 0 ? u.emojis.map((e) => this.emoji(e)) : [], + moved: null, + fields: u.fields.map((f) => this.field(f)), + bot: u.isBot, + }; + }; + + userPreferences = ( + u: MisskeyAPI.Entity.UserDetailMe, + v: "public" | "unlisted" | "private" | "direct", + ): MegalodonEntity.Preferences => { + return { + "reading:expand:media": "default", + "reading:expand:spoilers": false, + "posting:default:language": u.lang, + "posting:default:sensitive": u.alwaysMarkNsfw, + "posting:default:visibility": v, + }; + }; + + visibility = ( + v: "public" | "home" | "followers" | "specified", + ): "public" | "unlisted" | "private" | "direct" => { + switch (v) { + case "public": + return v; + case "home": + return "unlisted"; + case "followers": + return "private"; + case "specified": + return "direct"; + } + }; + + encodeVisibility = ( + v: "public" | "unlisted" | "private" | "direct", + ): "public" | "home" | "followers" | "specified" => { + switch (v) { + case "public": + return v; + case "unlisted": + return "home"; + case "private": + return "followers"; + case "direct": + return "specified"; + } + }; + + fileType = ( + s: string, + ): "unknown" | "image" | "gifv" | "video" | "audio" => { + if (s === "image/gif") { + return "gifv"; + } + if (s.includes("image")) { + return "image"; + } + if (s.includes("video")) { + return "video"; + } + if (s.includes("audio")) { + return "audio"; + } + return "unknown"; + }; + + file = (f: Entity.File): MegalodonEntity.Attachment => { + return { + id: f.id, + type: this.fileType(f.type), + url: f.url, + remote_url: f.url, + preview_url: f.thumbnailUrl, + text_url: f.url, + meta: { + width: f.properties.width, + height: f.properties.height, + }, + description: f.comment, + blurhash: f.blurhash, + }; + }; + + follower = (f: Entity.Follower): MegalodonEntity.Account => { + return this.user(f.follower); + }; + + following = (f: Entity.Following): MegalodonEntity.Account => { + return this.user(f.followee); + }; + + relation = (r: Entity.Relation): MegalodonEntity.Relationship => { + return { + id: r.id, + following: r.isFollowing, + followed_by: r.isFollowed, + blocking: r.isBlocking, + blocked_by: r.isBlocked, + muting: r.isMuted, + muting_notifications: false, + requested: r.hasPendingFollowRequestFromYou, + domain_blocking: false, + showing_reblogs: true, + endorsed: false, + notifying: false, + }; + }; + + choice = (c: Entity.Choice): MegalodonEntity.PollOption => { + return { + title: c.text, + votes_count: c.votes, + }; + }; + + poll = (p: Entity.Poll, id: string): MegalodonEntity.Poll => { + const now = dayjs(); + const expire = dayjs(p.expiresAt); + const count = p.choices.reduce((sum, choice) => sum + choice.votes, 0); + return { + id: id, + expires_at: p.expiresAt, + expired: now.isAfter(expire), + multiple: p.multiple, + votes_count: count, + options: p.choices.map((c) => this.choice(c)), + voted: p.choices.some((c) => c.isVoted), + own_votes: p.choices + .filter((c) => c.isVoted) + .map((c) => p.choices.indexOf(c)), + }; + }; + + note = (n: Entity.Note, host: string): MegalodonEntity.Status => { + host = host.replace("https://", ""); + + return { + id: n.id, + uri: n.uri ? n.uri : `https://${host}/notes/${n.id}`, + url: n.uri ? n.uri : `https://${host}/notes/${n.id}`, + account: this.user(n.user), + in_reply_to_id: n.replyId, + in_reply_to_account_id: n.reply?.userId ?? null, + reblog: n.renote ? this.note(n.renote, host) : null, + content: n.text ? this.escapeMFM(n.text) : "", + plain_content: n.text ? n.text : null, + created_at: n.createdAt, + // Remove reaction emojis with names containing @ from the emojis list. + emojis: n.emojis + .filter((e) => e.name.indexOf("@") === -1) + .map((e) => this.emoji(e)), + replies_count: n.repliesCount, + reblogs_count: n.renoteCount, + favourites_count: this.getTotalReactions(n.reactions), + reblogged: false, + favourited: !!n.myReaction, + muted: false, + sensitive: n.files ? n.files.some((f) => f.isSensitive) : false, + spoiler_text: n.cw ? n.cw : "", + visibility: this.visibility(n.visibility), + media_attachments: n.files ? n.files.map((f) => this.file(f)) : [], + mentions: [], + tags: [], + card: null, + poll: n.poll ? this.poll(n.poll, n.id) : null, + application: null, + language: null, + pinned: null, + // Use emojis list to provide URLs for emoji reactions. + reactions: this.mapReactions(n.emojis, n.reactions, n.myReaction), + bookmarked: false, + quote: n.renote && n.text ? this.note(n.renote, host) : null, + }; + }; + + mapReactions = ( + emojis: Array, + r: { [key: string]: number }, + myReaction?: string, + ): Array => { + // Map of emoji shortcodes to image URLs. + const emojiUrls = new Map( + emojis.map((e) => [e.name, e.url]), + ); + return Object.keys(r).map((key) => { + // Strip colons from custom emoji reaction names to match emoji shortcodes. + const shortcode = key.replaceAll(":", ""); + // If this is a custom emoji (vs. a Unicode emoji), find its image URL. + const url = emojiUrls.get(shortcode); + // Finally, remove trailing @. from local custom emoji reaction names. + const name = shortcode.replace("@.", ""); + return { + count: r[key], + me: key === myReaction, + name, + url, + // We don't actually have a static version of the asset, but clients expect one anyway. + static_url: url, + }; + }); + }; + + getTotalReactions = (r: { [key: string]: number }): number => { + return Object.values(r).length > 0 + ? Object.values(r).reduce( + (previousValue, currentValue) => previousValue + currentValue, + ) + : 0; + }; + + reactions = ( + r: Array, + ): Array => { + const result: Array = []; + for (const e of r) { + const i = result.findIndex((res) => res.name === e.type); + if (i >= 0) { + result[i].count++; + } else { + result.push({ + count: 1, + me: false, + name: e.type, + }); + } + } + return result; + }; + + noteToConversation = ( + n: Entity.Note, + host: string, + ): MegalodonEntity.Conversation => { + const accounts: Array = [this.user(n.user)]; + if (n.reply) { + accounts.push(this.user(n.reply.user)); + } + return { + id: n.id, + accounts: accounts, + last_status: this.note(n, host), + unread: false, + }; + }; + + list = (l: Entity.List): MegalodonEntity.List => ({ + id: l.id, + title: l.name, + }); + + encodeNotificationType = ( + e: MegalodonEntity.NotificationType, + ): MisskeyEntity.NotificationType => { + switch (e) { + case NotificationType.Follow: + return MisskeyNotificationType.Follow; + case NotificationType.Mention: + return MisskeyNotificationType.Reply; + case NotificationType.Favourite: + case NotificationType.Reaction: + return MisskeyNotificationType.Reaction; + case NotificationType.Reblog: + return MisskeyNotificationType.Renote; + case NotificationType.Poll: + return MisskeyNotificationType.PollEnded; + case NotificationType.FollowRequest: + return MisskeyNotificationType.ReceiveFollowRequest; + default: + return e; + } + }; + + decodeNotificationType = ( + e: MisskeyEntity.NotificationType, + ): MegalodonEntity.NotificationType => { + switch (e) { + case MisskeyNotificationType.Follow: + return NotificationType.Follow; + case MisskeyNotificationType.Mention: + case MisskeyNotificationType.Reply: + return NotificationType.Mention; + case MisskeyNotificationType.Renote: + case MisskeyNotificationType.Quote: + return NotificationType.Reblog; + case MisskeyNotificationType.Reaction: + return NotificationType.Reaction; + case MisskeyNotificationType.PollEnded: + return NotificationType.Poll; + case MisskeyNotificationType.ReceiveFollowRequest: + return NotificationType.FollowRequest; + case MisskeyNotificationType.FollowRequestAccepted: + return NotificationType.Follow; + default: + return e; + } + }; + + announcement = (a: Entity.Announcement): MegalodonEntity.Announcement => ({ + id: a.id, + content: `

${this.escapeMFM(a.title)}

${this.escapeMFM(a.text)}`, + starts_at: null, + ends_at: null, + published: true, + all_day: false, + published_at: a.createdAt, + updated_at: a.updatedAt, + read: a.isRead, + mentions: [], + statuses: [], + tags: [], + emojis: [], + reactions: [], + }); + + notification = ( + n: Entity.Notification, + host: string, + ): MegalodonEntity.Notification => { + let notification = { + id: n.id, + account: n.user ? this.user(n.user) : this.modelOfAcct, + created_at: n.createdAt, + type: this.decodeNotificationType(n.type), + }; + if (n.note) { + notification = Object.assign(notification, { + status: this.note(n.note, host), + }); + if (notification.type === NotificationType.Poll) { + notification = Object.assign(notification, { + account: this.note(n.note, host).account, + }); + } + if (n.reaction) { + notification = Object.assign(notification, { + reaction: this.mapReactions(n.note.emojis, { [n.reaction]: 1 })[0], + }); + } + } + return notification; + }; + + stats = (s: Entity.Stats): MegalodonEntity.Stats => { + return { + user_count: s.usersCount, + status_count: s.notesCount, + domain_count: s.instances, + }; + }; + + meta = (m: Entity.Meta, s: Entity.Stats): MegalodonEntity.Instance => { + const wss = m.uri.replace(/^https:\/\//, "wss://"); + return { + uri: m.uri, + title: m.name, + description: m.description, + email: m.maintainerEmail, + version: m.version, + thumbnail: m.bannerUrl, + urls: { + streaming_api: `${wss}/streaming`, + }, + stats: this.stats(s), + languages: m.langs, + contact_account: null, + max_toot_chars: m.maxNoteTextLength, + registrations: !m.disableRegistration, + }; + }; + + hashtag = (h: Entity.Hashtag): MegalodonEntity.Tag => { + return { + name: h.tag, + url: h.tag, + history: null, + following: false, + }; + }; + } + + export const DEFAULT_SCOPE = [ + "read:account", + "write:account", + "read:blocks", + "write:blocks", + "read:drive", + "write:drive", + "read:favorites", + "write:favorites", + "read:following", + "write:following", + "read:mutes", + "write:mutes", + "write:notes", + "read:notifications", + "write:notifications", + "read:reactions", + "write:reactions", + "write:votes", + ]; + + /** + * Interface + */ + export interface Interface { + post( + path: string, + params?: any, + headers?: { [key: string]: string }, + ): Promise>; + cancel(): void; + socket( + channel: + | "user" + | "localTimeline" + | "hybridTimeline" + | "globalTimeline" + | "conversation" + | "list", + listId?: string, + ): WebSocket; + } + + /** + * Misskey API client. + * + * Usign axios for request, you will handle promises. + */ + export class Client implements Interface { + private accessToken: string | null; + private baseUrl: string; + private userAgent: string; + private abortController: AbortController; + private proxyConfig: ProxyConfig | false = false; + private converter: Converter; + + /** + * @param baseUrl hostname or base URL + * @param accessToken access token from OAuth2 authorization + * @param userAgent UserAgent is specified in header on request. + * @param proxyConfig Proxy setting, or set false if don't use proxy. + * @param converter Converter instance. + */ + constructor( + baseUrl: string, + accessToken: string | null, + userAgent: string = DEFAULT_UA, + proxyConfig: ProxyConfig | false = false, + converter: Converter, + ) { + this.accessToken = accessToken; + this.baseUrl = baseUrl; + this.userAgent = userAgent; + this.proxyConfig = proxyConfig; + this.abortController = new AbortController(); + this.converter = converter; + axios.defaults.signal = this.abortController.signal; + } + + /** + * POST request to mastodon REST API. + * @param path relative path from baseUrl + * @param params Form data + * @param headers Request header object + */ + public async post( + path: string, + params: any = {}, + headers: { [key: string]: string } = {}, + ): Promise> { + let options: AxiosRequestConfig = { + headers: headers, + maxContentLength: Infinity, + maxBodyLength: Infinity, + }; + if (this.proxyConfig) { + options = Object.assign(options, { + httpAgent: proxyAgent(this.proxyConfig), + httpsAgent: proxyAgent(this.proxyConfig), + }); + } + let bodyParams = params; + if (this.accessToken) { + if (params instanceof FormData) { + bodyParams.append("i", this.accessToken); + } else { + bodyParams = Object.assign(params, { + i: this.accessToken, + }); + } + } + + return axios + .post(this.baseUrl + path, bodyParams, options) + .then((resp: AxiosResponse) => { + const res: Response = { + data: resp.data, + status: resp.status, + statusText: resp.statusText, + headers: resp.headers, + }; + return res; + }); + } + + /** + * Cancel all requests in this instance. + * @returns void + */ + public cancel(): void { + return this.abortController.abort(); + } + + /** + * Get connection and receive websocket connection for Misskey API. + * + * @param channel Channel name is user, localTimeline, hybridTimeline, globalTimeline, conversation or list. + * @param listId This parameter is required only list channel. + */ + public socket( + channel: + | "user" + | "localTimeline" + | "hybridTimeline" + | "globalTimeline" + | "conversation" + | "list", + listId?: string, + ): WebSocket { + if (!this.accessToken) { + throw new Error("accessToken is required"); + } + const url = `${this.baseUrl}/streaming`; + const streaming = new WebSocket( + url, + channel, + this.accessToken, + listId, + this.userAgent, + this.proxyConfig, + this.converter, + ); + process.nextTick(() => { + streaming.start(); + }); + return streaming; + } + } +} + +export default MisskeyAPI; diff --git a/packages/megalodon/src/misskey/entities/GetAll.ts b/packages/megalodon/src/misskey/entities/GetAll.ts new file mode 100644 index 0000000000..94ace2f184 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/GetAll.ts @@ -0,0 +1,6 @@ +namespace MisskeyEntity { + export type GetAll = { + tutorial: number; + defaultNoteVisibility: "public" | "home" | "followers" | "specified"; + }; +} diff --git a/packages/megalodon/src/misskey/entities/announcement.ts b/packages/megalodon/src/misskey/entities/announcement.ts new file mode 100644 index 0000000000..7594ba7efc --- /dev/null +++ b/packages/megalodon/src/misskey/entities/announcement.ts @@ -0,0 +1,10 @@ +namespace MisskeyEntity { + export type Announcement = { + id: string; + createdAt: string; + updatedAt: string; + text: string; + title: string; + isRead?: boolean; + }; +} diff --git a/packages/megalodon/src/misskey/entities/app.ts b/packages/megalodon/src/misskey/entities/app.ts new file mode 100644 index 0000000000..5924060d81 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/app.ts @@ -0,0 +1,9 @@ +namespace MisskeyEntity { + export type App = { + id: string; + name: string; + callbackUrl: string; + permission: Array; + secret: string; + }; +} diff --git a/packages/megalodon/src/misskey/entities/blocking.ts b/packages/megalodon/src/misskey/entities/blocking.ts new file mode 100644 index 0000000000..3e56790a7b --- /dev/null +++ b/packages/megalodon/src/misskey/entities/blocking.ts @@ -0,0 +1,10 @@ +/// + +namespace MisskeyEntity { + export type Blocking = { + id: string; + createdAt: string; + blockeeId: string; + blockee: UserDetail; + }; +} diff --git a/packages/megalodon/src/misskey/entities/createdNote.ts b/packages/megalodon/src/misskey/entities/createdNote.ts new file mode 100644 index 0000000000..235f7063fb --- /dev/null +++ b/packages/megalodon/src/misskey/entities/createdNote.ts @@ -0,0 +1,7 @@ +/// + +namespace MisskeyEntity { + export type CreatedNote = { + createdNote: Note; + }; +} diff --git a/packages/megalodon/src/misskey/entities/emoji.ts b/packages/megalodon/src/misskey/entities/emoji.ts new file mode 100644 index 0000000000..d320760e91 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/emoji.ts @@ -0,0 +1,9 @@ +namespace MisskeyEntity { + export type Emoji = { + name: string; + host: string | null; + url: string; + aliases: Array; + category: string; + }; +} diff --git a/packages/megalodon/src/misskey/entities/favorite.ts b/packages/megalodon/src/misskey/entities/favorite.ts new file mode 100644 index 0000000000..ba948f2e73 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/favorite.ts @@ -0,0 +1,10 @@ +/// + +namespace MisskeyEntity { + export type Favorite = { + id: string; + createdAt: string; + noteId: string; + note: Note; + }; +} diff --git a/packages/megalodon/src/misskey/entities/field.ts b/packages/megalodon/src/misskey/entities/field.ts new file mode 100644 index 0000000000..8bbb2d7c42 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/field.ts @@ -0,0 +1,7 @@ +namespace MisskeyEntity { + export type Field = { + name: string; + value: string; + verified?: string; + }; +} diff --git a/packages/megalodon/src/misskey/entities/file.ts b/packages/megalodon/src/misskey/entities/file.ts new file mode 100644 index 0000000000..e823dde1be --- /dev/null +++ b/packages/megalodon/src/misskey/entities/file.ts @@ -0,0 +1,20 @@ +namespace MisskeyEntity { + export type File = { + id: string; + createdAt: string; + name: string; + type: string; + md5: string; + size: number; + isSensitive: boolean; + properties: { + width: number; + height: number; + avgColor: string; + }; + url: string; + thumbnailUrl: string; + comment: string; + blurhash: string; + }; +} diff --git a/packages/megalodon/src/misskey/entities/followRequest.ts b/packages/megalodon/src/misskey/entities/followRequest.ts new file mode 100644 index 0000000000..60bd0e0abc --- /dev/null +++ b/packages/megalodon/src/misskey/entities/followRequest.ts @@ -0,0 +1,9 @@ +/// + +namespace MisskeyEntity { + export type FollowRequest = { + id: string; + follower: User; + followee: User; + }; +} diff --git a/packages/megalodon/src/misskey/entities/follower.ts b/packages/megalodon/src/misskey/entities/follower.ts new file mode 100644 index 0000000000..34ae825519 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/follower.ts @@ -0,0 +1,11 @@ +/// + +namespace MisskeyEntity { + export type Follower = { + id: string; + createdAt: string; + followeeId: string; + followerId: string; + follower: UserDetail; + }; +} diff --git a/packages/megalodon/src/misskey/entities/following.ts b/packages/megalodon/src/misskey/entities/following.ts new file mode 100644 index 0000000000..6cbc8f1c39 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/following.ts @@ -0,0 +1,11 @@ +/// + +namespace MisskeyEntity { + export type Following = { + id: string; + createdAt: string; + followeeId: string; + followerId: string; + followee: UserDetail; + }; +} diff --git a/packages/megalodon/src/misskey/entities/hashtag.ts b/packages/megalodon/src/misskey/entities/hashtag.ts new file mode 100644 index 0000000000..3ec4d6675b --- /dev/null +++ b/packages/megalodon/src/misskey/entities/hashtag.ts @@ -0,0 +1,7 @@ +namespace MisskeyEntity { + export type Hashtag = { + tag: string; + chart: Array; + usersCount: number; + }; +} diff --git a/packages/megalodon/src/misskey/entities/list.ts b/packages/megalodon/src/misskey/entities/list.ts new file mode 100644 index 0000000000..60706592a4 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/list.ts @@ -0,0 +1,8 @@ +namespace MisskeyEntity { + export type List = { + id: string; + createdAt: string; + name: string; + userIds: Array; + }; +} diff --git a/packages/megalodon/src/misskey/entities/meta.ts b/packages/megalodon/src/misskey/entities/meta.ts new file mode 100644 index 0000000000..97827fe8fd --- /dev/null +++ b/packages/megalodon/src/misskey/entities/meta.ts @@ -0,0 +1,18 @@ +/// + +namespace MisskeyEntity { + export type Meta = { + maintainerName: string; + maintainerEmail: string; + name: string; + version: string; + uri: string; + description: string; + langs: Array; + disableRegistration: boolean; + disableLocalTimeline: boolean; + bannerUrl: string; + maxNoteTextLength: 3000; + emojis: Array; + }; +} diff --git a/packages/megalodon/src/misskey/entities/mute.ts b/packages/megalodon/src/misskey/entities/mute.ts new file mode 100644 index 0000000000..7975b3d315 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/mute.ts @@ -0,0 +1,10 @@ +/// + +namespace MisskeyEntity { + export type Mute = { + id: string; + createdAt: string; + muteeId: string; + mutee: UserDetail; + }; +} diff --git a/packages/megalodon/src/misskey/entities/note.ts b/packages/megalodon/src/misskey/entities/note.ts new file mode 100644 index 0000000000..64a0a50785 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/note.ts @@ -0,0 +1,32 @@ +/// +/// +/// +/// + +namespace MisskeyEntity { + export type Note = { + id: string; + createdAt: string; + userId: string; + user: User; + text: string | null; + cw: string | null; + visibility: "public" | "home" | "followers" | "specified"; + renoteCount: number; + repliesCount: number; + reactions: { [key: string]: number }; + emojis: Array; + fileIds: Array; + files: Array; + replyId: string | null; + renoteId: string | null; + uri?: string; + reply?: Note; + renote?: Note; + viaMobile?: boolean; + tags?: Array; + poll?: Poll; + mentions?: Array; + myReaction?: string; + }; +} diff --git a/packages/megalodon/src/misskey/entities/notification.ts b/packages/megalodon/src/misskey/entities/notification.ts new file mode 100644 index 0000000000..7ecb911537 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/notification.ts @@ -0,0 +1,17 @@ +/// +/// + +namespace MisskeyEntity { + export type Notification = { + id: string; + createdAt: string; + // https://github.com/syuilo/misskey/blob/056942391aee135eb6c77aaa63f6ed5741d701a6/src/models/entities/notification.ts#L50-L62 + type: NotificationType; + userId: string; + user: User; + note?: Note; + reaction?: string; + }; + + export type NotificationType = string; +} diff --git a/packages/megalodon/src/misskey/entities/poll.ts b/packages/megalodon/src/misskey/entities/poll.ts new file mode 100644 index 0000000000..9f6bfa40d2 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/poll.ts @@ -0,0 +1,13 @@ +namespace MisskeyEntity { + export type Choice = { + text: string; + votes: number; + isVoted: boolean; + }; + + export type Poll = { + multiple: boolean; + expiresAt: string; + choices: Array; + }; +} diff --git a/packages/megalodon/src/misskey/entities/reaction.ts b/packages/megalodon/src/misskey/entities/reaction.ts new file mode 100644 index 0000000000..b35a25bfb5 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/reaction.ts @@ -0,0 +1,11 @@ +/// + +namespace MisskeyEntity { + export type Reaction = { + id: string; + createdAt: string; + user: User; + url?: string; + type: string; + }; +} diff --git a/packages/megalodon/src/misskey/entities/relation.ts b/packages/megalodon/src/misskey/entities/relation.ts new file mode 100644 index 0000000000..6db4a1b167 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/relation.ts @@ -0,0 +1,12 @@ +namespace MisskeyEntity { + export type Relation = { + id: string; + isFollowing: boolean; + hasPendingFollowRequestFromYou: boolean; + hasPendingFollowRequestToYou: boolean; + isFollowed: boolean; + isBlocking: boolean; + isBlocked: boolean; + isMuted: boolean; + }; +} diff --git a/packages/megalodon/src/misskey/entities/session.ts b/packages/megalodon/src/misskey/entities/session.ts new file mode 100644 index 0000000000..572333ff0b --- /dev/null +++ b/packages/megalodon/src/misskey/entities/session.ts @@ -0,0 +1,6 @@ +namespace MisskeyEntity { + export type Session = { + token: string; + url: string; + }; +} diff --git a/packages/megalodon/src/misskey/entities/state.ts b/packages/megalodon/src/misskey/entities/state.ts new file mode 100644 index 0000000000..62d60ce282 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/state.ts @@ -0,0 +1,7 @@ +namespace MisskeyEntity { + export type State = { + isFavorited: boolean; + isMutedThread: boolean; + isWatching: boolean; + }; +} diff --git a/packages/megalodon/src/misskey/entities/stats.ts b/packages/megalodon/src/misskey/entities/stats.ts new file mode 100644 index 0000000000..9832a0ad8a --- /dev/null +++ b/packages/megalodon/src/misskey/entities/stats.ts @@ -0,0 +1,9 @@ +namespace MisskeyEntity { + export type Stats = { + notesCount: number; + originalNotesCount: number; + usersCount: number; + originalUsersCount: number; + instances: number; + }; +} diff --git a/packages/megalodon/src/misskey/entities/user.ts b/packages/megalodon/src/misskey/entities/user.ts new file mode 100644 index 0000000000..96610f6e6d --- /dev/null +++ b/packages/megalodon/src/misskey/entities/user.ts @@ -0,0 +1,13 @@ +/// + +namespace MisskeyEntity { + export type User = { + id: string; + name: string; + username: string; + host: string | null; + avatarUrl: string; + avatarColor: string; + emojis: Array; + }; +} diff --git a/packages/megalodon/src/misskey/entities/userDetail.ts b/packages/megalodon/src/misskey/entities/userDetail.ts new file mode 100644 index 0000000000..0f5bd5f644 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/userDetail.ts @@ -0,0 +1,34 @@ +/// +/// +/// + +namespace MisskeyEntity { + export type UserDetail = { + id: string; + name: string; + username: string; + host: string | null; + avatarUrl: string; + avatarColor: string; + isAdmin: boolean; + isModerator: boolean; + isBot: boolean; + isCat: boolean; + emojis: Array; + createdAt: string; + bannerUrl: string; + bannerColor: string; + isLocked: boolean; + isSilenced: boolean; + isSuspended: boolean; + description: string; + followersCount: number; + followingCount: number; + notesCount: number; + avatarId: string; + bannerId: string; + pinnedNoteIds?: Array; + pinnedNotes?: Array; + fields: Array; + }; +} diff --git a/packages/megalodon/src/misskey/entities/userDetailMe.ts b/packages/megalodon/src/misskey/entities/userDetailMe.ts new file mode 100644 index 0000000000..272e65ffa4 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/userDetailMe.ts @@ -0,0 +1,36 @@ +/// +/// +/// + +namespace MisskeyEntity { + export type UserDetailMe = { + id: string; + name: string; + username: string; + host: string | null; + avatarUrl: string; + avatarColor: string; + isAdmin: boolean; + isModerator: boolean; + isBot: boolean; + isCat: boolean; + emojis: Array; + createdAt: string; + bannerUrl: string; + bannerColor: string; + isLocked: boolean; + isSilenced: boolean; + isSuspended: boolean; + description: string; + followersCount: number; + followingCount: number; + notesCount: number; + avatarId: string; + bannerId: string; + pinnedNoteIds?: Array; + pinnedNotes?: Array; + fields: Array; + alwaysMarkNsfw: boolean; + lang: string | null; + }; +} diff --git a/packages/megalodon/src/misskey/entities/userkey.ts b/packages/megalodon/src/misskey/entities/userkey.ts new file mode 100644 index 0000000000..921af65536 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/userkey.ts @@ -0,0 +1,8 @@ +/// + +namespace MisskeyEntity { + export type UserKey = { + accessToken: string; + user: User; + }; +} diff --git a/packages/megalodon/src/misskey/entity.ts b/packages/megalodon/src/misskey/entity.ts new file mode 100644 index 0000000000..72a80f9d96 --- /dev/null +++ b/packages/megalodon/src/misskey/entity.ts @@ -0,0 +1,28 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// + +export default MisskeyEntity; diff --git a/packages/megalodon/src/misskey/notification.ts b/packages/megalodon/src/misskey/notification.ts new file mode 100644 index 0000000000..eb7c2d23d8 --- /dev/null +++ b/packages/megalodon/src/misskey/notification.ts @@ -0,0 +1,18 @@ +import MisskeyEntity from "./entity"; + +namespace MisskeyNotificationType { + export const Follow: MisskeyEntity.NotificationType = "follow"; + export const Mention: MisskeyEntity.NotificationType = "mention"; + export const Reply: MisskeyEntity.NotificationType = "reply"; + export const Renote: MisskeyEntity.NotificationType = "renote"; + export const Quote: MisskeyEntity.NotificationType = "quote"; + export const Reaction: MisskeyEntity.NotificationType = "favourite"; + export const PollEnded: MisskeyEntity.NotificationType = "pollEnded"; + export const ReceiveFollowRequest: MisskeyEntity.NotificationType = + "receiveFollowRequest"; + export const FollowRequestAccepted: MisskeyEntity.NotificationType = + "followRequestAccepted"; + export const GroupInvited: MisskeyEntity.NotificationType = "groupInvited"; +} + +export default MisskeyNotificationType; diff --git a/packages/megalodon/src/misskey/web_socket.ts b/packages/megalodon/src/misskey/web_socket.ts new file mode 100644 index 0000000000..0cbfc2bfeb --- /dev/null +++ b/packages/megalodon/src/misskey/web_socket.ts @@ -0,0 +1,458 @@ +import WS from "ws"; +import dayjs, { Dayjs } from "dayjs"; +import { v4 as uuid } from "uuid"; +import { EventEmitter } from "events"; +import { WebSocketInterface } from "../megalodon"; +import proxyAgent, { ProxyConfig } from "../proxy_config"; +import MisskeyAPI from "./api_client"; + +/** + * WebSocket + * Misskey is not support http streaming. It supports websocket instead of streaming. + * So this class connect to Misskey server with WebSocket. + */ +export default class WebSocket + extends EventEmitter + implements WebSocketInterface +{ + public url: string; + public channel: + | "user" + | "localTimeline" + | "hybridTimeline" + | "globalTimeline" + | "conversation" + | "list"; + public parser: any; + public headers: { [key: string]: string }; + public proxyConfig: ProxyConfig | false = false; + public listId: string | null = null; + private _converter: MisskeyAPI.Converter; + private _accessToken: string; + private _reconnectInterval: number; + private _reconnectMaxAttempts: number; + private _reconnectCurrentAttempts: number; + private _connectionClosed: boolean; + private _client: WS | null = null; + private _channelID: string; + private _pongReceivedTimestamp: Dayjs; + private _heartbeatInterval = 60000; + private _pongWaiting = false; + + /** + * @param url Full url of websocket: e.g. wss://misskey.io/streaming + * @param channel Channel name is user, localTimeline, hybridTimeline, globalTimeline, conversation or list. + * @param accessToken The access token. + * @param listId This parameter is required when you specify list as channel. + */ + constructor( + url: string, + channel: + | "user" + | "localTimeline" + | "hybridTimeline" + | "globalTimeline" + | "conversation" + | "list", + accessToken: string, + listId: string | undefined, + userAgent: string, + proxyConfig: ProxyConfig | false = false, + converter: MisskeyAPI.Converter, + ) { + super(); + this.url = url; + this.parser = new Parser(); + this.channel = channel; + this.headers = { + "User-Agent": userAgent, + }; + if (listId === undefined) { + this.listId = null; + } else { + this.listId = listId; + } + this.proxyConfig = proxyConfig; + this._accessToken = accessToken; + this._reconnectInterval = 10000; + this._reconnectMaxAttempts = Infinity; + this._reconnectCurrentAttempts = 0; + this._connectionClosed = false; + this._channelID = uuid(); + this._pongReceivedTimestamp = dayjs(); + this._converter = converter; + } + + /** + * Start websocket connection. + */ + public start() { + this._connectionClosed = false; + this._resetRetryParams(); + this._startWebSocketConnection(); + } + + private baseUrlToHost(baseUrl: string): string { + return baseUrl.replace("https://", ""); + } + + /** + * Reset connection and start new websocket connection. + */ + private _startWebSocketConnection() { + this._resetConnection(); + this._setupParser(); + this._client = this._connect(); + 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; + } + + /** + * Connect to the endpoint. + */ + private _connect(): WS { + let options: WS.ClientOptions = { + headers: this.headers, + }; + if (this.proxyConfig) { + options = Object.assign(options, { + agent: proxyAgent(this.proxyConfig), + }); + } + const cli: WS = new WS(`${this.url}?i=${this._accessToken}`, options); + return cli; + } + + /** + * Connect specified channels in websocket. + */ + private _channel() { + if (!this._client) { + return; + } + switch (this.channel) { + case "conversation": + this._client.send( + JSON.stringify({ + type: "connect", + body: { + channel: "main", + id: this._channelID, + }, + }), + ); + break; + case "user": + this._client.send( + JSON.stringify({ + type: "connect", + body: { + channel: "main", + id: this._channelID, + }, + }), + ); + this._client.send( + JSON.stringify({ + type: "connect", + body: { + channel: "homeTimeline", + id: this._channelID, + }, + }), + ); + break; + case "list": + this._client.send( + JSON.stringify({ + type: "connect", + body: { + channel: "userList", + id: this._channelID, + params: { + listId: this.listId, + }, + }, + }), + ); + break; + default: + this._client.send( + JSON.stringify({ + type: "connect", + body: { + channel: this.channel, + id: this._channelID, + }, + }), + ); + break; + } + } + + /** + * 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._bindSocket(this._client); + } + }, this._reconnectInterval); + } + + /** + * Clear binding event for websocket 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) => { + if (code === 1000) { + this.emit("close", {}); + } else { + console.log(`Closed connection with ${code}`); + 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", {}); + this._channel(); + // Call first ping event. + setTimeout(() => { + client.ping(""); + }, 10000); + }); + client.on("message", (data: WS.Data, isBinary: boolean) => { + this.parser.parse(data, isBinary, this._channelID); + }); + client.on("error", (err: Error) => { + this.emit("error", err); + }); + } + + /** + * Set up parser when receive message. + */ + private _setupParser() { + this.parser.on("update", (note: MisskeyAPI.Entity.Note) => { + this.emit( + "update", + this._converter.note(note, this.baseUrlToHost(this.url)), + ); + }); + this.parser.on( + "notification", + (notification: MisskeyAPI.Entity.Notification) => { + this.emit( + "notification", + this._converter.notification( + notification, + this.baseUrlToHost(this.url), + ), + ); + }, + ); + this.parser.on("conversation", (note: MisskeyAPI.Entity.Note) => { + this.emit( + "conversation", + this._converter.noteToConversation(note, this.baseUrlToHost(this.url)), + ); + }); + this.parser.on("error", (err: Error) => { + this.emit("parser-error", err); + }); + } + + /** + * 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. + * @param channelID Parse only messages which has same channelID. + */ + public parse(data: WS.Data, isBinary: boolean, channelID: string) { + const message = isBinary ? data : data.toString(); + if (typeof message !== "string") { + this.emit("heartbeat", {}); + return; + } + + if (message === "") { + this.emit("heartbeat", {}); + return; + } + + let obj: { + type: string; + body: { + id: string; + type: string; + body: any; + }; + }; + let body: { + id: string; + type: string; + body: any; + }; + + try { + obj = JSON.parse(message); + if (obj.type !== "channel") { + return; + } + if (!obj.body) { + return; + } + body = obj.body; + if (body.id !== channelID) { + return; + } + } catch (err) { + this.emit( + "error", + new Error( + `Error parsing websocket reply: ${message}, error message: ${err}`, + ), + ); + return; + } + + switch (body.type) { + case "note": + this.emit("update", body.body as MisskeyAPI.Entity.Note); + break; + case "notification": + this.emit("notification", body.body as MisskeyAPI.Entity.Notification); + break; + case "mention": { + const note = body.body as MisskeyAPI.Entity.Note; + if (note.visibility === "specified") { + this.emit("conversation", note); + } + break; + } + // When renote and followed event, the same notification will be received. + case "renote": + case "followed": + case "follow": + case "unfollow": + case "receiveFollowRequest": + case "meUpdated": + case "readAllNotifications": + case "readAllUnreadSpecifiedNotes": + case "readAllAntennas": + case "readAllUnreadMentions": + case "unreadNotification": + // Ignore these events + break; + default: + this.emit( + "error", + new Error(`Unknown event has received: ${JSON.stringify(body)}`), + ); + break; + } + } +} diff --git a/packages/megalodon/src/notification.ts b/packages/megalodon/src/notification.ts new file mode 100644 index 0000000000..84cd23e40d --- /dev/null +++ b/packages/megalodon/src/notification.ts @@ -0,0 +1,14 @@ +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 Reaction: Entity.NotificationType = "reaction"; + export const FollowRequest: Entity.NotificationType = "follow_request"; + export const Status: Entity.NotificationType = "status"; + export const Poll: Entity.NotificationType = "poll"; +} + +export default NotificationType; diff --git a/packages/megalodon/src/oauth.ts b/packages/megalodon/src/oauth.ts new file mode 100644 index 0000000000..f0df721f0a --- /dev/null +++ b/packages/megalodon/src/oauth.ts @@ -0,0 +1,123 @@ +/** + * OAuth + * Response data when oauth request. + **/ +namespace OAuth { + export type AppDataFromServer = { + id: string; + name: string; + website: string | null; + redirect_uri: string; + client_id: string; + client_secret: string; + }; + + export type TokenDataFromServer = { + access_token: string; + token_type: string; + scope: string; + created_at: number; + expires_in: number | null; + refresh_token: string | null; + }; + + export class AppData { + public url: string | null; + public session_token: string | null; + constructor( + public id: string, + public name: string, + public website: string | null, + public redirect_uri: string, + public client_id: string, + public client_secret: string, + ) { + this.url = null; + this.session_token = null; + } + + /** + * Serialize raw application data from server + * @param raw from server + */ + static from(raw: AppDataFromServer) { + return new this( + raw.id, + raw.name, + raw.website, + raw.redirect_uri, + raw.client_id, + raw.client_secret, + ); + } + + get redirectUri() { + return this.redirect_uri; + } + get clientId() { + return this.client_id; + } + get clientSecret() { + return this.client_secret; + } + } + + export class TokenData { + public _scope: string; + constructor( + public access_token: string, + public token_type: string, + scope: string, + public created_at: number, + public expires_in: number | null = null, + public refresh_token: string | null = null, + ) { + this._scope = scope; + } + + /** + * Serialize raw token data from server + * @param raw from server + */ + static from(raw: TokenDataFromServer) { + return new this( + raw.access_token, + raw.token_type, + raw.scope, + raw.created_at, + raw.expires_in, + raw.refresh_token, + ); + } + + /** + * OAuth Aceess Token + */ + get accessToken() { + return this.access_token; + } + get tokenType() { + return this.token_type; + } + get scope() { + return this._scope; + } + /** + * Application ID + */ + get createdAt() { + return this.created_at; + } + get expiresIn() { + return this.expires_in; + } + /** + * OAuth Refresh Token + */ + get refreshToken() { + return this.refresh_token; + } + } +} + +export default OAuth; diff --git a/packages/megalodon/src/parser.ts b/packages/megalodon/src/parser.ts new file mode 100644 index 0000000000..2ddf2ac2e6 --- /dev/null +++ b/packages/megalodon/src/parser.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from "events"; +import Entity from "./entity"; + +/** + * Parser + * Parse response data in streaming. + **/ +export class Parser extends EventEmitter { + private message: string; + + constructor() { + super(); + this.message = ""; + } + + public parse(chunk: string) { + // skip heartbeats + if (chunk === ":thump\n") { + this.emit("heartbeat", {}); + return; + } + + this.message += chunk; + chunk = this.message; + + const size: number = chunk.length; + let start = 0; + let offset = 0; + let curr: string | undefined; + let next: string | undefined; + + while (offset < size) { + curr = chunk[offset]; + next = chunk[offset + 1]; + + if (curr === "\n" && next === "\n") { + const piece: string = chunk.slice(start, offset); + + offset += 2; + start = offset; + + if (!piece.length) continue; // empty object + + const root: Array = piece.split("\n"); + + // should never happen, as long as mastodon doesn't change API messages + if (root.length !== 2) continue; + + // remove event and data markers + const event: string = root[0].substr(7); + const data: string = root[1].substr(6); + + let jsonObj = {}; + try { + jsonObj = JSON.parse(data); + } catch (err) { + // delete event does not have json object + if (event !== "delete") { + this.emit( + "error", + new Error( + `Error parsing API reply: '${piece}', error message: '${err}'`, + ), + ); + continue; + } + } + switch (event) { + case "update": + this.emit("update", jsonObj as Entity.Status); + break; + case "notification": + this.emit("notification", jsonObj as Entity.Notification); + break; + case "conversation": + this.emit("conversation", jsonObj as Entity.Conversation); + break; + case "delete": + // When delete, data is an ID of the deleted status + this.emit("delete", data); + break; + default: + this.emit( + "error", + new Error(`Unknown event has received: ${event}`), + ); + continue; + } + } + offset++; + } + this.message = chunk.slice(start, size); + } +} diff --git a/packages/megalodon/src/proxy_config.ts b/packages/megalodon/src/proxy_config.ts new file mode 100644 index 0000000000..fadbcf084e --- /dev/null +++ b/packages/megalodon/src/proxy_config.ts @@ -0,0 +1,92 @@ +import { HttpsProxyAgent, HttpsProxyAgentOptions } from "https-proxy-agent"; +import { SocksProxyAgent, SocksProxyAgentOptions } from "socks-proxy-agent"; + +export type ProxyConfig = { + host: string; + port: number; + auth?: { + username: string; + password: string; + }; + protocol: + | "http" + | "https" + | "socks4" + | "socks4a" + | "socks5" + | "socks5h" + | "socks"; +}; + +class ProxyProtocolError extends Error {} + +const proxyAgent = ( + proxyConfig: ProxyConfig, +): HttpsProxyAgent | SocksProxyAgent => { + switch (proxyConfig.protocol) { + case "http": { + let options: HttpsProxyAgentOptions = { + host: proxyConfig.host, + port: proxyConfig.port, + secureProxy: false, + }; + if (proxyConfig.auth) { + options = Object.assign(options, { + auth: `${proxyConfig.auth.username}:${proxyConfig.auth.password}`, + }); + } + const httpsAgent = new HttpsProxyAgent(options); + return httpsAgent; + } + case "https": { + let options: HttpsProxyAgentOptions = { + host: proxyConfig.host, + port: proxyConfig.port, + secureProxy: true, + }; + if (proxyConfig.auth) { + options = Object.assign(options, { + auth: `${proxyConfig.auth.username}:${proxyConfig.auth.password}`, + }); + } + const httpsAgent = new HttpsProxyAgent(options); + return httpsAgent; + } + case "socks4": + case "socks4a": { + let options: SocksProxyAgentOptions = { + type: 4, + hostname: proxyConfig.host, + port: proxyConfig.port, + }; + if (proxyConfig.auth) { + options = Object.assign(options, { + userId: proxyConfig.auth.username, + password: proxyConfig.auth.password, + }); + } + const socksAgent = new SocksProxyAgent(options); + return socksAgent; + } + case "socks5": + case "socks5h": + case "socks": { + let options: SocksProxyAgentOptions = { + type: 5, + hostname: proxyConfig.host, + port: proxyConfig.port, + }; + if (proxyConfig.auth) { + options = Object.assign(options, { + userId: proxyConfig.auth.username, + password: proxyConfig.auth.password, + }); + } + const socksAgent = new SocksProxyAgent(options); + return socksAgent; + } + default: + throw new ProxyProtocolError("protocol is not accepted"); + } +}; +export default proxyAgent; diff --git a/packages/megalodon/src/response.ts b/packages/megalodon/src/response.ts new file mode 100644 index 0000000000..13fd8ab574 --- /dev/null +++ b/packages/megalodon/src/response.ts @@ -0,0 +1,8 @@ +type Response = { + data: T; + status: number; + statusText: string; + headers: any; +}; + +export default Response; diff --git a/packages/megalodon/test/integration/megalodon.spec.ts b/packages/megalodon/test/integration/megalodon.spec.ts new file mode 100644 index 0000000000..8964535509 --- /dev/null +++ b/packages/megalodon/test/integration/megalodon.spec.ts @@ -0,0 +1,27 @@ +import { detector } from '../../src/index' + +describe('detector', () => { + describe('mastodon', () => { + const url = 'https://fedibird.com' + it('should be mastodon', async () => { + const mastodon = await detector(url) + expect(mastodon).toEqual('mastodon') + }) + }) + + describe('pleroma', () => { + const url = 'https://pleroma.soykaf.com' + it('should be pleroma', async () => { + const pleroma = await detector(url) + expect(pleroma).toEqual('pleroma') + }) + }) + + describe('misskey', () => { + const url = 'https://misskey.io' + it('should be misskey', async () => { + const misskey = await detector(url) + expect(misskey).toEqual('misskey') + }) + }) +}) diff --git a/packages/megalodon/test/integration/misskey.spec.ts b/packages/megalodon/test/integration/misskey.spec.ts new file mode 100644 index 0000000000..0ec1288428 --- /dev/null +++ b/packages/megalodon/test/integration/misskey.spec.ts @@ -0,0 +1,204 @@ +import MisskeyEntity from '@/misskey/entity' +import MisskeyNotificationType from '@/misskey/notification' +import Misskey from '@/misskey' +import MegalodonNotificationType from '@/notification' +import axios, { AxiosResponse } from 'axios' + +jest.mock('axios') + +const user: MisskeyEntity.User = { + id: '1', + name: 'test_user', + username: 'TestUser', + host: 'misskey.io', + avatarUrl: 'https://example.com/icon.png', + avatarColor: '#000000', + emojis: [] +} + +const note: MisskeyEntity.Note = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: '1', + user: user, + text: 'hogehoge', + cw: null, + visibility: 'public', + renoteCount: 0, + repliesCount: 0, + reactions: {}, + emojis: [], + fileIds: [], + files: [], + replyId: null, + renoteId: null +} + +const follow: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.Follow +} + +const mention: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.Mention, + note: note +} + +const reply: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.Reply, + note: note +} + +const renote: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.Renote, + note: note +} + +const quote: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.Quote, + note: note +} + +const reaction: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.Reaction, + note: note, + reaction: '♥' +} + +const pollVote: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.PollEnded, + note: note +} + +const receiveFollowRequest: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.ReceiveFollowRequest +} + +const followRequestAccepted: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.FollowRequestAccepted +} + +const groupInvited: MisskeyEntity.Notification = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: user.id, + user: user, + type: MisskeyNotificationType.GroupInvited +} + +;(axios.CancelToken.source as any).mockImplementation(() => { + return { + token: { + throwIfRequested: () => {}, + promise: { + then: () => {}, + catch: () => {} + } + } + } +}) + +describe('getNotifications', () => { + const client = new Misskey('http://localhost', 'sample token') + const cases: Array<{ event: MisskeyEntity.Notification; expected: Entity.NotificationType; title: string }> = [ + { + event: follow, + expected: MegalodonNotificationType.Follow, + title: 'follow' + }, + { + event: mention, + expected: MegalodonNotificationType.Mention, + title: 'mention' + }, + { + event: reply, + expected: MegalodonNotificationType.Mention, + title: 'reply' + }, + { + event: renote, + expected: MegalodonNotificationType.Reblog, + title: 'renote' + }, + { + event: quote, + expected: MegalodonNotificationType.Reblog, + title: 'quote' + }, + { + event: reaction, + expected: MegalodonNotificationType.Reaction, + title: 'reaction' + }, + { + event: pollVote, + expected: MegalodonNotificationType.Poll, + title: 'pollVote' + }, + { + event: receiveFollowRequest, + expected: MegalodonNotificationType.FollowRequest, + title: 'receiveFollowRequest' + }, + { + event: followRequestAccepted, + expected: MegalodonNotificationType.Follow, + title: 'followRequestAccepted' + }, + { + event: groupInvited, + expected: MisskeyNotificationType.GroupInvited, + title: 'groupInvited' + } + ] + cases.forEach(c => { + it(`should be ${c.title} event`, async () => { + const mockResponse: AxiosResponse> = { + data: [c.event], + status: 200, + statusText: '200OK', + headers: {}, + config: {} + } + ;(axios.post as any).mockResolvedValue(mockResponse) + const res = await client.getNotifications() + expect(res.data[0].type).toEqual(c.expected) + }) + }) +}) diff --git a/packages/megalodon/test/unit/misskey/api_client.spec.ts b/packages/megalodon/test/unit/misskey/api_client.spec.ts new file mode 100644 index 0000000000..7cf33b983d --- /dev/null +++ b/packages/megalodon/test/unit/misskey/api_client.spec.ts @@ -0,0 +1,233 @@ +import MisskeyAPI from '@/misskey/api_client' +import MegalodonEntity from '@/entity' +import MisskeyEntity from '@/misskey/entity' +import MegalodonNotificationType from '@/notification' +import MisskeyNotificationType from '@/misskey/notification' + +const user: MisskeyEntity.User = { + id: '1', + name: 'test_user', + username: 'TestUser', + host: 'misskey.io', + avatarUrl: 'https://example.com/icon.png', + avatarColor: '#000000', + emojis: [] +} + +const converter: MisskeyAPI.Converter = new MisskeyAPI.Converter("https://example.com") + +describe('api_client', () => { + describe('notification', () => { + describe('encode', () => { + it('megalodon notification type should be encoded to misskey notification type', () => { + const cases: Array<{ src: MegalodonEntity.NotificationType; dist: MisskeyEntity.NotificationType }> = [ + { + src: MegalodonNotificationType.Follow, + dist: MisskeyNotificationType.Follow + }, + { + src: MegalodonNotificationType.Mention, + dist: MisskeyNotificationType.Reply + }, + { + src: MegalodonNotificationType.Favourite, + dist: MisskeyNotificationType.Reaction + }, + { + src: MegalodonNotificationType.Reaction, + dist: MisskeyNotificationType.Reaction + }, + { + src: MegalodonNotificationType.Reblog, + dist: MisskeyNotificationType.Renote + }, + { + src: MegalodonNotificationType.Poll, + dist: MisskeyNotificationType.PollEnded + }, + { + src: MegalodonNotificationType.FollowRequest, + dist: MisskeyNotificationType.ReceiveFollowRequest + } + ] + cases.forEach(c => { + expect(converter.encodeNotificationType(c.src)).toEqual(c.dist) + }) + }) + }) + describe('decode', () => { + it('misskey notification type should be decoded to megalodon notification type', () => { + const cases: Array<{ src: MisskeyEntity.NotificationType; dist: MegalodonEntity.NotificationType }> = [ + { + src: MisskeyNotificationType.Follow, + dist: MegalodonNotificationType.Follow + }, + { + src: MisskeyNotificationType.Mention, + dist: MegalodonNotificationType.Mention + }, + { + src: MisskeyNotificationType.Reply, + dist: MegalodonNotificationType.Mention + }, + { + src: MisskeyNotificationType.Renote, + dist: MegalodonNotificationType.Reblog + }, + { + src: MisskeyNotificationType.Quote, + dist: MegalodonNotificationType.Reblog + }, + { + src: MisskeyNotificationType.Reaction, + dist: MegalodonNotificationType.Reaction + }, + { + src: MisskeyNotificationType.PollEnded, + dist: MegalodonNotificationType.Poll + }, + { + src: MisskeyNotificationType.ReceiveFollowRequest, + dist: MegalodonNotificationType.FollowRequest + }, + { + src: MisskeyNotificationType.FollowRequestAccepted, + dist: MegalodonNotificationType.Follow + } + ] + cases.forEach(c => { + expect(converter.decodeNotificationType(c.src)).toEqual(c.dist) + }) + }) + }) + }) + describe('reactions', () => { + it('should be mapped', () => { + const misskeyReactions = [ + { + id: '1', + createdAt: '2020-04-21T13:04:13.968Z', + user: { + id: '81u70uwsja', + name: 'h3poteto', + username: 'h3poteto', + host: null, + avatarUrl: 'https://s3.arkjp.net/misskey/thumbnail-63807d97-20ca-40ba-9493-179aa48065c1.png', + avatarColor: 'rgb(146,189,195)', + emojis: [] + }, + type: '❤' + }, + { + id: '2', + createdAt: '2020-04-21T13:04:13.968Z', + user: { + id: '81u70uwsja', + name: 'h3poteto', + username: 'h3poteto', + host: null, + avatarUrl: 'https://s3.arkjp.net/misskey/thumbnail-63807d97-20ca-40ba-9493-179aa48065c1.png', + avatarColor: 'rgb(146,189,195)', + emojis: [] + }, + type: '❤' + }, + { + id: '3', + createdAt: '2020-04-21T13:04:13.968Z', + user: { + id: '81u70uwsja', + name: 'h3poteto', + username: 'h3poteto', + host: null, + avatarUrl: 'https://s3.arkjp.net/misskey/thumbnail-63807d97-20ca-40ba-9493-179aa48065c1.png', + avatarColor: 'rgb(146,189,195)', + emojis: [] + }, + type: '☺' + }, + { + id: '4', + createdAt: '2020-04-21T13:04:13.968Z', + user: { + id: '81u70uwsja', + name: 'h3poteto', + username: 'h3poteto', + host: null, + avatarUrl: 'https://s3.arkjp.net/misskey/thumbnail-63807d97-20ca-40ba-9493-179aa48065c1.png', + avatarColor: 'rgb(146,189,195)', + emojis: [] + }, + type: '❤' + } + ] + + const reactions = converter.reactions(misskeyReactions) + expect(reactions).toEqual([ + { + count: 3, + me: false, + name: '❤' + }, + { + count: 1, + me: false, + name: '☺' + } + ]) + }) + }) + + describe('status', () => { + describe('plain content', () => { + it('should be exported plain content and html content', () => { + const plainContent = 'hoge\nfuga\nfuga' + const content = 'hoge
fuga
fuga' + const note: MisskeyEntity.Note = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: '1', + user: user, + text: plainContent, + cw: null, + visibility: 'public', + renoteCount: 0, + repliesCount: 0, + reactions: {}, + emojis: [], + fileIds: [], + files: [], + replyId: null, + renoteId: null + } + const megalodonStatus = converter.note(note, user.host || 'misskey.io') + expect(megalodonStatus.plain_content).toEqual(plainContent) + expect(megalodonStatus.content).toEqual(content) + }) + it('html tags should be escaped', () => { + const plainContent = '

hoge\nfuga\nfuga

' + const content = '<p>hoge
fuga
fuga<p>' + const note: MisskeyEntity.Note = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: '1', + user: user, + text: plainContent, + cw: null, + visibility: 'public', + renoteCount: 0, + repliesCount: 0, + reactions: {}, + emojis: [], + fileIds: [], + files: [], + replyId: null, + renoteId: null + } + const megalodonStatus = converter.note(note, user.host || 'misskey.io') + expect(megalodonStatus.plain_content).toEqual(plainContent) + expect(megalodonStatus.content).toEqual(content) + }) + }) + }) +}) diff --git a/packages/megalodon/test/unit/parser.spec.ts b/packages/megalodon/test/unit/parser.spec.ts new file mode 100644 index 0000000000..5174a647c6 --- /dev/null +++ b/packages/megalodon/test/unit/parser.spec.ts @@ -0,0 +1,152 @@ +import { Parser } from '@/parser' +import Entity from '@/entity' + +const account: Entity.Account = { + id: '1', + username: 'h3poteto', + acct: 'h3poteto@pleroma.io', + display_name: 'h3poteto', + locked: 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 +} + +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', + 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, + reactions: [], + bookmarked: false, + quote: null +} + +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', () => { + const message: string = ':thump\n' + it('should be called', () => { + const spy = jest.fn() + parser.on('heartbeat', spy) + parser.parse(message) + expect(spy).toHaveBeenLastCalledWith({}) + }) + }) + + describe('message is not json', () => { + describe('event is delete', () => { + const message = `event: delete\ndata: 12asdf34\n\n` + it('should be called', () => { + const spy = jest.fn() + parser.once('delete', spy) + parser.parse(message) + expect(spy).toHaveBeenCalledWith('12asdf34') + }) + }) + + describe('event is not delete', () => { + const message = `event: event\ndata: 12asdf34\n\n` + it('should be error', () => { + const error = jest.fn() + const deleted = jest.fn() + parser.once('error', error) + parser.once('delete', deleted) + parser.parse(message) + expect(error).toHaveBeenCalled() + expect(deleted).not.toHaveBeenCalled() + }) + }) + }) + + describe('message is json', () => { + describe('event is update', () => { + const message = `event: update\ndata: ${JSON.stringify(status)}\n\n` + it('should be called', () => { + const spy = jest.fn() + parser.once('update', spy) + parser.parse(message) + expect(spy).toHaveBeenCalledWith(status) + }) + }) + + describe('event is notification', () => { + const message = `event: notification\ndata: ${JSON.stringify(notification)}\n\n` + it('should be called', () => { + const spy = jest.fn() + parser.once('notification', spy) + parser.parse(message) + expect(spy).toHaveBeenCalledWith(notification) + }) + }) + + describe('event is conversation', () => { + const message = `event: conversation\ndata: ${JSON.stringify(conversation)}\n\n` + it('should be called', () => { + const spy = jest.fn() + parser.once('conversation', spy) + parser.parse(message) + expect(spy).toHaveBeenCalledWith(conversation) + }) + }) + }) + }) +}) diff --git a/packages/megalodon/tsconfig.json b/packages/megalodon/tsconfig.json new file mode 100644 index 0000000000..5a9bfbde9a --- /dev/null +++ b/packages/megalodon/tsconfig.json @@ -0,0 +1,64 @@ +{ + "compilerOptions": { + /* Basic Options */ + "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + "lib": ["es2021", "dom"], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./lib", /* Redirect output structure to the directory. */ + "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + "strictNullChecks": true, /* Enable strict null checks. */ + "strictFunctionTypes": true, /* Enable strict checking of function types. */ + "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + "noUnusedLocals": false, /* Report errors on unused locals. */ + "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + "paths": { + "@*": ["src*"], + "~*": ["./*"] + }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + + /* Source Map Options */ + // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + }, + "include": ["./src", "./test"], + "exclude": ["node_modules", "example"] +} diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts index f33ab1c33c..0f9254216a 100644 --- a/packages/sw/src/scripts/create-notification.ts +++ b/packages/sw/src/scripts/create-notification.ts @@ -250,7 +250,7 @@ export async function createEmptyNotification(): Promise { await globalThis.registration.showNotification( (new URL(origin)).host, { - body: `Misskey v${_VERSION_}`, + body: `Sharkey v${_VERSION_}`, silent: true, badge: iconUrl('null'), tag: 'read_notification', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 902102d84e..42faf9301c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: fastify: specifier: 4.23.2 version: 4.23.2 + fastify-multer: + specifier: ^2.0.3 + version: 2.0.3 feed: specifier: 4.2.2 version: 4.2.2 @@ -236,6 +239,9 @@ importers: jsrsasign: specifier: 10.8.6 version: 10.8.6 + megalodon: + specifier: workspace:* + version: link:../megalodon meilisearch: specifier: 0.34.2 version: 0.34.2 @@ -999,6 +1005,124 @@ importers: specifier: 1.8.11 version: 1.8.11(typescript@5.2.2) + packages/megalodon: + dependencies: + '@types/oauth': + specifier: ^0.9.0 + version: 0.9.2 + '@types/ws': + specifier: ^8.5.4 + version: 8.5.5 + async-lock: + specifier: 1.4.0 + version: 1.4.0 + axios: + specifier: 1.2.2 + version: 1.2.2 + dayjs: + specifier: ^1.11.7 + version: 1.11.7 + form-data: + specifier: ^4.0.0 + version: 4.0.0 + https-proxy-agent: + specifier: ^5.0.1 + version: 5.0.1 + oauth: + specifier: ^0.10.0 + version: 0.10.0 + object-assign-deep: + specifier: ^0.4.0 + version: 0.4.0 + parse-link-header: + specifier: ^2.0.0 + version: 2.0.0 + socks-proxy-agent: + specifier: ^7.0.0 + version: 7.0.0 + typescript: + specifier: 4.9.4 + version: 4.9.4 + uuid: + specifier: ^9.0.0 + version: 9.0.1 + ws: + specifier: 8.12.0 + version: 8.12.0 + devDependencies: + '@types/async-lock': + specifier: 1.4.0 + version: 1.4.0 + '@types/core-js': + specifier: ^2.5.0 + version: 2.5.0 + '@types/form-data': + specifier: ^2.5.0 + version: 2.5.0 + '@types/jest': + specifier: ^29.4.0 + version: 29.5.5 + '@types/node': + specifier: 18.11.18 + version: 18.11.18 + '@types/object-assign-deep': + specifier: ^0.4.0 + version: 0.4.0 + '@types/parse-link-header': + specifier: ^2.0.0 + version: 2.0.0 + '@types/uuid': + specifier: ^9.0.0 + version: 9.0.4 + '@typescript-eslint/eslint-plugin': + specifier: ^5.49.0 + version: 5.49.0(@typescript-eslint/parser@5.49.0)(eslint@8.49.0)(typescript@4.9.4) + '@typescript-eslint/parser': + specifier: ^5.49.0 + version: 5.49.0(eslint@8.49.0)(typescript@4.9.4) + eslint: + specifier: ^8.32.0 + version: 8.49.0 + eslint-config-prettier: + specifier: ^8.6.0 + version: 8.6.0(eslint@8.49.0) + eslint-config-standard: + specifier: ^16.0.3 + version: 16.0.3(eslint-plugin-import@2.28.1)(eslint-plugin-node@11.0.0)(eslint-plugin-promise@6.1.1)(eslint@8.49.0) + eslint-plugin-import: + specifier: ^2.27.5 + version: 2.28.1(@typescript-eslint/parser@5.49.0)(eslint@8.49.0) + eslint-plugin-node: + specifier: ^11.0.0 + version: 11.0.0(eslint@8.49.0) + eslint-plugin-prettier: + specifier: ^4.2.1 + version: 4.2.1(eslint-config-prettier@8.6.0)(eslint@8.49.0)(prettier@2.8.8) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.1.1(eslint@8.49.0) + eslint-plugin-standard: + specifier: ^5.0.0 + version: 5.0.0(eslint@8.49.0) + jest: + specifier: ^29.4.0 + version: 29.7.0(@types/node@18.11.18) + jest-worker: + specifier: ^29.4.0 + version: 29.7.0 + lodash: + specifier: 4.17.21 + version: 4.17.21 + prettier: + specifier: ^2.8.3 + version: 2.8.8 + ts-jest: + specifier: ^29.0.5 + version: 29.0.5(@babel/core@7.22.11)(jest@29.7.0)(typescript@4.9.4) + typedoc: + specifier: ^0.23.24 + version: 0.23.24(typescript@4.9.4) + packages/misskey-js: dependencies: '@swc/cli': @@ -1682,13 +1806,6 @@ packages: tslib: 2.6.2 dev: false - /@babel/code-frame@7.21.4: - resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/highlight': 7.22.5 - dev: true - /@babel/code-frame@7.22.13: resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} engines: {node: '>=6.9.0'} @@ -1697,13 +1814,6 @@ packages: chalk: 2.4.2 dev: true - /@babel/code-frame@7.22.5: - resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/highlight': 7.22.5 - dev: true - /@babel/compat-data@7.22.9: resolution: {integrity: sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==} engines: {node: '>=6.9.0'} @@ -1857,7 +1967,7 @@ packages: '@babel/helper-module-imports': 7.22.5 '@babel/helper-simple-access': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.22.5 + '@babel/helper-validator-identifier': 7.22.15 dev: true /@babel/helper-optimise-call-expression@7.22.5: @@ -1963,15 +2073,6 @@ packages: js-tokens: 4.0.0 dev: true - /@babel/highlight@7.22.5: - resolution: {integrity: sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.22.5 - chalk: 2.4.2 - js-tokens: 4.0.0 - dev: true - /@babel/parser@7.21.8: resolution: {integrity: sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==} engines: {node: '>=6.0.0'} @@ -1979,20 +2080,12 @@ packages: dependencies: '@babel/types': 7.22.5 - /@babel/parser@7.22.11: - resolution: {integrity: sha512-R5zb8eJIBPJriQtbH/htEQy4k7E2dHWlD2Y2VT07JCzwYZHBxV5ZYtM0UhXSNMT74LyxuM+b1jdL7pSesXbC/g==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.22.17 - dev: true - /@babel/parser@7.22.16: resolution: {integrity: sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==} engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.22.11 + '@babel/types': 7.22.17 /@babel/parser@7.22.7: resolution: {integrity: sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==} @@ -3050,14 +3143,6 @@ packages: - supports-color dev: true - /@babel/types@7.22.11: - resolution: {integrity: sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.22.5 - '@babel/helper-validator-identifier': 7.22.5 - to-fast-properties: 2.0.0 - /@babel/types@7.22.17: resolution: {integrity: sha512-YSQPHLFtQNE5xN9tHuZnzu8vPr61wVTBZdfv1meex1NBosa4iT05k/Jw06ddJugi4bk7The/oSwQGFcksmEJQg==} engines: {node: '>=6.9.0'} @@ -7476,7 +7561,7 @@ packages: resolution: {integrity: sha512-xTEnpUKiV/bMyEsE5bT4oYA0x0Z/colMtxzUY8bKyPXBNLn/e0V4ZjBZkEhms0xE4pv9QsPfSRu9AWS4y5wGvA==} engines: {node: '>=14'} dependencies: - '@babel/code-frame': 7.21.4 + '@babel/code-frame': 7.22.13 '@babel/runtime': 7.21.0 '@types/aria-query': 5.0.1 aria-query: 5.1.3 @@ -7578,6 +7663,10 @@ packages: resolution: {integrity: sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==} dev: true + /@types/async-lock@1.4.0: + resolution: {integrity: sha512-2+rYSaWrpdbQG3SA0LmMT6YxWLrI81AqpMlSkw3QtFc2HGDufkweQSn30Eiev7x9LL0oyFrBqk1PXOnB9IEgKg==} + dev: true + /@types/babel__core@7.20.0: resolution: {integrity: sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==} dependencies: @@ -7671,6 +7760,10 @@ packages: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} dev: true + /@types/core-js@2.5.0: + resolution: {integrity: sha512-qjkHL3wF0JMHMqgm/kmL8Pf8rIiqvueEiZ0g6NVTcBX1WN46GWDr+V5z+gsHUeL0n8TfAmXnYmF7ajsxmBp4PQ==} + dev: true + /@types/cross-spawn@6.0.2: resolution: {integrity: sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==} dependencies: @@ -7752,6 +7845,13 @@ packages: '@types/node': 20.6.3 dev: true + /@types/form-data@2.5.0: + resolution: {integrity: sha512-23/wYiuckYYtFpL+4RPWiWmRQH2BjFuqCUi2+N3amB1a1Drv+i/byTrGvlLwRVLFNAZbwpbQ7JvTK+VCAPMbcg==} + deprecated: This is a stub types definition. form-data provides its own type definitions, so you do not need this installed. + dependencies: + form-data: 4.0.0 + dev: true + /@types/glob@7.2.0: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: @@ -7907,6 +8007,10 @@ packages: resolution: {integrity: sha512-Mnq3O9Xz52exs3mlxMcQuA7/9VFe/dXcrgAyfjLkABIqxXKOgBRjyazTxUbjsxDa4BP7hhPliyjVTP9RDP14xg==} dev: true + /@types/node@18.11.18: + resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==} + dev: true + /@types/node@18.17.15: resolution: {integrity: sha512-2yrWpBk32tvV/JAd3HNHWuZn/VDN1P+72hWirHnvsvTGSqbANi+kSeuQR9yAHnbvaBvHDsoTdXV0Fe+iRtHLKA==} dev: true @@ -7941,6 +8045,9 @@ packages: resolution: {integrity: sha512-Nu3/abQ6yR9VlsCdX3aiGsWFkj6OJvJqDvg/36t8Gwf2mFXdBZXPDN3K+2yfeA6Lo2m1Q12F8Qil9TZ48nWhOQ==} dependencies: '@types/node': 20.6.3 + + /@types/object-assign-deep@0.4.0: + resolution: {integrity: sha512-3D0F3rHRNDc8cQSXNzwF1jBrJi28Mdrhc10ZLlqbJWDPYRWTTWB9Tc8JoKrgBvLKioXoPoHT6Uzf3s2F7akCUg==} dev: true /@types/offscreencanvas@2019.3.0: @@ -7953,6 +8060,10 @@ packages: requiresBuild: true dev: false + /@types/parse-link-header@2.0.0: + resolution: {integrity: sha512-KbqcQLdRaawDOfXnwqr6nvhe1MV+Uv/Ww+ViSx7Ujgw9X5qCgObLP52B1ZSJqZD8FK1y/4o+bJQTUrZOynegcg==} + dev: true + /@types/pg@8.10.2: resolution: {integrity: sha512-MKFs9P6nJ+LAeHLU3V0cODEOgyThJ3OAnmOlsZsxux6sfQs3HRXR5bBn7xG5DjckEFhTAxsXi7k7cd0pCMxpJw==} dependencies: @@ -8143,7 +8254,6 @@ packages: resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} dependencies: '@types/node': 20.6.3 - dev: true /@types/yargs-parser@21.0.0: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} @@ -8169,6 +8279,33 @@ packages: dev: true optional: true + /@typescript-eslint/eslint-plugin@5.49.0(@typescript-eslint/parser@5.49.0)(eslint@8.49.0)(typescript@4.9.4): + resolution: {integrity: sha512-IhxabIpcf++TBaBa1h7jtOWyon80SXPRLDq0dVz5SLFC/eW6tofkw/O7Ar3lkx5z5U6wzbKDrl2larprp5kk5Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/parser': ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/parser': 5.49.0(eslint@8.49.0)(typescript@4.9.4) + '@typescript-eslint/scope-manager': 5.49.0 + '@typescript-eslint/type-utils': 5.49.0(eslint@8.49.0)(typescript@4.9.4) + '@typescript-eslint/utils': 5.49.0(eslint@8.49.0)(typescript@4.9.4) + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.49.0 + ignore: 5.2.4 + natural-compare-lite: 1.4.0 + regexpp: 3.2.0 + semver: 7.5.4 + tsutils: 3.21.0(typescript@4.9.4) + typescript: 4.9.4 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/eslint-plugin@6.7.2(@typescript-eslint/parser@6.7.2)(eslint@8.49.0)(typescript@5.2.2): resolution: {integrity: sha512-ooaHxlmSgZTM6CHYAFRlifqh1OAr3PAQEwi7lhYhaegbnXrnh7CDcHmc3+ihhbQC7H0i4JF0psI5ehzkF6Yl6Q==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8198,6 +8335,26 @@ packages: - supports-color dev: true + /@typescript-eslint/parser@5.49.0(eslint@8.49.0)(typescript@4.9.4): + resolution: {integrity: sha512-veDlZN9mUhGqU31Qiv2qEp+XrJj5fgZpJ8PW30sHU+j/8/e5ruAhLaVDAeznS7A7i4ucb/s8IozpDtt9NqCkZg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 5.49.0 + '@typescript-eslint/types': 5.49.0 + '@typescript-eslint/typescript-estree': 5.49.0(typescript@4.9.4) + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.49.0 + typescript: 4.9.4 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/parser@6.7.2(eslint@8.49.0)(typescript@5.2.2): resolution: {integrity: sha512-KA3E4ox0ws+SPyxQf9iSI25R6b4Ne78ORhNHeVKrPQnoYsb9UhieoiRoJgrzgEeKGOXhcY1i8YtOeCHHTDa6Fw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8219,6 +8376,14 @@ packages: - supports-color dev: true + /@typescript-eslint/scope-manager@5.49.0: + resolution: {integrity: sha512-clpROBOiMIzpbWNxCe1xDK14uPZh35u4QaZO1GddilEzoCLAEz4szb51rBpdgurs5k2YzPtJeTEN3qVbG+LRUQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.49.0 + '@typescript-eslint/visitor-keys': 5.49.0 + dev: true + /@typescript-eslint/scope-manager@6.7.2: resolution: {integrity: sha512-bgi6plgyZjEqapr7u2mhxGR6E8WCzKNUFWNh6fkpVe9+yzRZeYtDTbsIBzKbcxI+r1qVWt6VIoMSNZ4r2A+6Yw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8227,6 +8392,26 @@ packages: '@typescript-eslint/visitor-keys': 6.7.2 dev: true + /@typescript-eslint/type-utils@5.49.0(eslint@8.49.0)(typescript@4.9.4): + resolution: {integrity: sha512-eUgLTYq0tR0FGU5g1YHm4rt5H/+V2IPVkP0cBmbhRyEmyGe4XvJ2YJ6sYTmONfjmdMqyMLad7SB8GvblbeESZA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '*' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 5.49.0(typescript@4.9.4) + '@typescript-eslint/utils': 5.49.0(eslint@8.49.0)(typescript@4.9.4) + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.49.0 + tsutils: 3.21.0(typescript@4.9.4) + typescript: 4.9.4 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/type-utils@6.7.2(eslint@8.49.0)(typescript@5.2.2): resolution: {integrity: sha512-36F4fOYIROYRl0qj95dYKx6kybddLtsbmPIYNK0OBeXv2j9L5nZ17j9jmfy+bIDHKQgn2EZX+cofsqi8NPATBQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8247,11 +8432,37 @@ packages: - supports-color dev: true + /@typescript-eslint/types@5.49.0: + resolution: {integrity: sha512-7If46kusG+sSnEpu0yOz2xFv5nRz158nzEXnJFCGVEHWnuzolXKwrH5Bsf9zsNlOQkyZuk0BZKKoJQI+1JPBBg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + /@typescript-eslint/types@6.7.2: resolution: {integrity: sha512-flJYwMYgnUNDAN9/GAI3l8+wTmvTYdv64fcH8aoJK76Y+1FCZ08RtI5zDerM/FYT5DMkAc+19E4aLmd5KqdFyg==} engines: {node: ^16.0.0 || >=18.0.0} dev: true + /@typescript-eslint/typescript-estree@5.49.0(typescript@4.9.4): + resolution: {integrity: sha512-PBdx+V7deZT/3GjNYPVQv1Nc0U46dAHbIuOG8AZ3on3vuEKiPDwFE/lG1snN2eUB9IhF7EyF7K1hmTcLztNIsA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.49.0 + '@typescript-eslint/visitor-keys': 5.49.0 + debug: 4.3.4(supports-color@8.1.1) + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.5.4 + tsutils: 3.21.0(typescript@4.9.4) + typescript: 4.9.4 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/typescript-estree@6.7.2(typescript@5.2.2): resolution: {integrity: sha512-kiJKVMLkoSciGyFU0TOY0fRxnp9qq1AzVOHNeN1+B9erKFCJ4Z8WdjAkKQPP+b1pWStGFqezMLltxO+308dJTQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8273,6 +8484,26 @@ packages: - supports-color dev: true + /@typescript-eslint/utils@5.49.0(eslint@8.49.0)(typescript@4.9.4): + resolution: {integrity: sha512-cPJue/4Si25FViIb74sHCLtM4nTSBXtLx1d3/QT6mirQ/c65bV8arBEebBJJizfq8W2YyMoPI/WWPFWitmNqnQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@types/json-schema': 7.0.12 + '@types/semver': 7.5.2 + '@typescript-eslint/scope-manager': 5.49.0 + '@typescript-eslint/types': 5.49.0 + '@typescript-eslint/typescript-estree': 5.49.0(typescript@4.9.4) + eslint: 8.49.0 + eslint-scope: 5.1.1 + eslint-utils: 3.0.0(eslint@8.49.0) + semver: 7.5.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /@typescript-eslint/utils@6.7.2(eslint@8.49.0)(typescript@5.2.2): resolution: {integrity: sha512-ZCcBJug/TS6fXRTsoTkgnsvyWSiXwMNiPzBUani7hDidBdj1779qwM1FIAmpH4lvlOZNF3EScsxxuGifjpLSWQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8292,6 +8523,14 @@ packages: - typescript dev: true + /@typescript-eslint/visitor-keys@5.49.0: + resolution: {integrity: sha512-v9jBMjpNWyn8B6k/Mjt6VbUS4J1GvUlR4x3Y+ibnP1z7y7V4n0WRz+50DY6+Myj0UaXVSuUlHohO+eZ8IJEnkg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.49.0 + eslint-visitor-keys: 3.4.3 + dev: true + /@typescript-eslint/visitor-keys@6.7.2: resolution: {integrity: sha512-uVw9VIMFBUTz8rIeaUT3fFe8xIUx8r4ywAdlQv1ifH+6acn/XF8Y6rwJ7XNmkNMDrTW+7+vxFFPIF40nJCVsMQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8443,7 +8682,7 @@ packages: /@vue/compiler-core@3.3.4: resolution: {integrity: sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==} dependencies: - '@babel/parser': 7.22.7 + '@babel/parser': 7.22.16 '@vue/shared': 3.3.4 estree-walker: 2.0.2 source-map-js: 1.0.2 @@ -8807,6 +9046,10 @@ packages: engines: {node: '>= 6.0.0'} dev: false + /append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + dev: false + /aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} dev: false @@ -9063,6 +9306,10 @@ packages: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} dev: true + /async-lock@1.4.0: + resolution: {integrity: sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==} + dev: false + /async-mutex@0.4.0: resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==} dependencies: @@ -9135,6 +9382,16 @@ packages: - debug dev: true + /axios@1.2.2: + resolution: {integrity: sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==} + dependencies: + follow-redirects: 1.15.2(debug@4.3.4) + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /b4a@1.6.4: resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==} @@ -9438,6 +9695,13 @@ packages: node-releases: 2.0.13 update-browserslist-db: 1.0.11(browserslist@4.21.9) + /bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + dependencies: + fast-json-stable-stringify: 2.1.0 + dev: true + /bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} dependencies: @@ -9607,7 +9871,7 @@ packages: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: function-bind: 1.1.1 - get-intrinsic: 1.2.0 + get-intrinsic: 1.2.1 /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} @@ -10108,6 +10372,16 @@ packages: typedarray: 0.0.6 dev: true + /concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.0 + typedarray: 0.0.6 + dev: false + /config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} dependencies: @@ -10190,6 +10464,25 @@ packages: readable-stream: 3.6.0 dev: false + /create-jest@29.7.0(@types/node@18.11.18): + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@18.11.18) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /create-jest@29.7.0(@types/node@20.6.3): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -10485,7 +10778,6 @@ packages: /dayjs@1.11.7: resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==} - dev: true /de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -10659,14 +10951,6 @@ packages: engines: {node: '>=8'} dev: true - /define-properties@1.1.4: - resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==} - engines: {node: '>= 0.4'} - dependencies: - has-property-descriptors: 1.0.0 - object-keys: 1.1.1 - dev: true - /define-properties@1.2.0: resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} engines: {node: '>= 0.4'} @@ -11024,7 +11308,7 @@ packages: resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.0 + get-intrinsic: 1.2.1 has-symbols: 1.0.3 is-arguments: 1.1.1 is-map: 2.0.2 @@ -11195,6 +11479,29 @@ packages: source-map: 0.6.1 dev: true + /eslint-config-prettier@8.6.0(eslint@8.49.0): + resolution: {integrity: sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + dependencies: + eslint: 8.49.0 + dev: true + + /eslint-config-standard@16.0.3(eslint-plugin-import@2.28.1)(eslint-plugin-node@11.0.0)(eslint-plugin-promise@6.1.1)(eslint@8.49.0): + resolution: {integrity: sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==} + peerDependencies: + eslint: ^7.12.1 + eslint-plugin-import: ^2.22.1 + eslint-plugin-node: ^11.1.0 + eslint-plugin-promise: ^4.2.1 || ^5.0.0 + dependencies: + eslint: 8.49.0 + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@5.49.0)(eslint@8.49.0) + eslint-plugin-node: 11.0.0(eslint@8.49.0) + eslint-plugin-promise: 6.1.1(eslint@8.49.0) + dev: true + /eslint-formatter-pretty@4.1.0: resolution: {integrity: sha512-IsUTtGxF1hrH6lMWiSl1WbGaiP01eT6kzywdY1U+zLc0MP+nwEnUiS9UI8IaOTUhTeQJLlCEWIbXINBH4YJbBQ==} engines: {node: '>=10'} @@ -11219,6 +11526,35 @@ packages: - supports-color dev: true + /eslint-module-utils@2.8.0(@typescript-eslint/parser@5.49.0)(eslint-import-resolver-node@0.3.7)(eslint@8.49.0): + resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + dependencies: + '@typescript-eslint/parser': 5.49.0(eslint@8.49.0)(typescript@4.9.4) + debug: 3.2.7(supports-color@5.5.0) + eslint: 8.49.0 + eslint-import-resolver-node: 0.3.7 + transitivePeerDependencies: + - supports-color + dev: true + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.2)(eslint-import-resolver-node@0.3.7)(eslint@8.49.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} @@ -11248,6 +11584,52 @@ packages: - supports-color dev: true + /eslint-plugin-es@3.0.1(eslint@8.49.0): + resolution: {integrity: sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=4.19.1' + dependencies: + eslint: 8.49.0 + eslint-utils: 2.1.0 + regexpp: 3.2.0 + dev: true + + /eslint-plugin-import@2.28.1(@typescript-eslint/parser@5.49.0)(eslint@8.49.0): + resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + dependencies: + '@typescript-eslint/parser': 5.49.0(eslint@8.49.0)(typescript@4.9.4) + array-includes: 3.1.6 + array.prototype.findlastindex: 1.2.2 + array.prototype.flat: 1.3.1 + array.prototype.flatmap: 1.3.1 + debug: 3.2.7(supports-color@5.5.0) + doctrine: 2.1.0 + eslint: 8.49.0 + eslint-import-resolver-node: 0.3.7 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.49.0)(eslint-import-resolver-node@0.3.7)(eslint@8.49.0) + has: 1.0.3 + is-core-module: 2.13.0 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.6 + object.groupby: 1.0.0 + object.values: 1.1.6 + semver: 6.3.1 + tsconfig-paths: 3.14.2 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + dev: true + /eslint-plugin-import@2.28.1(@typescript-eslint/parser@6.7.2)(eslint@8.49.0): resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} engines: {node: '>=4'} @@ -11283,6 +11665,56 @@ packages: - supports-color dev: true + /eslint-plugin-node@11.0.0(eslint@8.49.0): + resolution: {integrity: sha512-chUs/NVID+sknFiJzxoN9lM7uKSOEta8GC8365hw1nDfwIPIjjpRSwwPvQanWv8dt/pDe9EV4anmVSwdiSndNg==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=5.16.0' + dependencies: + eslint: 8.49.0 + eslint-plugin-es: 3.0.1(eslint@8.49.0) + eslint-utils: 2.1.0 + ignore: 5.2.4 + minimatch: 3.1.2 + resolve: 1.22.3 + semver: 6.3.1 + dev: true + + /eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.6.0)(eslint@8.49.0)(prettier@2.8.8): + resolution: {integrity: sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + eslint: '>=7.28.0' + eslint-config-prettier: '*' + prettier: '>=2.0.0' + peerDependenciesMeta: + eslint-config-prettier: + optional: true + dependencies: + eslint: 8.49.0 + eslint-config-prettier: 8.6.0(eslint@8.49.0) + prettier: 2.8.8 + prettier-linter-helpers: 1.0.0 + dev: true + + /eslint-plugin-promise@6.1.1(eslint@8.49.0): + resolution: {integrity: sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + dependencies: + eslint: 8.49.0 + dev: true + + /eslint-plugin-standard@5.0.0(eslint@8.49.0): + resolution: {integrity: sha512-eSIXPc9wBM4BrniMzJRBm2uoVuXz2EPa+NXPk2+itrVt+r5SbKFERx/IgrK/HmfjddyKVz2f+j+7gBRvu19xLg==} + deprecated: 'standard 16.0.0 and eslint-config-standard 16.0.0 no longer require the eslint-plugin-standard package. You can remove it from your dependencies with ''npm rm eslint-plugin-standard''. More info here: https://github.com/standard/standard/issues/1316' + peerDependencies: + eslint: '>=5.0.0' + dependencies: + eslint: 8.49.0 + dev: true + /eslint-plugin-vue@9.17.0(eslint@8.49.0): resolution: {integrity: sha512-r7Bp79pxQk9I5XDP0k2dpUC7Ots3OSWgvGZNu3BxmKK6Zg7NgVtcOB6OCna5Kb9oQwJPl5hq183WD0SY5tZtIQ==} engines: {node: ^14.17.0 || >=16.0.0} @@ -11305,6 +11737,14 @@ packages: resolution: {integrity: sha512-+TQ+x4JdTnDoFEXXb3fDvfGOwnyNV7duH8fXWTPD1ieaBmB8omj7Gw/pMBBu4uI2uJCCU8APDaQJzWuXnTsH4A==} dev: true + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + /eslint-scope@7.2.0: resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11321,6 +11761,33 @@ packages: estraverse: 5.3.0 dev: true + /eslint-utils@2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} + dependencies: + eslint-visitor-keys: 1.3.0 + dev: true + + /eslint-utils@3.0.0(eslint@8.49.0): + resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} + engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} + peerDependencies: + eslint: '>=5' + dependencies: + eslint: 8.49.0 + eslint-visitor-keys: 2.1.0 + dev: true + + /eslint-visitor-keys@1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} + dev: true + + /eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + dev: true + /eslint-visitor-keys@3.4.1: resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11415,6 +11882,11 @@ packages: estraverse: 5.3.0 dev: true + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -11674,6 +12146,10 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + /fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + dev: true + /fast-fifo@1.3.0: resolution: {integrity: sha512-IgfweLvEpwyA4WgiQe9Nx6VV2QkML2NkvZnk1oKnIzXgXdWxuhF7zw4DvLTPZJn6PIUneiAXPF24QmoEqHTjyw==} @@ -11731,6 +12207,26 @@ packages: strnum: 1.0.5 dev: false + /fastify-multer@2.0.3: + resolution: {integrity: sha512-QnFqrRgxmUwWHTgX9uyQSu0C/hmVCfcxopqjApZ4uaZD5W9MJ+nHUlW4+9q7Yd3BRxDIuHvgiM5mjrh6XG8cAA==} + engines: {node: '>=10.17.0'} + dependencies: + '@fastify/busboy': 1.1.0 + append-field: 1.0.0 + concat-stream: 2.0.0 + fastify-plugin: 2.3.4 + mkdirp: 1.0.4 + on-finished: 2.4.1 + type-is: 1.6.18 + xtend: 4.0.2 + dev: false + + /fastify-plugin@2.3.4: + resolution: {integrity: sha512-I+Oaj6p9oiRozbam30sh39BiuiqBda7yK2nmSPVwDCfIBlKnT8YB3MY+pRQc2Fcd07bf6KPGklHJaQ2Qu81TYQ==} + dependencies: + semver: 7.5.4 + dev: false + /fastify-plugin@4.5.0: resolution: {integrity: sha512-79ak0JxddO0utAXAQ5ccKhvs6vX2MGyHHMMsmZkBANrq3hXc1CHzvNPHOcvTsVMEPl5I+NT+RO4YKMGehOfSIg==} dev: false @@ -12183,6 +12679,7 @@ packages: function-bind: 1.1.1 has: 1.0.3 has-symbols: 1.0.3 + dev: true /get-intrinsic@1.2.1: resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} @@ -12191,7 +12688,6 @@ packages: has: 1.0.3 has-proto: 1.0.1 has-symbols: 1.0.3 - dev: true /get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} @@ -12542,13 +13038,12 @@ packages: /has-property-descriptors@1.0.0: resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} dependencies: - get-intrinsic: 1.2.0 + get-intrinsic: 1.2.1 dev: true /has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} - dev: true /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} @@ -12887,7 +13382,7 @@ packages: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.2.0 + get-intrinsic: 1.2.1 has: 1.0.3 side-channel: 1.0.4 dev: true @@ -13035,12 +13530,6 @@ packages: dependencies: has: 1.0.3 - /is-core-module@2.12.1: - resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} - dependencies: - has: 1.0.3 - dev: true - /is-core-module@2.13.0: resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==} dependencies: @@ -13288,7 +13777,7 @@ packages: resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.0 + get-intrinsic: 1.2.1 dev: true /is-wsl@2.2.0: @@ -13329,7 +13818,7 @@ packages: engines: {node: '>=8'} dependencies: '@babel/core': 7.22.11 - '@babel/parser': 7.22.11 + '@babel/parser': 7.22.16 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.0 semver: 6.3.1 @@ -13439,6 +13928,34 @@ packages: - supports-color dev: true + /jest-cli@29.7.0(@types/node@18.11.18): + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@18.11.18) + exit: 0.1.2 + import-local: 3.1.0 + jest-config: 29.7.0(@types/node@18.11.18) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.6.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /jest-cli@29.7.0(@types/node@20.6.3): resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -13467,6 +13984,46 @@ packages: - ts-node dev: true + /jest-config@29.7.0(@types/node@18.11.18): + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.22.11 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 18.11.18 + babel-jest: 29.7.0(@babel/core@7.22.11) + chalk: 4.1.2 + ci-info: 3.7.1 + deepmerge: 4.2.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + dev: true + /jest-config@29.7.0(@types/node@20.6.3): resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -13774,7 +14331,7 @@ packages: '@babel/generator': 7.22.10 '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.11) '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.22.11) - '@babel/types': 7.22.11 + '@babel/types': 7.22.17 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 @@ -13849,6 +14406,27 @@ packages: supports-color: 8.1.1 dev: true + /jest@29.7.0(@types/node@18.11.18): + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.1.0 + jest-cli: 29.7.0(@types/node@18.11.18) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /jest@29.7.0(@types/node@20.6.3): resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -14307,7 +14885,6 @@ packages: /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - dev: false /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -14407,6 +14984,10 @@ packages: resolution: {integrity: sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==} engines: {node: 14 || >=16.14} + /lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + dev: true + /luxon@3.3.0: resolution: {integrity: sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==} engines: {node: '>=12'} @@ -14467,6 +15048,10 @@ packages: semver: 7.5.4 dev: true + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + /make-fetch-happen@11.1.1: resolution: {integrity: sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -14523,6 +15108,12 @@ packages: react: 18.2.0 dev: true + /marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + dev: true + /matter-js@0.19.0: resolution: {integrity: sha512-v2huwvQGOHTGOkMqtHd2hercCG3f6QAObTisPPHg8TZqq2lz7eIY/5i/5YUV8Ibf3mEioFEmwibcPUF2/fnKKQ==} dev: false @@ -14957,6 +15548,10 @@ packages: /napi-build-utils@1.0.2: resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + /natural-compare-lite@1.4.0: + resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} + dev: true + /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -15208,7 +15803,7 @@ packages: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: hosted-git-info: 2.8.9 - resolve: 1.22.1 + resolve: 1.22.3 semver: 5.7.1 validate-npm-package-license: 3.0.4 dev: true @@ -15316,23 +15911,24 @@ packages: resolution: {integrity: sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==} dev: false + /object-assign-deep@0.4.0: + resolution: {integrity: sha512-54Uvn3s+4A/cMWx9tlRez1qtc7pN7pbQ+Yi7mjLjcBpWLlP+XbSHiHbQW6CElDiV4OvuzqnMrBdkgxI1mT8V/Q==} + engines: {node: '>=6'} + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - /object-inspect@1.12.2: - resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} - /object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} - dev: true /object-is@1.1.5: resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.1.4 + define-properties: 1.2.0 dev: true /object-keys@1.1.1: @@ -15345,7 +15941,7 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.1.4 + define-properties: 1.2.0 has-symbols: 1.0.3 object-keys: 1.1.1 dev: true @@ -15614,12 +16210,18 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} dependencies: - '@babel/code-frame': 7.22.5 + '@babel/code-frame': 7.22.13 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 dev: true + /parse-link-header@2.0.0: + resolution: {integrity: sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw==} + dependencies: + xtend: 4.0.2 + dev: false + /parse-srcset@1.0.2: resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} dev: false @@ -16318,6 +16920,13 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + dependencies: + fast-diff: 1.3.0 + dev: true + /prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -16468,6 +17077,10 @@ packages: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} dev: true + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: false + /ps-tree@1.2.0: resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} engines: {node: '>= 0.10'} @@ -17112,7 +17725,7 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.1.4 + define-properties: 1.2.0 functions-have-names: 1.2.3 dev: true @@ -17125,6 +17738,11 @@ packages: functions-have-names: 1.2.3 dev: true + /regexpp@3.2.0: + resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} + engines: {node: '>=8'} + dev: true + /regexpu-core@5.3.2: resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==} engines: {node: '>=4'} @@ -17262,7 +17880,7 @@ packages: resolution: {integrity: sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==} hasBin: true dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 dev: true @@ -17593,12 +18211,20 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + /shiki@0.12.1: + resolution: {integrity: sha512-aieaV1m349rZINEBkjxh2QbBvFFQOlgqYTNtCal82hHj4dDZ76oMlQIX+C7ryerBTDiga3e5NfH6smjdJ02BbQ==} + dependencies: + jsonc-parser: 3.2.0 + vscode-oniguruma: 1.7.0 + vscode-textmate: 8.0.0 + dev: true + /side-channel@1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.0 - object-inspect: 1.12.2 + get-intrinsic: 1.2.1 + object-inspect: 1.12.3 /siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -18620,6 +19246,40 @@ packages: engines: {node: '>=6.10'} dev: true + /ts-jest@29.0.5(@babel/core@7.22.11)(jest@29.7.0)(typescript@4.9.4): + resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@babel/core': 7.22.11 + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@18.11.18) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.5.4 + typescript: 4.9.4 + yargs-parser: 21.1.1 + dev: true + /ts-map@1.0.3: resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==} dev: true @@ -18684,6 +19344,16 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + /tsutils@3.21.0(typescript@4.9.4): + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + dependencies: + tslib: 1.14.1 + typescript: 4.9.4 + dev: true + /tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} dependencies: @@ -18789,6 +19459,19 @@ packages: /typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + /typedoc@0.23.24(typescript@4.9.4): + resolution: {integrity: sha512-bfmy8lNQh+WrPYcJbtjQ6JEEsVl/ce1ZIXyXhyW+a1vFrjO39t6J8sL/d6FfAGrJTc7McCXgk9AanYBSNvLdIA==} + engines: {node: '>= 14.14'} + hasBin: true + peerDependencies: + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x + dependencies: + lunr: 2.3.9 + marked: 4.3.0 + minimatch: 5.1.2 + shiki: 0.12.1 + typescript: 4.9.4 dev: true /typeorm@0.3.17(ioredis@5.3.2)(pg@8.11.3): @@ -18870,6 +19553,11 @@ packages: - supports-color dev: false + /typescript@4.9.4: + resolution: {integrity: sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==} + engines: {node: '>=4.2.0'} + hasBin: true + /typescript@5.0.4: resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} engines: {node: '>=12.20'} @@ -19318,6 +20006,14 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + /vscode-oniguruma@1.7.0: + resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} + dev: true + + /vscode-textmate@8.0.0: + resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} + dev: true + /vue-component-type-helpers@1.8.13: resolution: {integrity: sha512-zbCQviVRexZ7NF2kizQq5LicG5QGXPHPALKE3t59f5q2FwaG9GKtdhhIV4rw4LDUm9RkvGAP8TSXlXcBWY8rFQ==} dev: true @@ -19663,6 +20359,19 @@ packages: async-limiter: 1.0.1 dev: true + /ws@8.12.0: + resolution: {integrity: sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /ws@8.14.2(bufferutil@4.0.7)(utf-8-validate@6.0.3): resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} engines: {node: '>=10.0.0'} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ead1764a56..ef2bb67209 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,4 @@ packages: - 'packages/frontend' - 'packages/sw' - 'packages/misskey-js' + - 'packages/megalodon' diff --git a/scripts/clean-all.js b/scripts/clean-all.js index 4735eed760..e4f5acae0d 100644 --- a/scripts/clean-all.js +++ b/scripts/clean-all.js @@ -16,6 +16,8 @@ const fs = require('fs'); fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/sw/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/megalodon/lib', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../node_modules', { recursive: true, force: true }); diff --git a/scripts/clean.js b/scripts/clean.js index 812553e17b..df1d33888d 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -10,4 +10,5 @@ const fs = require('fs'); fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/megalodon/lib', { recursive: true, force: true }); })(); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index cf27517a3d..3fccfbc936 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -35,6 +35,12 @@ await execa('pnpm', ['--filter', 'misskey-js', 'build'], { stderr: process.stderr, }); +await execa("pnpm", ['--filter', 'megalodon', 'build'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, +}); + execa('pnpm', ['build-assets', '--watch'], { cwd: _dirname + '/../', stdout: process.stdout, From ceffd53d47900d18a7fd5b87c3a3af8cabdd5627 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sat, 23 Sep 2023 18:51:57 +0200 Subject: [PATCH 02/31] fix: spacing --- .../src/server/api/mastodon/MastodonApiServerService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index b79489d18d..3af93fda66 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -25,10 +25,10 @@ export function getClient(BASE_URL: string, authorization: string | undefined): @Injectable() export class MastodonApiServerService { constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, @Inject(DI.config) - private config: Config, + private config: Config, private metaService: MetaService, ) { } From 9dfc4dbf02e4e6185134cc26729c6e43d304140e Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 00:39:33 +0200 Subject: [PATCH 03/31] fix: converter returning sharkeyid wrong --- packages/backend/src/server/api/mastodon/converters.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 94b70230d8..a4429af677 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -26,7 +26,8 @@ export function convertId(in_id: string, id_convert_type: IdConvertType): string outStr = charFromNum(remainder) + outStr; input /= BigInt(36); } - return outStr; + let ReversedoutStr = outStr.split("").reduce((acc, char) => char + acc, ""); + return ReversedoutStr; default: throw new Error('Invalid ID conversion type'); From 4e401408e9a9d02b93d4710598184276f9d47737 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 00:39:55 +0200 Subject: [PATCH 04/31] fix: megalodon not having checks for emojis --- packages/megalodon/src/misskey/api_client.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index a0b01030d8..e4dd140459 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -136,7 +136,7 @@ namespace MisskeyAPI { avatar_static: u.avatarUrl, header: this.plcUrl, header_static: this.plcUrl, - emojis: u.emojis.map((e) => this.emoji(e)), + emojis: u.emojis && u.emojis.length > 0 ? u.emojis.map((e) => this.emoji(e)) : [], moved: null, fields: [], bot: false, @@ -322,9 +322,9 @@ namespace MisskeyAPI { plain_content: n.text ? n.text : null, created_at: n.createdAt, // Remove reaction emojis with names containing @ from the emojis list. - emojis: n.emojis + emojis: n.emojis && n.emojis.length > 0 ? n.emojis .filter((e) => e.name.indexOf("@") === -1) - .map((e) => this.emoji(e)), + .map((e) => this.emoji(e)) : [], replies_count: n.repliesCount, reblogs_count: n.renoteCount, favourites_count: this.getTotalReactions(n.reactions), @@ -343,7 +343,7 @@ namespace MisskeyAPI { language: null, pinned: null, // Use emojis list to provide URLs for emoji reactions. - reactions: this.mapReactions(n.emojis, n.reactions, n.myReaction), + reactions: n.emojis && n.emojis.length > 0 ? this.mapReactions(n.emojis, n.reactions, n.myReaction) : [], bookmarked: false, quote: n.renote && n.text ? this.note(n.renote, host) : null, }; From b25e19384563ddc2ad870b1b4d839d169a16fba5 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 00:41:13 +0200 Subject: [PATCH 05/31] upd: create auth endpoint --- .../src/server/api/mastodon/endpoints/auth.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/backend/src/server/api/mastodon/endpoints/auth.ts diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts new file mode 100644 index 0000000000..48b5ec55c9 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -0,0 +1,74 @@ +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 async function apiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { + const body: any = request.body || request.query; + try { + let scope = body.scopes; + if (typeof scope === "string") scope = scope.split(" "); + 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 scopeArr = Array.from(pushScope); + + const red = body.redirect_uris; + const appData = await client.registerApp(body.client_name, { + scopes: scopeArr, + redirect_uris: red, + website: body.website, + }); + const returns = { + 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, + }; + + return returns; + } catch (e: any) { + console.error(e); + return e.response.data; + } +} From 763d7c3a631266453d5001b1d7e60b1f81525013 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 00:42:03 +0200 Subject: [PATCH 06/31] add: timeline endpoint only with the functions for other endpoints --- .../server/api/mastodon/endpoints/timeline.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/backend/src/server/api/mastodon/endpoints/timeline.ts diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts new file mode 100644 index 0000000000..4d4cb2c419 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -0,0 +1,47 @@ +import { convertId, IdConvertType as IdType, convertAccount, convertConversation, convertList, convertStatus } from '../converters.js'; +import { ParsedUrlQuery } from "querystring"; + +export function limitToInt(q: ParsedUrlQuery) { + let object: any = q; + if (q.limit) + if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10); + if (q.offset) + if (typeof q.offset === "string") object.offset = parseInt(q.offset, 10); + return object; +} + +export function argsToBools(q: ParsedUrlQuery) { + // Values taken from https://docs.joinmastodon.org/client/intro/#boolean + const toBoolean = (value: string) => + !["0", "f", "F", "false", "FALSE", "off", "OFF"].includes(value); + + // Keys taken from: + // - https://docs.joinmastodon.org/methods/accounts/#statuses + // - https://docs.joinmastodon.org/methods/timelines/#public + // - https://docs.joinmastodon.org/methods/timelines/#tag + let object: any = q; + if (q.only_media) + if (typeof q.only_media === "string") + object.only_media = toBoolean(q.only_media); + if (q.exclude_replies) + if (typeof q.exclude_replies === "string") + object.exclude_replies = toBoolean(q.exclude_replies); + if (q.exclude_reblogs) + if (typeof q.exclude_reblogs === "string") + object.exclude_reblogs = toBoolean(q.exclude_reblogs); + if (q.pinned) + if (typeof q.pinned === "string") object.pinned = toBoolean(q.pinned); + if (q.local) + if (typeof q.local === "string") object.local = toBoolean(q.local); + return q; +} + +export function convertTimelinesArgsId(q: ParsedUrlQuery) { + if (typeof q.min_id === "string") + q.min_id = convertId(q.min_id, IdType.SharkeyId); + if (typeof q.max_id === "string") + q.max_id = convertId(q.max_id, IdType.SharkeyId); + if (typeof q.since_id === "string") + q.since_id = convertId(q.since_id, IdType.SharkeyId); + return q; +} \ No newline at end of file From 21015e7df69840dae72a6a399bbc3ce9d48aa834 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 00:42:59 +0200 Subject: [PATCH 07/31] add: account endpoint - Still work in progress --- .../server/api/mastodon/endpoints/account.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 packages/backend/src/server/api/mastodon/endpoints/account.ts diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts new file mode 100644 index 0000000000..33bd688e01 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -0,0 +1,147 @@ +import { FindOptionsWhere, IsNull } from "typeorm"; +import type { MegalodonInterface } from "megalodon"; +import type { FastifyRequest } from 'fastify'; +import { argsToBools, convertTimelinesArgsId, limitToInt } from "./timeline.js"; +import { convertId, IdConvertType as IdType, convertAccount, convertFeaturedTag, convertList, convertRelationship, convertStatus } from '../converters.js'; + +const relationshipModel = { + id: "", + following: false, + followed_by: false, + delivery_following: false, + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: false, + domain_blocking: false, + showing_reblogs: false, + endorsed: false, + notifying: false, + note: "", +}; + +export class apiAccountMastodon { + private request: FastifyRequest; + private client: MegalodonInterface; + private BASE_URL: string; + + constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) { + this.request = request; + this.client = client; + this.BASE_URL = BASE_URL; + } + + public async verifyCredentials() { + try { + const data = await this.client.verifyAccountCredentials(); + let acct = data.data; + acct.id = convertId(acct.id, IdType.MastodonId); + acct.display_name = acct.display_name || acct.username; + acct.url = `${this.BASE_URL}/@${acct.url}`; + acct.note = acct.note || ""; + acct.avatar_static = acct.avatar; + acct.header = acct.header || "/static-assets/transparent.png"; + acct.header_static = acct.header || "/static-assets/transparent.png"; + acct.source = { + note: acct.note, + fields: acct.fields, + privacy: await (this.client as any).getDefaultPostPrivacy(), + sensitive: false, + language: "", + }; + console.log(acct); + return acct; + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async updateCredentials() { + try { + const data = await this.client.updateCredentials(this.request.body as any); + return convertAccount(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async lookup() { + try { + const data = await this.client.search((this.request.query as any).acct, "accounts"); + return convertAccount(data.data.accounts[0]); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async getRelationships(users: [string]) { + try { + relationshipModel.id = users?.toString() || "1"; + + if (!users) { + return [relationshipModel]; + } + + let reqIds = []; + for (let i = 0; i < users.length; i++) { + reqIds.push(convertId(users[i], IdType.SharkeyId)); + } + + const data = await this.client.getRelationships(reqIds); + return data.data.map((relationship) => convertRelationship(relationship)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async getStatuses() { + try { + const data = await this.client.getAccountStatuses( + convertId((this.request.params as any).id, IdType.SharkeyId), + convertTimelinesArgsId(argsToBools(limitToInt(this.request.query as any))) + ); + return data.data.map((status) => convertStatus(status)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async getFollowers() { + try { + const data = await this.client.getAccountFollowers( + convertId((this.request.params as any).id, IdType.SharkeyId), + convertTimelinesArgsId(limitToInt(this.request.query as any)) + ); + return data.data.map((account) => convertAccount(account)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async getFollowing() { + try { + const data = await this.client.getAccountFollowing( + convertId((this.request.params as any).id, IdType.SharkeyId), + convertTimelinesArgsId(limitToInt(this.request.query as any)) + ); + return data.data.map((account) => convertAccount(account)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } +} \ No newline at end of file From b4674ce65c2535dd3afc908e5616e5b65fb07608 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 00:43:28 +0200 Subject: [PATCH 08/31] upd: add new endpoints to Masto API --- .../api/mastodon/MastodonApiServerService.ts | 157 +++++++++++++++++- 1 file changed, 155 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 3af93fda66..9d1ee61053 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -5,12 +5,14 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import megalodon, { MegalodonInterface } from "megalodon"; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; -import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment } from './converters.js'; +import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag } from './converters.js'; import { IsNull } from 'typeorm'; import type { Config } from '@/config.js'; import { getInstance } from './endpoints/meta.js'; import { MetaService } from '@/core/MetaService.js'; import multer from 'fastify-multer'; +import { apiAuthMastodon } from './endpoints/auth.js'; +import { apiAccountMastodon } from './endpoints/account.js'; const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); @@ -173,6 +175,19 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); + + fastify.post("/v1/apps", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); fastify.get("/v1/preferences", async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; @@ -186,7 +201,145 @@ export class MastodonApiServerService { console.error(e); reply.code(401).send(e.response.data); } - }); + }); + + //#region Accounts + fastify.get("/v1/accounts/verify_credentials", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.verifyCredentials()); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.patch("/v1/accounts/update_credentials", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.updateCredentials()); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/accounts/lookup", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.lookup()); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/accounts/relationships", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 + let users; + try { + let ids = _request.query ? (_request.query as any)["id[]"] : null; + if (typeof ids === "string") { + ids = [ids]; + } + users = ids; + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getRelationships(users)); + } catch (e: any) { + console.error(e); + let data = e.response.data; + data.users = users; + console.error(data); + reply.code(401).send(data); + } + }); + + fastify.get<{ Params: { id: string } }>("/v1/accounts/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const sharkId = convertId(_request.params.id, IdType.SharkeyId); + const data = await client.getAccount(sharkId); + reply.send(convertAccount(data.data)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/statuses", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getStatuses()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/featured_tags", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountFeaturedTags(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data.map((tag) => convertFeaturedTag(tag))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/followers", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getFollowers()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/following", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getFollowing()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + //#endregion done(); } } \ No newline at end of file From afda15260f4f97ec00b3e7fdf63bd13013daae40 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 01:44:53 +0200 Subject: [PATCH 09/31] upd: megalodon to v7 --- .gitignore | 1 + .../api/mastodon/MastodonApiServerService.ts | 4 +- .../src/server/api/mastodon/converters.ts | 4 - .../server/api/mastodon/endpoints/account.ts | 4 +- packages/frontend/vite.config.ts | 1 + packages/megalodon/.npmignore | 3 + packages/megalodon/package.json | 94 +- packages/megalodon/src/axios.d.ts | 2 +- packages/megalodon/src/cancel.ts | 16 +- packages/megalodon/src/converter.ts | 3 - packages/megalodon/src/default.ts | 6 +- packages/megalodon/src/detector.ts | 137 + packages/megalodon/src/entities/account.ts | 52 +- packages/megalodon/src/entities/activity.ts | 12 +- .../megalodon/src/entities/announcement.ts | 62 +- .../megalodon/src/entities/application.ts | 10 +- .../src/entities/async_attachment.ts | 22 +- packages/megalodon/src/entities/attachment.ts | 84 +- packages/megalodon/src/entities/card.ts | 30 +- packages/megalodon/src/entities/context.ts | 8 +- .../megalodon/src/entities/conversation.ts | 12 +- packages/megalodon/src/entities/emoji.ts | 14 +- .../megalodon/src/entities/featured_tag.ts | 12 +- packages/megalodon/src/entities/field.ts | 10 +- packages/megalodon/src/entities/filter.ts | 18 +- .../megalodon/src/entities/follow_request.ts | 27 + packages/megalodon/src/entities/history.ts | 10 +- .../megalodon/src/entities/identity_proof.ts | 14 +- packages/megalodon/src/entities/instance.ts | 69 +- packages/megalodon/src/entities/list.ts | 11 +- packages/megalodon/src/entities/marker.ts | 26 +- packages/megalodon/src/entities/mention.ts | 12 +- .../megalodon/src/entities/notification.ts | 19 +- packages/megalodon/src/entities/poll.ts | 19 +- .../megalodon/src/entities/poll_option.ts | 8 +- .../megalodon/src/entities/preferences.ts | 14 +- .../src/entities/push_subscription.ts | 26 +- packages/megalodon/src/entities/reaction.ts | 14 +- .../megalodon/src/entities/relationship.ts | 30 +- packages/megalodon/src/entities/report.ts | 23 +- packages/megalodon/src/entities/results.ts | 10 +- packages/megalodon/src/entities/role.ts | 5 + .../src/entities/scheduled_status.ts | 12 +- packages/megalodon/src/entities/source.ts | 14 +- packages/megalodon/src/entities/stats.ts | 10 +- packages/megalodon/src/entities/status.ts | 72 +- .../megalodon/src/entities/status_params.ts | 20 +- .../megalodon/src/entities/status_source.ts | 7 + packages/megalodon/src/entities/tag.ts | 12 +- packages/megalodon/src/entities/token.ts | 12 +- packages/megalodon/src/entities/urls.ts | 6 +- packages/megalodon/src/entity.ts | 4 +- packages/megalodon/src/filter_context.ts | 14 +- packages/megalodon/src/friendica.ts | 2868 +++++++++ .../megalodon/src/friendica/api_client.ts | 768 +++ .../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 | 48 + .../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 | 57 +- packages/megalodon/src/mastodon.ts | 3169 +++++++++ packages/megalodon/src/mastodon/api_client.ts | 661 ++ .../src/mastodon/entities/account.ts | 35 + .../src/mastodon/entities/activity.ts | 8 + .../src/mastodon/entities/announcement.ts | 40 + .../src/mastodon/entities/application.ts | 7 + .../src/mastodon/entities/async_attachment.ts | 14 + .../src/mastodon/entities/attachment.ts | 49 + .../megalodon/src/mastodon/entities/card.ts | 18 + .../src/mastodon/entities/context.ts | 8 + .../src/mastodon/entities/conversation.ts | 11 + .../megalodon/src/mastodon/entities/emoji.ts | 9 + .../src/mastodon/entities/featured_tag.ts | 8 + .../megalodon/src/mastodon/entities/field.ts | 7 + .../megalodon/src/mastodon/entities/filter.ts | 12 + .../src/mastodon/entities/history.ts | 7 + .../src/mastodon/entities/identity_proof.ts | 9 + .../src/mastodon/entities/instance.ts | 49 + .../megalodon/src/mastodon/entities/list.ts | 9 + .../megalodon/src/mastodon/entities/marker.ts | 14 + .../src/mastodon/entities/mention.ts | 8 + .../src/mastodon/entities/notification.ts | 14 + .../megalodon/src/mastodon/entities/poll.ts | 13 + .../src/mastodon/entities/poll_option.ts | 6 + .../src/mastodon/entities/preferences.ts | 9 + .../mastodon/entities/push_subscription.ts | 16 + .../src/mastodon/entities/relationship.ts | 18 + .../megalodon/src/mastodon/entities/report.ts | 17 + .../src/mastodon/entities/results.ts | 11 + .../megalodon/src/mastodon/entities/role.ts | 5 + .../src/mastodon/entities/scheduled_status.ts | 10 + .../megalodon/src/mastodon/entities/source.ts | 10 + .../megalodon/src/mastodon/entities/stats.ts | 7 + .../megalodon/src/mastodon/entities/status.ts | 49 + .../src/mastodon/entities/status_params.ts | 12 + .../src/mastodon/entities/status_source.ts | 7 + .../megalodon/src/mastodon/entities/tag.ts | 10 + .../megalodon/src/mastodon/entities/token.ts | 8 + .../megalodon/src/mastodon/entities/urls.ts | 5 + packages/megalodon/src/mastodon/entity.ts | 39 + .../megalodon/src/mastodon/notification.ts | 16 + packages/megalodon/src/mastodon/web_socket.ts | 348 + packages/megalodon/src/megalodon.ts | 2862 ++++----- packages/megalodon/src/misskey.ts | 5639 +++++++---------- packages/megalodon/src/misskey/api_client.ts | 1278 ++-- .../megalodon/src/misskey/entities/GetAll.ts | 6 - .../src/misskey/entities/announcement.ts | 17 +- .../megalodon/src/misskey/entities/app.ts | 14 +- .../src/misskey/entities/blocking.ts | 12 +- .../src/misskey/entities/createdNote.ts | 6 +- .../megalodon/src/misskey/entities/emoji.ts | 13 +- .../src/misskey/entities/favorite.ts | 12 +- .../megalodon/src/misskey/entities/field.ts | 7 - .../megalodon/src/misskey/entities/file.ts | 34 +- .../src/misskey/entities/followRequest.ts | 10 +- .../src/misskey/entities/follower.ts | 14 +- .../src/misskey/entities/following.ts | 14 +- .../megalodon/src/misskey/entities/hashtag.ts | 10 +- .../megalodon/src/misskey/entities/list.ts | 12 +- .../megalodon/src/misskey/entities/meta.ts | 57 +- .../megalodon/src/misskey/entities/mute.ts | 12 +- .../megalodon/src/misskey/entities/note.ts | 52 +- .../src/misskey/entities/notification.ts | 22 +- .../megalodon/src/misskey/entities/poll.ts | 20 +- .../src/misskey/entities/reaction.ts | 13 +- .../src/misskey/entities/relation.ts | 20 +- .../megalodon/src/misskey/entities/session.ts | 8 +- .../megalodon/src/misskey/entities/state.ts | 7 - .../megalodon/src/misskey/entities/stats.ts | 14 +- .../megalodon/src/misskey/entities/user.ts | 18 +- .../src/misskey/entities/userDetail.ts | 56 +- .../src/misskey/entities/userDetailMe.ts | 36 - .../megalodon/src/misskey/entities/userkey.ts | 8 +- packages/megalodon/src/misskey/entity.ts | 6 +- .../megalodon/src/misskey/notification.ts | 26 +- packages/megalodon/src/misskey/web_socket.ts | 803 ++- packages/megalodon/src/notification.ts | 32 +- packages/megalodon/src/oauth.ts | 204 +- packages/megalodon/src/parser.ts | 142 +- packages/megalodon/src/pleroma.ts | 3217 ++++++++++ packages/megalodon/src/pleroma/api_client.ts | 823 +++ .../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 | 64 + .../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 + packages/megalodon/src/proxy_config.ts | 132 +- packages/megalodon/src/response.ts | 12 +- .../megalodon/test/integration/cancel.spec.ts | 38 + .../test/integration/cancelWorker.ts | 5 + .../test/integration/detector.spec.ts | 67 + .../test/integration/mastodon.spec.ts | 218 + .../integration/mastodon/api_client.spec.ts | 176 + .../test/integration/megalodon.spec.ts | 27 - .../test/integration/misskey.spec.ts | 34 +- .../test/integration/pleroma.spec.ts | 222 + packages/megalodon/test/unit/mastodon.spec.ts | 6 + .../test/unit/mastodon/api_client.spec.ts | 80 + .../test/unit/misskey/api_client.spec.ts | 170 +- packages/megalodon/test/unit/parser.spec.ts | 8 +- .../test/unit/pleroma/api_client.spec.ts | 226 + .../megalodon/test/unit/webo_socket.spec.ts | 184 + packages/megalodon/tsconfig.json | 4 +- pnpm-lock.yaml | 718 +-- 234 files changed, 21334 insertions(+), 7675 deletions(-) create mode 100644 packages/megalodon/.npmignore delete mode 100644 packages/megalodon/src/converter.ts create mode 100644 packages/megalodon/src/detector.ts create mode 100644 packages/megalodon/src/entities/follow_request.ts create mode 100644 packages/megalodon/src/entities/role.ts create mode 100644 packages/megalodon/src/entities/status_source.ts create mode 100644 packages/megalodon/src/friendica.ts create mode 100644 packages/megalodon/src/friendica/api_client.ts create mode 100644 packages/megalodon/src/friendica/entities/account.ts create mode 100644 packages/megalodon/src/friendica/entities/activity.ts create mode 100644 packages/megalodon/src/friendica/entities/application.ts create mode 100644 packages/megalodon/src/friendica/entities/async_attachment.ts create mode 100644 packages/megalodon/src/friendica/entities/attachment.ts create mode 100644 packages/megalodon/src/friendica/entities/card.ts create mode 100644 packages/megalodon/src/friendica/entities/context.ts create mode 100644 packages/megalodon/src/friendica/entities/conversation.ts create mode 100644 packages/megalodon/src/friendica/entities/emoji.ts create mode 100644 packages/megalodon/src/friendica/entities/featured_tag.ts create mode 100644 packages/megalodon/src/friendica/entities/field.ts create mode 100644 packages/megalodon/src/friendica/entities/filter.ts create mode 100644 packages/megalodon/src/friendica/entities/follow_request.ts create mode 100644 packages/megalodon/src/friendica/entities/history.ts create mode 100644 packages/megalodon/src/friendica/entities/identity_proof.ts create mode 100644 packages/megalodon/src/friendica/entities/instance.ts create mode 100644 packages/megalodon/src/friendica/entities/list.ts create mode 100644 packages/megalodon/src/friendica/entities/marker.ts create mode 100644 packages/megalodon/src/friendica/entities/mention.ts create mode 100644 packages/megalodon/src/friendica/entities/notification.ts create mode 100644 packages/megalodon/src/friendica/entities/poll.ts create mode 100644 packages/megalodon/src/friendica/entities/poll_option.ts create mode 100644 packages/megalodon/src/friendica/entities/preferences.ts create mode 100644 packages/megalodon/src/friendica/entities/push_subscription.ts create mode 100644 packages/megalodon/src/friendica/entities/relationship.ts create mode 100644 packages/megalodon/src/friendica/entities/report.ts create mode 100644 packages/megalodon/src/friendica/entities/results.ts create mode 100644 packages/megalodon/src/friendica/entities/scheduled_status.ts create mode 100644 packages/megalodon/src/friendica/entities/source.ts create mode 100644 packages/megalodon/src/friendica/entities/stats.ts create mode 100644 packages/megalodon/src/friendica/entities/status.ts create mode 100644 packages/megalodon/src/friendica/entities/status_params.ts create mode 100644 packages/megalodon/src/friendica/entities/status_source.ts create mode 100644 packages/megalodon/src/friendica/entities/tag.ts create mode 100644 packages/megalodon/src/friendica/entities/token.ts create mode 100644 packages/megalodon/src/friendica/entities/urls.ts create mode 100644 packages/megalodon/src/friendica/entity.ts create mode 100644 packages/megalodon/src/friendica/notification.ts create mode 100644 packages/megalodon/src/friendica/web_socket.ts create mode 100644 packages/megalodon/src/mastodon.ts create mode 100644 packages/megalodon/src/mastodon/api_client.ts create mode 100644 packages/megalodon/src/mastodon/entities/account.ts create mode 100644 packages/megalodon/src/mastodon/entities/activity.ts create mode 100644 packages/megalodon/src/mastodon/entities/announcement.ts create mode 100644 packages/megalodon/src/mastodon/entities/application.ts create mode 100644 packages/megalodon/src/mastodon/entities/async_attachment.ts create mode 100644 packages/megalodon/src/mastodon/entities/attachment.ts create mode 100644 packages/megalodon/src/mastodon/entities/card.ts create mode 100644 packages/megalodon/src/mastodon/entities/context.ts create mode 100644 packages/megalodon/src/mastodon/entities/conversation.ts create mode 100644 packages/megalodon/src/mastodon/entities/emoji.ts create mode 100644 packages/megalodon/src/mastodon/entities/featured_tag.ts create mode 100644 packages/megalodon/src/mastodon/entities/field.ts create mode 100644 packages/megalodon/src/mastodon/entities/filter.ts create mode 100644 packages/megalodon/src/mastodon/entities/history.ts create mode 100644 packages/megalodon/src/mastodon/entities/identity_proof.ts create mode 100644 packages/megalodon/src/mastodon/entities/instance.ts create mode 100644 packages/megalodon/src/mastodon/entities/list.ts create mode 100644 packages/megalodon/src/mastodon/entities/marker.ts create mode 100644 packages/megalodon/src/mastodon/entities/mention.ts create mode 100644 packages/megalodon/src/mastodon/entities/notification.ts create mode 100644 packages/megalodon/src/mastodon/entities/poll.ts create mode 100644 packages/megalodon/src/mastodon/entities/poll_option.ts create mode 100644 packages/megalodon/src/mastodon/entities/preferences.ts create mode 100644 packages/megalodon/src/mastodon/entities/push_subscription.ts create mode 100644 packages/megalodon/src/mastodon/entities/relationship.ts create mode 100644 packages/megalodon/src/mastodon/entities/report.ts create mode 100644 packages/megalodon/src/mastodon/entities/results.ts create mode 100644 packages/megalodon/src/mastodon/entities/role.ts create mode 100644 packages/megalodon/src/mastodon/entities/scheduled_status.ts create mode 100644 packages/megalodon/src/mastodon/entities/source.ts create mode 100644 packages/megalodon/src/mastodon/entities/stats.ts create mode 100644 packages/megalodon/src/mastodon/entities/status.ts create mode 100644 packages/megalodon/src/mastodon/entities/status_params.ts create mode 100644 packages/megalodon/src/mastodon/entities/status_source.ts create mode 100644 packages/megalodon/src/mastodon/entities/tag.ts create mode 100644 packages/megalodon/src/mastodon/entities/token.ts create mode 100644 packages/megalodon/src/mastodon/entities/urls.ts create mode 100644 packages/megalodon/src/mastodon/entity.ts create mode 100644 packages/megalodon/src/mastodon/notification.ts create mode 100644 packages/megalodon/src/mastodon/web_socket.ts delete mode 100644 packages/megalodon/src/misskey/entities/GetAll.ts delete mode 100644 packages/megalodon/src/misskey/entities/field.ts delete mode 100644 packages/megalodon/src/misskey/entities/state.ts delete mode 100644 packages/megalodon/src/misskey/entities/userDetailMe.ts create mode 100644 packages/megalodon/src/pleroma.ts create mode 100644 packages/megalodon/src/pleroma/api_client.ts create mode 100644 packages/megalodon/src/pleroma/entities/account.ts create mode 100644 packages/megalodon/src/pleroma/entities/activity.ts create mode 100644 packages/megalodon/src/pleroma/entities/announcement.ts create mode 100644 packages/megalodon/src/pleroma/entities/application.ts create mode 100644 packages/megalodon/src/pleroma/entities/async_attachment.ts create mode 100644 packages/megalodon/src/pleroma/entities/attachment.ts create mode 100644 packages/megalodon/src/pleroma/entities/card.ts create mode 100644 packages/megalodon/src/pleroma/entities/context.ts create mode 100644 packages/megalodon/src/pleroma/entities/conversation.ts create mode 100644 packages/megalodon/src/pleroma/entities/emoji.ts create mode 100644 packages/megalodon/src/pleroma/entities/featured_tag.ts create mode 100644 packages/megalodon/src/pleroma/entities/field.ts create mode 100644 packages/megalodon/src/pleroma/entities/filter.ts create mode 100644 packages/megalodon/src/pleroma/entities/history.ts create mode 100644 packages/megalodon/src/pleroma/entities/identity_proof.ts create mode 100644 packages/megalodon/src/pleroma/entities/instance.ts create mode 100644 packages/megalodon/src/pleroma/entities/list.ts create mode 100644 packages/megalodon/src/pleroma/entities/marker.ts create mode 100644 packages/megalodon/src/pleroma/entities/mention.ts create mode 100644 packages/megalodon/src/pleroma/entities/notification.ts create mode 100644 packages/megalodon/src/pleroma/entities/poll.ts create mode 100644 packages/megalodon/src/pleroma/entities/poll_option.ts create mode 100644 packages/megalodon/src/pleroma/entities/preferences.ts create mode 100644 packages/megalodon/src/pleroma/entities/push_subscription.ts create mode 100644 packages/megalodon/src/pleroma/entities/reaction.ts create mode 100644 packages/megalodon/src/pleroma/entities/relationship.ts create mode 100644 packages/megalodon/src/pleroma/entities/report.ts create mode 100644 packages/megalodon/src/pleroma/entities/results.ts create mode 100644 packages/megalodon/src/pleroma/entities/scheduled_status.ts create mode 100644 packages/megalodon/src/pleroma/entities/source.ts create mode 100644 packages/megalodon/src/pleroma/entities/stats.ts create mode 100644 packages/megalodon/src/pleroma/entities/status.ts create mode 100644 packages/megalodon/src/pleroma/entities/status_params.ts create mode 100644 packages/megalodon/src/pleroma/entities/status_source.ts create mode 100644 packages/megalodon/src/pleroma/entities/tag.ts create mode 100644 packages/megalodon/src/pleroma/entities/token.ts create mode 100644 packages/megalodon/src/pleroma/entities/urls.ts create mode 100644 packages/megalodon/src/pleroma/entity.ts create mode 100644 packages/megalodon/src/pleroma/notification.ts create mode 100644 packages/megalodon/src/pleroma/web_socket.ts create mode 100644 packages/megalodon/test/integration/cancel.spec.ts create mode 100644 packages/megalodon/test/integration/cancelWorker.ts create mode 100644 packages/megalodon/test/integration/detector.spec.ts create mode 100644 packages/megalodon/test/integration/mastodon.spec.ts create mode 100644 packages/megalodon/test/integration/mastodon/api_client.spec.ts delete mode 100644 packages/megalodon/test/integration/megalodon.spec.ts create mode 100644 packages/megalodon/test/integration/pleroma.spec.ts create mode 100644 packages/megalodon/test/unit/mastodon.spec.ts create mode 100644 packages/megalodon/test/unit/mastodon/api_client.spec.ts create mode 100644 packages/megalodon/test/unit/pleroma/api_client.spec.ts create mode 100644 packages/megalodon/test/unit/webo_socket.spec.ts diff --git a/.gitignore b/.gitignore index 11e69b2621..3b15a676a7 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ temp # Sharkey /packages/megalodon/lib +/packages/megalodon-bk # blender backups *.blend1 diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 9d1ee61053..320fdf52d5 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -20,7 +20,7 @@ export function getClient(BASE_URL: string, authorization: string | undefined): const accessTokenArr = authorization?.split(" ") ?? [null]; const accessToken = accessTokenArr[accessTokenArr.length - 1]; const generator = (megalodon as any).default; - const client = generator(BASE_URL, accessToken) as MegalodonInterface; + const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface; return client; } @@ -303,7 +303,7 @@ export class MastodonApiServerService { const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const data = await client.getAccountFeaturedTags(convertId(_request.params.id, IdType.SharkeyId)); + const data = await client.getFeaturedTags(); reply.send(data.data.map((tag) => convertFeaturedTag(tag))); } catch (e: any) { console.error(e); diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index a4429af677..e6244455e0 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -83,8 +83,6 @@ export function convertNotification(notification: Entity.Notification) { notification.id = convertId(notification.id, IdConvertType.MastodonId); if (notification.status) notification.status = convertStatus(notification.status); - if (notification.reaction) - notification.reaction = convertReaction(notification.reaction); return notification; } @@ -120,8 +118,6 @@ export function convertStatus(status: Entity.Status) { })); if (status.poll) status.poll = convertPoll(status.poll); if (status.reblog) status.reblog = convertStatus(status.reblog); - if (status.quote) status.quote = convertStatus(status.quote); - status.reactions = status.reactions.map(convertReaction); return status; } diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 33bd688e01..205543f186 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -46,7 +46,7 @@ export class apiAccountMastodon { acct.source = { note: acct.note, fields: acct.fields, - privacy: await (this.client as any).getDefaultPostPrivacy(), + privacy: "", sensitive: false, language: "", }; @@ -72,7 +72,7 @@ export class apiAccountMastodon { public async lookup() { try { - const data = await this.client.search((this.request.query as any).acct, "accounts"); + const data = await this.client.search((this.request.query as any).acct, { type: "accounts" }); return convertAccount(data.data.accounts[0]); } catch (e: any) { console.error(e); diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index da976b7917..aac514728f 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -46,6 +46,7 @@ export function getConfig(): UserConfig { base: '/vite/', server: { + host: '0.0.0.0', port: 5173, }, diff --git a/packages/megalodon/.npmignore b/packages/megalodon/.npmignore new file mode 100644 index 0000000000..fd54d1deb4 --- /dev/null +++ b/packages/megalodon/.npmignore @@ -0,0 +1,3 @@ +node_modules +./src +tsconfig.json diff --git a/packages/megalodon/package.json b/packages/megalodon/package.json index 3403b94b47..ebd958834e 100644 --- a/packages/megalodon/package.json +++ b/packages/megalodon/package.json @@ -1,16 +1,35 @@ { "name": "megalodon", - "private": true, + "version": "7.0.1", + "description": "Mastodon API client for node.js and browser", "main": "./lib/src/index.js", "typings": "./lib/src/index.d.ts", "scripts": { "build": "tsc -p ./", - "build:debug": "pnpm run build", - "lint": "pnpm biome check **/*.ts --apply", - "format": "pnpm biome format --write src/**/*.ts", + "lint": "eslint --ext .js,.ts src", "doc": "typedoc --out ../docs ./src", "test": "NODE_ENV=test jest -u --maxWorkers=3" }, + "engines": { + "node": ">=15.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/h3poteto/megalodon.git" + }, + "keywords": [ + "mastodon", + "client", + "api", + "streaming", + "rest", + "proxy" + ], + "author": "h3poteto", + "license": "MIT", + "bugs": { + "url": "https://github.com/h3poteto/megalodon/issues" + }, "jest": { "moduleFileExtensions": [ "ts", @@ -25,59 +44,44 @@ ], "preset": "ts-jest/presets/default", "transform": { - "^.+\\.(ts|tsx)$": "ts-jest" - }, - "globals": { - "ts-jest": { + "^.+\\.(ts|tsx)$": ["ts-jest", { "tsconfig": "tsconfig.json" - } + }] }, "testEnvironment": "node" }, + "homepage": "https://github.com/h3poteto/megalodon#readme", "dependencies": { - "@types/oauth": "^0.9.0", - "@types/ws": "^8.5.4", - "axios": "1.2.2", - "dayjs": "^1.11.7", + "@types/oauth": "^0.9.2", + "@types/ws": "^8.5.5", + "axios": "1.5.0", + "dayjs": "^1.11.9", "form-data": "^4.0.0", - "https-proxy-agent": "^5.0.1", + "https-proxy-agent": "^7.0.2", "oauth": "^0.10.0", "object-assign-deep": "^0.4.0", "parse-link-header": "^2.0.0", - "socks-proxy-agent": "^7.0.0", - "typescript": "4.9.4", - "uuid": "^9.0.0", - "ws": "8.12.0", - "async-lock": "1.4.0" + "socks-proxy-agent": "^8.0.2", + "typescript": "5.1.6", + "uuid": "^9.0.1", + "ws": "8.14.2" }, "devDependencies": { - "@types/core-js": "^2.5.0", + "@types/core-js": "^2.5.6", "@types/form-data": "^2.5.0", - "@types/jest": "^29.4.0", - "@types/object-assign-deep": "^0.4.0", - "@types/parse-link-header": "^2.0.0", - "@types/uuid": "^9.0.0", - "@types/node": "18.11.18", - "@typescript-eslint/eslint-plugin": "^5.49.0", - "@typescript-eslint/parser": "^5.49.0", - "@types/async-lock": "1.4.0", - "eslint": "^8.32.0", - "eslint-config-prettier": "^8.6.0", - "eslint-config-standard": "^16.0.3", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-node": "^11.0.0", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-promise": "^6.1.1", - "eslint-plugin-standard": "^5.0.0", - "jest": "^29.4.0", - "jest-worker": "^29.4.0", + "@types/jest": "^29.5.5", + "@types/object-assign-deep": "^0.4.1", + "@types/parse-link-header": "^2.0.1", + "@types/uuid": "^9.0.4", + "@typescript-eslint/eslint-plugin": "^6.7.2", + "@typescript-eslint/parser": "^6.7.2", + "eslint": "^8.49.0", + "eslint-config-prettier": "^9.0.0", + "jest": "^29.7.0", + "jest-worker": "^29.7.0", "lodash": "^4.17.14", - "prettier": "^2.8.3", - "ts-jest": "^29.0.5", - "typedoc": "^0.23.24" - }, - "directories": { - "lib": "lib", - "test": "test" + "prettier": "^3.0.3", + "ts-jest": "^29.1.1", + "typedoc": "^0.25.1" } } diff --git a/packages/megalodon/src/axios.d.ts b/packages/megalodon/src/axios.d.ts index f19fe38a2b..114cb06aa0 100644 --- a/packages/megalodon/src/axios.d.ts +++ b/packages/megalodon/src/axios.d.ts @@ -1 +1 @@ -declare module "axios/lib/adapters/http"; +declare module 'axios/lib/adapters/http' diff --git a/packages/megalodon/src/cancel.ts b/packages/megalodon/src/cancel.ts index f8e4729b8e..3b905a492e 100644 --- a/packages/megalodon/src/cancel.ts +++ b/packages/megalodon/src/cancel.ts @@ -1,13 +1,13 @@ export class RequestCanceledError extends Error { - public isCancel: boolean; + public isCancel: boolean - constructor(msg: string) { - super(msg); - this.isCancel = true; - Object.setPrototypeOf(this, RequestCanceledError); - } + constructor(msg: string) { + super(msg) + this.isCancel = true + Object.setPrototypeOf(this, RequestCanceledError) + } } export const isCancel = (value: any): boolean => { - return value && value.isCancel; -}; + return value && value.isCancel +} diff --git a/packages/megalodon/src/converter.ts b/packages/megalodon/src/converter.ts deleted file mode 100644 index 93d669fa7d..0000000000 --- a/packages/megalodon/src/converter.ts +++ /dev/null @@ -1,3 +0,0 @@ -import MisskeyAPI from "./misskey/api_client"; - -export default MisskeyAPI.Converter; diff --git a/packages/megalodon/src/default.ts b/packages/megalodon/src/default.ts index 45bce13e21..0194b3dccb 100644 --- a/packages/megalodon/src/default.ts +++ b/packages/megalodon/src/default.ts @@ -1,3 +1,3 @@ -export const NO_REDIRECT = "urn:ietf:wg:oauth:2.0:oob"; -export const DEFAULT_SCOPE = ["read", "write", "follow"]; -export const DEFAULT_UA = "megalodon"; +export const NO_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob' +export const DEFAULT_SCOPE = ['read', 'write', 'follow'] +export const DEFAULT_UA = 'megalodon' diff --git a/packages/megalodon/src/detector.ts b/packages/megalodon/src/detector.ts new file mode 100644 index 0000000000..31f34d72f7 --- /dev/null +++ b/packages/megalodon/src/detector.ts @@ -0,0 +1,137 @@ +import axios, { AxiosRequestConfig } from 'axios' +import proxyAgent, { ProxyConfig } from './proxy_config' +import { NodeinfoError } from './megalodon' + +const NODEINFO_10 = 'http://nodeinfo.diaspora.software/ns/schema/1.0' +const NODEINFO_20 = 'http://nodeinfo.diaspora.software/ns/schema/2.0' +const NODEINFO_21 = 'http://nodeinfo.diaspora.software/ns/schema/2.1' + +type Links = { + links: Array +} + +type Link = { + href: string + rel: string +} + +type Nodeinfo10 = { + software: Software + metadata: Metadata +} + +type Nodeinfo20 = { + software: Software + metadata: Metadata +} + +type Nodeinfo21 = { + software: Software + metadata: Metadata +} + +type Software = { + name: string +} + +type Metadata = { + upstream?: { + name: string + } +} + +/** + * Detect SNS type. + * Now support Mastodon, Pleroma and Pixelfed. Throws an error when no known platform can be detected. + * + * @param url Base URL of SNS. + * @param proxyConfig Proxy setting, or set false if don't use proxy. + * @return SNS name. + */ +export const detector = async ( + url: string, + proxyConfig: ProxyConfig | false = false +): Promise<'mastodon' | 'pleroma' | 'misskey' | 'friendica'> => { + let options: AxiosRequestConfig = { + timeout: 20000 + } + if (proxyConfig) { + options = Object.assign(options, { + httpsAgent: proxyAgent(proxyConfig) + }) + } + + const res = await axios.get(url + '/.well-known/nodeinfo', options) + const link = res.data.links.find(l => l.rel === NODEINFO_20 || l.rel === NODEINFO_21) + if (!link) throw new NodeinfoError('Could not find nodeinfo') + switch (link.rel) { + case NODEINFO_10: { + const res = await axios.get(link.href, options) + switch (res.data.software.name) { + case 'pleroma': + return 'pleroma' + case 'akkoma': + return 'pleroma' + case 'mastodon': + return 'mastodon' + case "wildebeest": + return "mastodon" + case 'misskey': + return 'misskey' + case 'friendica': + return 'friendica' + default: + if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') { + return 'mastodon' + } + throw new NodeinfoError('Unknown SNS') + } + } + case NODEINFO_20: { + const res = await axios.get(link.href, options) + switch (res.data.software.name) { + case 'pleroma': + return 'pleroma' + case 'akkoma': + return 'pleroma' + case 'mastodon': + return 'mastodon' + case "wildebeest": + return "mastodon" + case 'misskey': + return 'misskey' + case 'friendica': + return 'friendica' + default: + if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') { + return 'mastodon' + } + throw new NodeinfoError('Unknown SNS') + } + } + case NODEINFO_21: { + const res = await axios.get(link.href, options) + switch (res.data.software.name) { + case 'pleroma': + return 'pleroma' + case 'akkoma': + return 'pleroma' + case 'mastodon': + return 'mastodon' + case "wildebeest": + return "mastodon" + case 'misskey': + return 'misskey' + case 'friendica': + return 'friendica' + default: + if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') { + return 'mastodon' + } + throw new NodeinfoError('Unknown SNS') + } + } + default: + throw new NodeinfoError('Could not find nodeinfo') + } +} diff --git a/packages/megalodon/src/entities/account.ts b/packages/megalodon/src/entities/account.ts index 06a85eb98e..89c0f17c4b 100644 --- a/packages/megalodon/src/entities/account.ts +++ b/packages/megalodon/src/entities/account.ts @@ -1,27 +1,35 @@ /// /// /// +/// namespace Entity { - export type Account = { - id: string; - username: string; - acct: string; - display_name: string; - locked: boolean; - 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 | null; - source?: Source; - }; + export type Account = { + id: string + username: string + acct: string + display_name: string + locked: boolean + discoverable?: boolean + group: boolean | null + 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 | null + source?: Source + role?: Role + mute_expires_at?: string + } } diff --git a/packages/megalodon/src/entities/activity.ts b/packages/megalodon/src/entities/activity.ts index 6bc0b6d80e..2494916a92 100644 --- a/packages/megalodon/src/entities/activity.ts +++ b/packages/megalodon/src/entities/activity.ts @@ -1,8 +1,8 @@ namespace Entity { - export type Activity = { - week: string; - statuses: string; - logins: string; - registrations: string; - }; + export type Activity = { + week: string + statuses: string + logins: string + registrations: string + } } diff --git a/packages/megalodon/src/entities/announcement.ts b/packages/megalodon/src/entities/announcement.ts index 7c79831634..0db9c23bbe 100644 --- a/packages/megalodon/src/entities/announcement.ts +++ b/packages/megalodon/src/entities/announcement.ts @@ -1,34 +1,40 @@ -/// /// -/// namespace Entity { - 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; - read?: boolean; - mentions: Array; - statuses: Array; - tags: Array; - emojis: Array; - reactions: Array; - }; + 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 | null + read: boolean | null + mentions: Array + statuses: Array + tags: Array + emojis: Array + reactions: Array + } - export type AnnouncementAccount = { - id: string; - username: string; - url: string; - acct: string; - }; + export type AnnouncementAccount = { + id: string + username: string + url: string + acct: string + } - export type AnnouncementStatus = { - id: string; - url: 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/entities/application.ts b/packages/megalodon/src/entities/application.ts index 9b98b12772..3af64fcf96 100644 --- a/packages/megalodon/src/entities/application.ts +++ b/packages/megalodon/src/entities/application.ts @@ -1,7 +1,7 @@ namespace Entity { - export type Application = { - name: string; - website?: string | null; - vapid_key?: string | null; - }; + export type Application = { + name: string + website?: string | null + vapid_key?: string | null + } } diff --git a/packages/megalodon/src/entities/async_attachment.ts b/packages/megalodon/src/entities/async_attachment.ts index 9cc17acc5c..b383f90c58 100644 --- a/packages/megalodon/src/entities/async_attachment.ts +++ b/packages/megalodon/src/entities/async_attachment.ts @@ -1,14 +1,14 @@ /// namespace Entity { - 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; - }; + 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/entities/attachment.ts b/packages/megalodon/src/entities/attachment.ts index 082c79eddb..aab1deadea 100644 --- a/packages/megalodon/src/entities/attachment.ts +++ b/packages/megalodon/src/entities/attachment.ts @@ -1,49 +1,49 @@ namespace Entity { - export type Sub = { - // For Image, Gifv, and Video - width?: number; - height?: number; - size?: string; - aspect?: number; + export type Sub = { + // For Image, Gifv, and Video + width?: number + height?: number + size?: string + aspect?: number - // For Gifv and Video - frame_rate?: string; + // For Gifv and Video + frame_rate?: string - // For Audio, Gifv, and Video - duration?: number; - bitrate?: number; - }; + // For Audio, Gifv, and Video + duration?: number + bitrate?: number + } - export type Focus = { - x: number; - y: 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 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; - }; + 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/entities/card.ts b/packages/megalodon/src/entities/card.ts index 356d99aee4..1ef6f5e4d6 100644 --- a/packages/megalodon/src/entities/card.ts +++ b/packages/megalodon/src/entities/card.ts @@ -1,16 +1,18 @@ namespace Entity { - export type Card = { - url: string; - title: string; - description: string; - type: "link" | "photo" | "video" | "rich"; - image?: string; - author_name?: string; - author_url?: string; - provider_name?: string; - provider_url?: string; - html?: string; - width?: number; - height?: number; - }; + export type Card = { + url: string + title: string + description: string + type: 'link' | 'photo' | 'video' | 'rich' + image: string | null + author_name: string | null + author_url: string | null + provider_name: string | null + provider_url: string | null + html: string | null + width: number | null + height: number | null + embed_url: string | null + blurhash: string | null + } } diff --git a/packages/megalodon/src/entities/context.ts b/packages/megalodon/src/entities/context.ts index a794a7c5a8..3f2eda58f7 100644 --- a/packages/megalodon/src/entities/context.ts +++ b/packages/megalodon/src/entities/context.ts @@ -1,8 +1,8 @@ /// namespace Entity { - export type Context = { - ancestors: Array; - descendants: Array; - }; + export type Context = { + ancestors: Array + descendants: Array + } } diff --git a/packages/megalodon/src/entities/conversation.ts b/packages/megalodon/src/entities/conversation.ts index 2bdc196661..cdadf1e0f2 100644 --- a/packages/megalodon/src/entities/conversation.ts +++ b/packages/megalodon/src/entities/conversation.ts @@ -2,10 +2,10 @@ /// namespace Entity { - export type Conversation = { - id: string; - accounts: Array; - last_status: Status | null; - unread: boolean; - }; + export type Conversation = { + id: string + accounts: Array + last_status: Status | null + unread: boolean + } } diff --git a/packages/megalodon/src/entities/emoji.ts b/packages/megalodon/src/entities/emoji.ts index 10c32ab0bd..546ef818f9 100644 --- a/packages/megalodon/src/entities/emoji.ts +++ b/packages/megalodon/src/entities/emoji.ts @@ -1,9 +1,9 @@ namespace Entity { - export type Emoji = { - shortcode: string; - static_url: string; - url: string; - visible_in_picker: boolean; - category: string; - }; + export type Emoji = { + shortcode: string + static_url: string + url: string + visible_in_picker: boolean + category?: string + } } diff --git a/packages/megalodon/src/entities/featured_tag.ts b/packages/megalodon/src/entities/featured_tag.ts index fc9f8c69cc..06ae6d7a9d 100644 --- a/packages/megalodon/src/entities/featured_tag.ts +++ b/packages/megalodon/src/entities/featured_tag.ts @@ -1,8 +1,8 @@ namespace Entity { - export type FeaturedTag = { - id: string; - name: string; - statuses_count: number; - last_status_at: string; - }; + export type FeaturedTag = { + id: string + name: string + statuses_count: number + last_status_at: string + } } diff --git a/packages/megalodon/src/entities/field.ts b/packages/megalodon/src/entities/field.ts index de4b6b2b72..03e4604b02 100644 --- a/packages/megalodon/src/entities/field.ts +++ b/packages/megalodon/src/entities/field.ts @@ -1,7 +1,7 @@ namespace Entity { - export type Field = { - name: string; - value: string; - verified_at: string | null; - }; + export type Field = { + name: string + value: string + verified_at: string | null + } } diff --git a/packages/megalodon/src/entities/filter.ts b/packages/megalodon/src/entities/filter.ts index 55b7305cc3..ffbacb7287 100644 --- a/packages/megalodon/src/entities/filter.ts +++ b/packages/megalodon/src/entities/filter.ts @@ -1,12 +1,12 @@ namespace Entity { - export type Filter = { - id: string; - phrase: string; - context: Array; - expires_at: string | null; - irreversible: boolean; - whole_word: boolean; - }; + export type Filter = { + id: string + phrase: string + context: Array + expires_at: string | null + irreversible: boolean + whole_word: boolean + } - export type FilterContext = string; + export type FilterContext = string } diff --git a/packages/megalodon/src/entities/follow_request.ts b/packages/megalodon/src/entities/follow_request.ts new file mode 100644 index 0000000000..84ea4d02c8 --- /dev/null +++ b/packages/megalodon/src/entities/follow_request.ts @@ -0,0 +1,27 @@ +/// +/// + +namespace Entity { + 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/entities/history.ts b/packages/megalodon/src/entities/history.ts index 4676357d69..0709694260 100644 --- a/packages/megalodon/src/entities/history.ts +++ b/packages/megalodon/src/entities/history.ts @@ -1,7 +1,7 @@ namespace Entity { - export type History = { - day: string; - uses: number; - accounts: number; - }; + export type History = { + day: string + uses: number + accounts: number + } } diff --git a/packages/megalodon/src/entities/identity_proof.ts b/packages/megalodon/src/entities/identity_proof.ts index 3b42e6f412..ff857addb0 100644 --- a/packages/megalodon/src/entities/identity_proof.ts +++ b/packages/megalodon/src/entities/identity_proof.ts @@ -1,9 +1,9 @@ namespace Entity { - export type IdentityProof = { - provider: string; - provider_username: string; - updated_at: string; - proof_url: string; - profile_url: string; - }; + export type IdentityProof = { + provider: string + provider_username: string + updated_at: string + proof_url: string + profile_url: string + } } diff --git a/packages/megalodon/src/entities/instance.ts b/packages/megalodon/src/entities/instance.ts index 9c0f572db4..8f4808be8f 100644 --- a/packages/megalodon/src/entities/instance.ts +++ b/packages/megalodon/src/entities/instance.ts @@ -3,39 +3,38 @@ /// namespace Entity { - export type Instance = { - uri: string; - title: string; - description: string; - email: string; - version: string; - thumbnail: string | null; - urls: URLs; - stats: Stats; - languages: Array; - contact_account: Account | null; - max_toot_chars?: number; - registrations?: boolean; - configuration?: { - statuses: { - max_characters: number; - max_media_attachments: number; - characters_reserved_per_url: number; - }; - media_attachments: { - supported_mime_types: Array; - image_size_limit: number; - image_matrix_limit: number; - video_size_limit: number; - video_frame_limit: number; - video_matrix_limit: number; - }; - polls: { - max_options: number; - max_characters_per_option: number; - min_expiration: number; - max_expiration: number; - }; - }; - }; + 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 + configuration: { + statuses: { + max_characters: number + max_media_attachments?: number + characters_reserved_per_url?: number + } + polls?: { + max_options: number + max_characters_per_option: number + min_expiration: number + max_expiration: number + } + } + contact_account?: Account + rules?: Array + } + + export type InstanceRule = { + id: string + text: string + } } diff --git a/packages/megalodon/src/entities/list.ts b/packages/megalodon/src/entities/list.ts index 97e75286b2..58c264abab 100644 --- a/packages/megalodon/src/entities/list.ts +++ b/packages/megalodon/src/entities/list.ts @@ -1,6 +1,9 @@ namespace Entity { - export type List = { - id: string; - title: string; - }; + export type List = { + id: string + title: string + replies_policy: RepliesPolicy | null + } + + export type RepliesPolicy = 'followed' | 'list' | 'none' } diff --git a/packages/megalodon/src/entities/marker.ts b/packages/megalodon/src/entities/marker.ts index 7ee99282ca..33cb98a10c 100644 --- a/packages/megalodon/src/entities/marker.ts +++ b/packages/megalodon/src/entities/marker.ts @@ -1,15 +1,15 @@ namespace Entity { - export type Marker = { - home?: { - last_read_id: string; - version: number; - updated_at: string; - }; - notifications?: { - last_read_id: string; - version: number; - updated_at: string; - unread_count?: number; - }; - }; + export type Marker = { + home?: { + last_read_id: string + version: number + updated_at: string + } + notifications?: { + last_read_id: string + version: number + updated_at: string + unread_count?: number + } + } } diff --git a/packages/megalodon/src/entities/mention.ts b/packages/megalodon/src/entities/mention.ts index 4fe36a6553..046912971c 100644 --- a/packages/megalodon/src/entities/mention.ts +++ b/packages/megalodon/src/entities/mention.ts @@ -1,8 +1,8 @@ namespace Entity { - export type Mention = { - id: string; - username: string; - url: string; - acct: string; - }; + export type Mention = { + id: string + username: string + url: string + acct: string + } } diff --git a/packages/megalodon/src/entities/notification.ts b/packages/megalodon/src/entities/notification.ts index 68eff3347e..653d235d99 100644 --- a/packages/megalodon/src/entities/notification.ts +++ b/packages/megalodon/src/entities/notification.ts @@ -2,14 +2,15 @@ /// namespace Entity { - export type Notification = { - account: Account; - created_at: string; - id: string; - status?: Status; - reaction?: Reaction; - type: NotificationType; - }; + export type Notification = { + account: Account + created_at: string + id: string + status?: Status + emoji?: string + type: NotificationType + target?: Account + } - export type NotificationType = string; + export type NotificationType = string } diff --git a/packages/megalodon/src/entities/poll.ts b/packages/megalodon/src/entities/poll.ts index 2539d68b20..69706e8ae1 100644 --- a/packages/megalodon/src/entities/poll.ts +++ b/packages/megalodon/src/entities/poll.ts @@ -1,14 +1,13 @@ /// namespace Entity { - export type Poll = { - id: string; - expires_at: string | null; - expired: boolean; - multiple: boolean; - votes_count: number; - options: Array; - voted: boolean; - own_votes: Array; - }; + 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/entities/poll_option.ts b/packages/megalodon/src/entities/poll_option.ts index e818a8607b..ae4c638498 100644 --- a/packages/megalodon/src/entities/poll_option.ts +++ b/packages/megalodon/src/entities/poll_option.ts @@ -1,6 +1,6 @@ namespace Entity { - export type PollOption = { - title: string; - votes_count: number | null; - }; + export type PollOption = { + title: string + votes_count: number | null + } } diff --git a/packages/megalodon/src/entities/preferences.ts b/packages/megalodon/src/entities/preferences.ts index 7994dc568e..cb5797c4ce 100644 --- a/packages/megalodon/src/entities/preferences.ts +++ b/packages/megalodon/src/entities/preferences.ts @@ -1,9 +1,9 @@ namespace Entity { - 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; - }; + 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/entities/push_subscription.ts b/packages/megalodon/src/entities/push_subscription.ts index ad1146a242..fe7464e8e2 100644 --- a/packages/megalodon/src/entities/push_subscription.ts +++ b/packages/megalodon/src/entities/push_subscription.ts @@ -1,16 +1,16 @@ namespace Entity { - export type Alerts = { - follow: boolean; - favourite: boolean; - mention: boolean; - reblog: boolean; - poll: boolean; - }; + 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; - }; + export type PushSubscription = { + id: string + endpoint: string + server_key: string + alerts: Alerts + } } diff --git a/packages/megalodon/src/entities/reaction.ts b/packages/megalodon/src/entities/reaction.ts index 4edbec6a7d..8c626f9e84 100644 --- a/packages/megalodon/src/entities/reaction.ts +++ b/packages/megalodon/src/entities/reaction.ts @@ -1,12 +1,10 @@ /// namespace Entity { - export type Reaction = { - count: number; - me: boolean; - name: string; - url?: string; - static_url?: string; - accounts?: Array; - }; + export type Reaction = { + count: number + me: boolean + name: string + accounts?: Array + } } diff --git a/packages/megalodon/src/entities/relationship.ts b/packages/megalodon/src/entities/relationship.ts index 91802d5c88..283a1158c6 100644 --- a/packages/megalodon/src/entities/relationship.ts +++ b/packages/megalodon/src/entities/relationship.ts @@ -1,17 +1,17 @@ namespace Entity { - export type Relationship = { - id: string; - following: boolean; - followed_by: boolean; - delivery_following?: boolean; - blocking: boolean; - blocked_by: boolean; - muting: boolean; - muting_notifications: boolean; - requested: boolean; - domain_blocking: boolean; - showing_reblogs: boolean; - endorsed: boolean; - notifying: boolean; - }; + 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/entities/report.ts b/packages/megalodon/src/entities/report.ts index 6862a5fabe..353886a344 100644 --- a/packages/megalodon/src/entities/report.ts +++ b/packages/megalodon/src/entities/report.ts @@ -1,9 +1,18 @@ +/// + namespace Entity { - export type Report = { - id: string; - action_taken: string; - comment: string; - account_id: string; - status_ids: Array; - }; + export type Report = { + id: string + action_taken: boolean + action_taken_at: string | null + status_ids: Array | null + rule_ids: Array | null + // These parameters don't exist in Pleroma + category: Category | null + comment: string | null + forwarded: boolean | null + target_account?: Account | null + } + + export type Category = 'spam' | 'violation' | 'other' } diff --git a/packages/megalodon/src/entities/results.ts b/packages/megalodon/src/entities/results.ts index 4448e53350..fe168de67b 100644 --- a/packages/megalodon/src/entities/results.ts +++ b/packages/megalodon/src/entities/results.ts @@ -3,9 +3,9 @@ /// namespace Entity { - export type Results = { - accounts: Array; - statuses: Array; - hashtags: Array; - }; + export type Results = { + accounts: Array + statuses: Array + hashtags: Array + } } diff --git a/packages/megalodon/src/entities/role.ts b/packages/megalodon/src/entities/role.ts new file mode 100644 index 0000000000..caaae9ea12 --- /dev/null +++ b/packages/megalodon/src/entities/role.ts @@ -0,0 +1,5 @@ +namespace Entity { + export type Role = { + name: string + } +} diff --git a/packages/megalodon/src/entities/scheduled_status.ts b/packages/megalodon/src/entities/scheduled_status.ts index 78dfb8ed26..561a5b9f2c 100644 --- a/packages/megalodon/src/entities/scheduled_status.ts +++ b/packages/megalodon/src/entities/scheduled_status.ts @@ -1,10 +1,10 @@ /// /// namespace Entity { - export type ScheduledStatus = { - id: string; - scheduled_at: string; - params: StatusParams; - media_attachments: Array; - }; + export type ScheduledStatus = { + id: string + scheduled_at: string + params: StatusParams + media_attachments: Array | null + } } diff --git a/packages/megalodon/src/entities/source.ts b/packages/megalodon/src/entities/source.ts index 913b02fda7..d87cf55d85 100644 --- a/packages/megalodon/src/entities/source.ts +++ b/packages/megalodon/src/entities/source.ts @@ -1,10 +1,10 @@ /// namespace Entity { - export type Source = { - privacy: string | null; - sensitive: boolean | null; - language: string | null; - note: string; - fields: Array; - }; + export type Source = { + privacy: string | null + sensitive: boolean | null + language: string | null + note: string + fields: Array + } } diff --git a/packages/megalodon/src/entities/stats.ts b/packages/megalodon/src/entities/stats.ts index 6471df039a..76f0bad34c 100644 --- a/packages/megalodon/src/entities/stats.ts +++ b/packages/megalodon/src/entities/stats.ts @@ -1,7 +1,7 @@ namespace Entity { - export type Stats = { - user_count: number; - status_count: number; - domain_count: number; - }; + export type Stats = { + user_count: number + status_count: number + domain_count: number + } } diff --git a/packages/megalodon/src/entities/status.ts b/packages/megalodon/src/entities/status.ts index f27f728b54..295703e57c 100644 --- a/packages/megalodon/src/entities/status.ts +++ b/packages/megalodon/src/entities/status.ts @@ -1,7 +1,6 @@ /// /// /// -/// /// /// /// @@ -9,37 +8,42 @@ /// namespace Entity { - 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; - plain_content: string | null; - created_at: string; - 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; - reactions: Array; - quote: Status | null; - bookmarked: boolean; - }; + 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 + plain_content: string | null + created_at: string + 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 + emoji_reactions: Array + quote: boolean + bookmarked: boolean + } + + export type StatusTag = { + name: string + url: string + } } diff --git a/packages/megalodon/src/entities/status_params.ts b/packages/megalodon/src/entities/status_params.ts index 18908c01c1..82d7890868 100644 --- a/packages/megalodon/src/entities/status_params.ts +++ b/packages/megalodon/src/entities/status_params.ts @@ -1,12 +1,12 @@ namespace Entity { - 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"; - scheduled_at: string | null; - application_id: string; - }; + 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 + application_id: number | null + } } diff --git a/packages/megalodon/src/entities/status_source.ts b/packages/megalodon/src/entities/status_source.ts new file mode 100644 index 0000000000..0de7030ed5 --- /dev/null +++ b/packages/megalodon/src/entities/status_source.ts @@ -0,0 +1,7 @@ +namespace Entity { + export type StatusSource = { + id: string + text: string + spoiler_text: string + } +} diff --git a/packages/megalodon/src/entities/tag.ts b/packages/megalodon/src/entities/tag.ts index ccc88aece6..ddc5fe92be 100644 --- a/packages/megalodon/src/entities/tag.ts +++ b/packages/megalodon/src/entities/tag.ts @@ -1,10 +1,10 @@ /// namespace Entity { - export type Tag = { - name: string; - url: string; - history: Array | null; - following?: boolean; - }; + export type Tag = { + name: string + url: string + history: Array + following?: boolean + } } diff --git a/packages/megalodon/src/entities/token.ts b/packages/megalodon/src/entities/token.ts index 1583edafb1..6fa28e39b5 100644 --- a/packages/megalodon/src/entities/token.ts +++ b/packages/megalodon/src/entities/token.ts @@ -1,8 +1,8 @@ namespace Entity { - export type Token = { - access_token: string; - token_type: string; - scope: string; - created_at: number; - }; + export type Token = { + access_token: string + token_type: string + scope: string + created_at: number + } } diff --git a/packages/megalodon/src/entities/urls.ts b/packages/megalodon/src/entities/urls.ts index 1ee9ed67c9..4a980d589d 100644 --- a/packages/megalodon/src/entities/urls.ts +++ b/packages/megalodon/src/entities/urls.ts @@ -1,5 +1,5 @@ namespace Entity { - export type URLs = { - streaming_api: string; - }; + export type URLs = { + streaming_api: string + } } diff --git a/packages/megalodon/src/entity.ts b/packages/megalodon/src/entity.ts index b73d2b359b..387981cec0 100644 --- a/packages/megalodon/src/entity.ts +++ b/packages/megalodon/src/entity.ts @@ -11,6 +11,7 @@ /// /// /// +/// /// /// /// @@ -31,8 +32,9 @@ /// /// /// +/// /// /// /// -export default Entity; +export default Entity diff --git a/packages/megalodon/src/filter_context.ts b/packages/megalodon/src/filter_context.ts index 4c83cb15f2..c69be98cd2 100644 --- a/packages/megalodon/src/filter_context.ts +++ b/packages/megalodon/src/filter_context.ts @@ -1,11 +1,11 @@ -import Entity from "./entity"; +import Entity from './entity' namespace FilterContext { - export const Home: Entity.FilterContext = "home"; - export const Notifications: Entity.FilterContext = "notifications"; - export const Public: Entity.FilterContext = "public"; - export const Thread: Entity.FilterContext = "thread"; - export const Account: Entity.FilterContext = "account"; + export const Home: Entity.FilterContext = 'home' + export const Notifications: Entity.FilterContext = 'notifications' + export const Public: Entity.FilterContext = 'public' + export const Thread: Entity.FilterContext = 'thread' + export const Account: Entity.FilterContext = 'account' } -export default FilterContext; +export default FilterContext diff --git a/packages/megalodon/src/friendica.ts b/packages/megalodon/src/friendica.ts new file mode 100644 index 0000000000..c5ee9d59ce --- /dev/null +++ b/packages/megalodon/src/friendica.ts @@ -0,0 +1,2868 @@ +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 new file mode 100644 index 0000000000..1f27fd6d20 --- /dev/null +++ b/packages/megalodon/src/friendica/api_client.ts @@ -0,0 +1,768 @@ +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, + 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 new file mode 100644 index 0000000000..670a583712 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/account.ts @@ -0,0 +1,29 @@ +/// +/// +/// +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 new file mode 100644 index 0000000000..4db360d233 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/activity.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..5e54ce82d8 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/application.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..76934af66a --- /dev/null +++ b/packages/megalodon/src/friendica/entities/async_attachment.ts @@ -0,0 +1,14 @@ +/// +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 new file mode 100644 index 0000000000..04be0e72d2 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/attachment.ts @@ -0,0 +1,49 @@ +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 new file mode 100644 index 0000000000..c23471983b --- /dev/null +++ b/packages/megalodon/src/friendica/entities/card.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000000..9c977544a7 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/context.ts @@ -0,0 +1,8 @@ +/// + +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 new file mode 100644 index 0000000000..550ae70817 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/conversation.ts @@ -0,0 +1,11 @@ +/// +/// + +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 new file mode 100644 index 0000000000..a0d92e6bc7 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/emoji.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..14dd1a8263 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/featured_tag.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..299ca0a456 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/field.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..a71a936ab1 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/filter.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000000..83f5bf9ba9 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/follow_request.ts @@ -0,0 +1,27 @@ +/// +/// + +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 new file mode 100644 index 0000000000..8f9cd6bd6b --- /dev/null +++ b/packages/megalodon/src/friendica/entities/history.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..fb6166c65f --- /dev/null +++ b/packages/megalodon/src/friendica/entities/identity_proof.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000000..a86390eb0b --- /dev/null +++ b/packages/megalodon/src/friendica/entities/instance.ts @@ -0,0 +1,28 @@ +/// +/// +/// + +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 new file mode 100644 index 0000000000..90487aec28 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/list.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000000..4ec41a07d6 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/marker.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000000..0e93333fe8 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/mention.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..acdbfb9276 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/notification.ts @@ -0,0 +1,14 @@ +/// +/// + +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 new file mode 100644 index 0000000000..4ac2262c5e --- /dev/null +++ b/packages/megalodon/src/friendica/entities/poll.ts @@ -0,0 +1,13 @@ +/// + +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 new file mode 100644 index 0000000000..f9628ddd80 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/poll_option.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..dec8b511be --- /dev/null +++ b/packages/megalodon/src/friendica/entities/preferences.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000000..857a98f27e --- /dev/null +++ b/packages/megalodon/src/friendica/entities/push_subscription.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000000..bba3099a82 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/relationship.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000000..f20d6d2db1 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/report.ts @@ -0,0 +1,16 @@ +/// + +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 new file mode 100644 index 0000000000..7af2356574 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/results.ts @@ -0,0 +1,11 @@ +/// +/// +/// + +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 new file mode 100644 index 0000000000..da292f7008 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/scheduled_status.ts @@ -0,0 +1,10 @@ +/// +/// +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 new file mode 100644 index 0000000000..4033e911e8 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/source.ts @@ -0,0 +1,10 @@ +/// +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 new file mode 100644 index 0000000000..8ef290b7bc --- /dev/null +++ b/packages/megalodon/src/friendica/entities/stats.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..9d2e8b9666 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/status.ts @@ -0,0 +1,48 @@ +/// +/// +/// +/// +/// +/// +/// + +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 + 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 new file mode 100644 index 0000000000..6a14af837a --- /dev/null +++ b/packages/megalodon/src/friendica/entities/status_params.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000000..2b5ee9bd0f --- /dev/null +++ b/packages/megalodon/src/friendica/entities/status_source.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..f7998d22fd --- /dev/null +++ b/packages/megalodon/src/friendica/entities/tag.ts @@ -0,0 +1,10 @@ +/// + +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 new file mode 100644 index 0000000000..904d68651f --- /dev/null +++ b/packages/megalodon/src/friendica/entities/token.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..8c736b9ef4 --- /dev/null +++ b/packages/megalodon/src/friendica/entities/urls.ts @@ -0,0 +1,5 @@ +namespace FriendicaEntity { + export type URLs = { + streaming_api: string + } +} diff --git a/packages/megalodon/src/friendica/entity.ts b/packages/megalodon/src/friendica/entity.ts new file mode 100644 index 0000000000..6d64f061ce --- /dev/null +++ b/packages/megalodon/src/friendica/entity.ts @@ -0,0 +1,38 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// + +export default FriendicaEntity diff --git a/packages/megalodon/src/friendica/notification.ts b/packages/megalodon/src/friendica/notification.ts new file mode 100644 index 0000000000..78701c46bc --- /dev/null +++ b/packages/megalodon/src/friendica/notification.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000000..ca16f24a5f --- /dev/null +++ b/packages/megalodon/src/friendica/web_socket.ts @@ -0,0 +1,18 @@ +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 758d3a46ad..ae7c45e40a 100644 --- a/packages/megalodon/src/index.ts +++ b/packages/megalodon/src/index.ts @@ -1,32 +1,31 @@ -import Response from "./response"; -import OAuth from "./oauth"; -import { isCancel, RequestCanceledError } from "./cancel"; -import { ProxyConfig } from "./proxy_config"; -import generator, { - detector, - MegalodonInterface, - WebSocketInterface, -} from "./megalodon"; -import Misskey from "./misskey"; -import Entity from "./entity"; -import NotificationType from "./notification"; -import FilterContext from "./filter_context"; -import Converter from "./converter"; +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 { 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' export { - Response, - OAuth, - RequestCanceledError, - isCancel, - ProxyConfig, - detector, - MegalodonInterface, - WebSocketInterface, - NotificationType, - FilterContext, - Misskey, - Entity, - Converter, -}; + Response, + OAuth, + RequestCanceledError, + isCancel, + ProxyConfig, + detector, + MegalodonInterface, + WebSocketInterface, + NotificationType, + FilterContext, + Mastodon, + Pleroma, + Misskey, + Entity +} -export default generator; +export default generator diff --git a/packages/megalodon/src/mastodon.ts b/packages/megalodon/src/mastodon.ts new file mode 100644 index 0000000000..4a8b1fc1ea --- /dev/null +++ b/packages/megalodon/src/mastodon.ts @@ -0,0 +1,3169 @@ +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 new file mode 100644 index 0000000000..58f56d1013 --- /dev/null +++ b/packages/megalodon/src/mastodon/api_client.ts @@ -0,0 +1,661 @@ +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, + 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/account.ts b/packages/megalodon/src/mastodon/entities/account.ts new file mode 100644 index 0000000000..9055fdd915 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/account.ts @@ -0,0 +1,35 @@ +/// +/// +/// +/// +namespace MastodonEntity { + export type Account = { + id: string + username: string + acct: string + display_name: string + locked: boolean + discoverable?: boolean + group: boolean | null + 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 + role?: Role + mute_expires_at?: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/activity.ts b/packages/megalodon/src/mastodon/entities/activity.ts new file mode 100644 index 0000000000..3e84c9df07 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/activity.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type Activity = { + week: string + statuses: string + logins: string + registrations: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/announcement.ts b/packages/megalodon/src/mastodon/entities/announcement.ts new file mode 100644 index 0000000000..426112e76f --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/announcement.ts @@ -0,0 +1,40 @@ +/// + +namespace MastodonEntity { + 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 + read: boolean | null + 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/mastodon/entities/application.ts b/packages/megalodon/src/mastodon/entities/application.ts new file mode 100644 index 0000000000..a3f07997ee --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/application.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type Application = { + name: string + website?: string | null + vapid_key?: string | null + } +} diff --git a/packages/megalodon/src/mastodon/entities/async_attachment.ts b/packages/megalodon/src/mastodon/entities/async_attachment.ts new file mode 100644 index 0000000000..45f574d645 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/async_attachment.ts @@ -0,0 +1,14 @@ +/// +namespace MastodonEntity { + 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/mastodon/entities/attachment.ts b/packages/megalodon/src/mastodon/entities/attachment.ts new file mode 100644 index 0000000000..ec51c5bcab --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/attachment.ts @@ -0,0 +1,49 @@ +namespace MastodonEntity { + 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/mastodon/entities/card.ts b/packages/megalodon/src/mastodon/entities/card.ts new file mode 100644 index 0000000000..9058de7f7c --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/card.ts @@ -0,0 +1,18 @@ +namespace MastodonEntity { + 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 + embed_url: string + blurhash: string | null + } +} diff --git a/packages/megalodon/src/mastodon/entities/context.ts b/packages/megalodon/src/mastodon/entities/context.ts new file mode 100644 index 0000000000..c2b6e26822 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/context.ts @@ -0,0 +1,8 @@ +/// + +namespace MastodonEntity { + export type Context = { + ancestors: Array + descendants: Array + } +} diff --git a/packages/megalodon/src/mastodon/entities/conversation.ts b/packages/megalodon/src/mastodon/entities/conversation.ts new file mode 100644 index 0000000000..0ee3f1d7f9 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/conversation.ts @@ -0,0 +1,11 @@ +/// +/// + +namespace MastodonEntity { + export type Conversation = { + id: string + accounts: Array + last_status: Status | null + unread: boolean + } +} diff --git a/packages/megalodon/src/mastodon/entities/emoji.ts b/packages/megalodon/src/mastodon/entities/emoji.ts new file mode 100644 index 0000000000..5434914323 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/emoji.ts @@ -0,0 +1,9 @@ +namespace MastodonEntity { + export type Emoji = { + shortcode: string + static_url: string + url: string + visible_in_picker: boolean + category?: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/featured_tag.ts b/packages/megalodon/src/mastodon/entities/featured_tag.ts new file mode 100644 index 0000000000..a2628b98c4 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/featured_tag.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type FeaturedTag = { + id: string + name: string + statuses_count: number + last_status_at: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/field.ts b/packages/megalodon/src/mastodon/entities/field.ts new file mode 100644 index 0000000000..b6ada5d132 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/field.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type Field = { + name: string + value: string + verified_at: string | null + } +} diff --git a/packages/megalodon/src/mastodon/entities/filter.ts b/packages/megalodon/src/mastodon/entities/filter.ts new file mode 100644 index 0000000000..1e17e61b21 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/filter.ts @@ -0,0 +1,12 @@ +namespace MastodonEntity { + 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/mastodon/entities/history.ts b/packages/megalodon/src/mastodon/entities/history.ts new file mode 100644 index 0000000000..cc1a3b4fb3 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/history.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type History = { + day: string + uses: number + accounts: number + } +} diff --git a/packages/megalodon/src/mastodon/entities/identity_proof.ts b/packages/megalodon/src/mastodon/entities/identity_proof.ts new file mode 100644 index 0000000000..a4dab9eaf0 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/identity_proof.ts @@ -0,0 +1,9 @@ +namespace MastodonEntity { + export type IdentityProof = { + provider: string + provider_username: string + updated_at: string + proof_url: string + profile_url: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/instance.ts b/packages/megalodon/src/mastodon/entities/instance.ts new file mode 100644 index 0000000000..842e2c6bbf --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/instance.ts @@ -0,0 +1,49 @@ +/// +/// +/// + +namespace MastodonEntity { + 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 + invites_enabled: boolean + max_toot_chars?: number + configuration: { + statuses: { + max_characters: number + max_media_attachments: number + characters_reserved_per_url: number + } + media_attachments: { + supported_mime_types: Array + image_size_limit: number + image_matrix_limit: number + video_size_limit: number + video_frame_limit: number + video_matrix_limit: number + } + polls: { + max_options: number + max_characters_per_option: number + min_expiration: number + max_expiration: number + } + } + contact_account: Account + rules: Array + } + + export type InstanceRule = { + id: string + text: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/list.ts b/packages/megalodon/src/mastodon/entities/list.ts new file mode 100644 index 0000000000..503499839f --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/list.ts @@ -0,0 +1,9 @@ +namespace MastodonEntity { + export type List = { + id: string + title: string + replies_policy: RepliesPolicy + } + + export type RepliesPolicy = 'followed' | 'list' | 'none' +} diff --git a/packages/megalodon/src/mastodon/entities/marker.ts b/packages/megalodon/src/mastodon/entities/marker.ts new file mode 100644 index 0000000000..1b0983e2b3 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/marker.ts @@ -0,0 +1,14 @@ +namespace MastodonEntity { + 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/mastodon/entities/mention.ts b/packages/megalodon/src/mastodon/entities/mention.ts new file mode 100644 index 0000000000..ddc99d93e6 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/mention.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type Mention = { + id: string + username: string + url: string + acct: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/notification.ts b/packages/megalodon/src/mastodon/entities/notification.ts new file mode 100644 index 0000000000..ad5519bae4 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/notification.ts @@ -0,0 +1,14 @@ +/// +/// + +namespace MastodonEntity { + export type Notification = { + account: Account + created_at: string + id: string + status?: Status + type: NotificationType + } + + export type NotificationType = string +} diff --git a/packages/megalodon/src/mastodon/entities/poll.ts b/packages/megalodon/src/mastodon/entities/poll.ts new file mode 100644 index 0000000000..eaf09050f9 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/poll.ts @@ -0,0 +1,13 @@ +/// + +namespace MastodonEntity { + 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/mastodon/entities/poll_option.ts b/packages/megalodon/src/mastodon/entities/poll_option.ts new file mode 100644 index 0000000000..d3c14190fb --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/poll_option.ts @@ -0,0 +1,6 @@ +namespace MastodonEntity { + export type PollOption = { + title: string + votes_count: number | null + } +} diff --git a/packages/megalodon/src/mastodon/entities/preferences.ts b/packages/megalodon/src/mastodon/entities/preferences.ts new file mode 100644 index 0000000000..3c8cc953c0 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/preferences.ts @@ -0,0 +1,9 @@ +namespace MastodonEntity { + 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/mastodon/entities/push_subscription.ts b/packages/megalodon/src/mastodon/entities/push_subscription.ts new file mode 100644 index 0000000000..ad96ea685f --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/push_subscription.ts @@ -0,0 +1,16 @@ +namespace MastodonEntity { + 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/mastodon/entities/relationship.ts b/packages/megalodon/src/mastodon/entities/relationship.ts new file mode 100644 index 0000000000..8e02df5769 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/relationship.ts @@ -0,0 +1,18 @@ +namespace MastodonEntity { + 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 + languages: Array + } +} diff --git a/packages/megalodon/src/mastodon/entities/report.ts b/packages/megalodon/src/mastodon/entities/report.ts new file mode 100644 index 0000000000..0eba265a35 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/report.ts @@ -0,0 +1,17 @@ +/// + +namespace MastodonEntity { + export type Report = { + id: string + action_taken: boolean + action_taken_at: string | null + 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/mastodon/entities/results.ts b/packages/megalodon/src/mastodon/entities/results.ts new file mode 100644 index 0000000000..a1c9065435 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/results.ts @@ -0,0 +1,11 @@ +/// +/// +/// + +namespace MastodonEntity { + export type Results = { + accounts: Array + statuses: Array + hashtags: Array + } +} diff --git a/packages/megalodon/src/mastodon/entities/role.ts b/packages/megalodon/src/mastodon/entities/role.ts new file mode 100644 index 0000000000..ebfad59c1e --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/role.ts @@ -0,0 +1,5 @@ +namespace MastodonEntity { + export type Role = { + name: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/scheduled_status.ts b/packages/megalodon/src/mastodon/entities/scheduled_status.ts new file mode 100644 index 0000000000..2388311acc --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/scheduled_status.ts @@ -0,0 +1,10 @@ +/// +/// +namespace MastodonEntity { + export type ScheduledStatus = { + id: string + scheduled_at: string + params: StatusParams + media_attachments: Array + } +} diff --git a/packages/megalodon/src/mastodon/entities/source.ts b/packages/megalodon/src/mastodon/entities/source.ts new file mode 100644 index 0000000000..c104602172 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/source.ts @@ -0,0 +1,10 @@ +/// +namespace MastodonEntity { + export type Source = { + privacy: string | null + sensitive: boolean | null + language: string | null + note: string + fields: Array + } +} diff --git a/packages/megalodon/src/mastodon/entities/stats.ts b/packages/megalodon/src/mastodon/entities/stats.ts new file mode 100644 index 0000000000..925da85010 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/stats.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type Stats = { + user_count: number + status_count: number + domain_count: number + } +} diff --git a/packages/megalodon/src/mastodon/entities/status.ts b/packages/megalodon/src/mastodon/entities/status.ts new file mode 100644 index 0000000000..9624e9c72a --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/status.ts @@ -0,0 +1,49 @@ +/// +/// +/// +/// +/// +/// +/// +/// + +namespace MastodonEntity { + 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 + 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/mastodon/entities/status_params.ts b/packages/megalodon/src/mastodon/entities/status_params.ts new file mode 100644 index 0000000000..1b6ffb7ddc --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/status_params.ts @@ -0,0 +1,12 @@ +namespace MastodonEntity { + 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 + application_id: number + } +} diff --git a/packages/megalodon/src/mastodon/entities/status_source.ts b/packages/megalodon/src/mastodon/entities/status_source.ts new file mode 100644 index 0000000000..f8ca42c032 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/status_source.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type StatusSource = { + id: string + text: string + spoiler_text: string + } +} diff --git a/packages/megalodon/src/mastodon/entities/tag.ts b/packages/megalodon/src/mastodon/entities/tag.ts new file mode 100644 index 0000000000..8a10ad58b9 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/tag.ts @@ -0,0 +1,10 @@ +/// + +namespace MastodonEntity { + export type Tag = { + name: string + url: string + history: Array + following?: boolean + } +} diff --git a/packages/megalodon/src/mastodon/entities/token.ts b/packages/megalodon/src/mastodon/entities/token.ts new file mode 100644 index 0000000000..128663ecfd --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/token.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type Token = { + access_token: string + token_type: string + scope: string + created_at: number + } +} diff --git a/packages/megalodon/src/mastodon/entities/urls.ts b/packages/megalodon/src/mastodon/entities/urls.ts new file mode 100644 index 0000000000..4f6b55a4c1 --- /dev/null +++ b/packages/megalodon/src/mastodon/entities/urls.ts @@ -0,0 +1,5 @@ +namespace MastodonEntity { + export type URLs = { + streaming_api: string + } +} diff --git a/packages/megalodon/src/mastodon/entity.ts b/packages/megalodon/src/mastodon/entity.ts new file mode 100644 index 0000000000..dcafdfe749 --- /dev/null +++ b/packages/megalodon/src/mastodon/entity.ts @@ -0,0 +1,39 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// + +export default MastodonEntity diff --git a/packages/megalodon/src/mastodon/notification.ts b/packages/megalodon/src/mastodon/notification.ts new file mode 100644 index 0000000000..b7551a019e --- /dev/null +++ b/packages/megalodon/src/mastodon/notification.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000000..28bf38a666 --- /dev/null +++ b/packages/megalodon/src/mastodon/web_socket.ts @@ -0,0 +1,348 @@ +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 33a5790f67..b2f62e4adf 100644 --- a/packages/megalodon/src/megalodon.ts +++ b/packages/megalodon/src/megalodon.ts @@ -1,183 +1,166 @@ -import Response from "./response"; -import OAuth from "./oauth"; -import proxyAgent, { ProxyConfig } from "./proxy_config"; -import Entity from "./entity"; -import axios, { AxiosRequestConfig } from "axios"; -import Misskey from "./misskey"; -import { DEFAULT_UA } from "./default"; +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; - stop(): void; - // EventEmitter - on(event: string | symbol, listener: (...args: any[]) => void): this; - once(event: string | symbol, listener: (...args: any[]) => void): this; - removeListener( - event: string | symbol, - listener: (...args: any[]) => void, - ): this; - removeAllListeners(event?: string | symbol): this; + start(): void + stop(): void + // EventEmitter + on(event: string | symbol, listener: (...args: any[]) => void): this + once(event: string | symbol, listener: (...args: any[]) => void): this + removeListener(event: string | symbol, listener: (...args: any[]) => void): this + removeAllListeners(event?: string | symbol): this } export interface MegalodonInterface { - /** - * Cancel all requests in this instance. - * - * @return void - */ - cancel(): void; + /** + * Cancel all requests in this instance. + * + * @return void + */ + cancel(): void - /** - * 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** - */ - registerApp( - client_name: string, - options: Partial<{ - scopes: Array; - redirect_uris: string; - website: string; - }>, - ): Promise; + /** + * 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** + */ + registerApp( + client_name: string, + options: Partial<{ scopes: Array; redirect_uris: string; website: string }> + ): Promise - /** - * Call /api/v1/apps - * - * Create an application. - * @param client_name your application's name - * @param options Form Data - */ - createApp( - client_name: string, - options: Partial<{ - scopes: Array; - redirect_uris: string; - website: string; - }>, - ): Promise; + /** + * Create an application. + * + * @param client_name your application's name + * @param options Form Data + */ + createApp( + client_name: string, + options: Partial<{ scopes: Array; redirect_uris: string; website: string }> + ): Promise - // ====================================== - // apps - // ====================================== - /** - * GET /api/v1/apps/verify_credentials - * - * @return An Application - */ - verifyAppCredentials(): Promise>; + // ====================================== + // apps + // ====================================== + /** + * Verify the app works. + * + * @return An Application + */ + verifyAppCredentials(): Promise> - // ====================================== - // apps/oauth - // ====================================== + // ====================================== + // apps/oauth + // ====================================== + /** + * 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 + */ + fetchAccessToken(client_id: string | null, client_secret: string, code: string, redirect_uri?: string): Promise - /** - * 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 - */ - fetchAccessToken( - client_id: string | null, - client_secret: string, - code: string, - redirect_uri?: string, - ): Promise; + /** + * 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 + */ + refreshToken(client_id: string, client_secret: string, refresh_token: string): Promise - /** - * 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 - */ - refreshToken( - client_id: string, - client_secret: string, - refresh_token: string, - ): Promise; + /** + * 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 + */ + revokeToken(client_id: string, client_secret: string, token: string): Promise> - /** - * 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 - */ - revokeToken( - client_id: string, - client_secret: string, - token: string, - ): Promise>; - - // ====================================== - // 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. - */ - registerAccount( - username: string, - email: string, - password: string, - agreement: boolean, - locale: string, - reason?: string | null, - ): Promise>; - /** - * GET /api/v1/accounts/verify_credentials - * - * @return Account. - */ - verifyAccountCredentials(): Promise>; - /** - * PATCH /api/v1/accounts/update_credentials - * - * @return An account. - */ - 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>; - /** - * GET /api/v1/accounts/:id - * - * @param id The account ID. - * @return An account. - */ - getAccount(id: string): Promise>; - /** - * GET /api/v1/accounts/:id/statuses + // ====================================== + // accounts + // ====================================== + /** + * Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox. + * + * @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. + */ + registerAccount( + username: string, + email: string, + password: string, + agreement: boolean, + locale: string, + reason?: string | null + ): Promise> + /** + * Test to make sure that the user token works. + * + * @return Account. + */ + verifyAccountCredentials(): Promise> + /** + * Update the user’s display and preferences. + * + * @param options.discoverable Whether the account should be shown in the profile directory. + * @param options.bot Whether the account has a bot flag. + * @param options.display_name The display name to use for the profile. + * @param options.note The account bio. + * @param options.avatar Avatar image encoded using multipart/form-data. + * @param options.header Header image encoded using multipart/form-data. + * @param options.locked Whether manual approval of follow requests is required. + * @param options.source.privacy Default post privacy for authored statuses. Can be public, unlisted, or private. + * @param options.source.sensitive Whether to mark authored statuses as sensitive by default. + * @param options.source.language Default language to use for authored statuses (ISO 6391). + * @param options.fields_attributes The profile fields to be set. Inside this hash, the key is an integer cast to a string (although the exact integer does not matter), and the value is another hash including name and value. By default, max 4 fields. + * @return An account. + */ + 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> + /** + * View information about a profile. + * + * @param id The account ID. + * @return An account. + */ + getAccount(id: string): Promise> + /** + * Statuses posted to the given account. * * @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. @@ -188,1333 +171,1253 @@ export interface MegalodonInterface { * @param options.only_media Show only statuses with media attached? Defaults to false. * @return Account's statuses. */ - 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>>; - /** - * 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. - */ - getAccountFavourites( - id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - }, - ): Promise>>; - /** - * POST /api/v1/pleroma/accounts/:id/subscribe - * - * @param id Target account ID. - * @return Relationship. - */ - subscribeAccount(id: string): Promise>; - /** - * POST /api/v1/pleroma/accounts/:id/unsubscribe - * - * @param id Target account ID. - * @return Relationship. - */ - unsubscribeAccount(id: string): Promise>; - /** - * 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. - */ - getAccountFollowers( - id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - get_all?: boolean; - sleep_ms?: number; - }, - ): Promise>>; - - /** - * GET /api/v1/accounts/:id/featured_tags - * - * @param id The account ID. - * @return The array of accounts. - */ - getAccountFeaturedTags( - id: string, - ): Promise>>; - - /** - * 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. - */ - getAccountFollowing( - id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - get_all?: boolean; - sleep_ms?: number; - }, - ): Promise>>; - /** - * GET /api/v1/accounts/:id/lists - * - * @param id The account ID. - * @return The array of lists. - */ - getAccountLists(id: string): Promise>>; - /** - * GET /api/v1/accounts/:id/identity_proofs - * - * @param id The account ID. - * @return Array of IdentityProof - */ - getIdentityProof(id: string): Promise>>; - /** - * POST /api/v1/accounts/:id/follow - * - * @param id The account ID. - * @param reblog Receive this account's reblogs in home timeline. - * @return Relationship - */ - followAccount( - id: string, - options?: { - reblog?: boolean; - }, - ): Promise>; - /** - * POST /api/v1/accounts/:id/unfollow - * - * @param id The account ID. - * @return Relationship - */ - unfollowAccount(id: string): Promise>; - /** - * POST /api/v1/accounts/:id/block - * - * @param id The account ID. - * @return Relationship - */ - blockAccount(id: string): Promise>; - /** - * POST /api/v1/accounts/:id/unblock - * - * @param id The account ID. - * @return RElationship - */ - unblockAccount(id: string): Promise>; - /** - * POST /api/v1/accounts/:id/mute - * - * @param id The account ID. - * @param notifications Mute notifications in addition to statuses. - * @return Relationship - */ - muteAccount( - id: string, - notifications: boolean, - ): Promise>; - /** - * POST /api/v1/accounts/:id/unmute - * - * @param id The account ID. - * @return Relationship - */ - unmuteAccount(id: string): Promise>; - /** - * POST /api/v1/accounts/:id/pin - * - * @param id The account ID. - * @return Relationship - */ - pinAccount(id: string): Promise>; - /** - * POST /api/v1/accounts/:id/unpin - * - * @param id The account ID. - * @return Relationship - */ - unpinAccount(id: string): Promise>; - /** - * GET /api/v1/accounts/relationships - * - * @param id The account ID. - * @return Relationship - */ - getRelationship(id: string): Promise>; - /** - * Get multiple relationships in one method - * - * @param ids Array of account IDs. - * @return Array of Relationship. - */ - getRelationships( - ids: Array, - ): Promise>>; - /** - * 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. - */ - searchAccount( - q: string, - options?: { - following?: boolean; - resolve?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - }, - ): Promise>>; - // ====================================== - // 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. - */ - getBookmarks(options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>>; - // ====================================== - // 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. - */ - getFavourites(options?: { - limit?: number; - max_id?: string; - min_id?: string; - }): Promise>>; - // ====================================== - // 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. - */ - getMutes(options?: { - limit?: number; - max_id?: string; - min_id?: string; - }): Promise>>; - // ====================================== - // 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. - */ - getBlocks(options?: { - limit?: number; - max_id?: string; - min_id?: string; - }): Promise>>; - // ====================================== - // 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. - */ - getDomainBlocks(options?: { - limit?: number; - max_id?: string; - min_id?: string; - }): Promise>>; - /** - * POST/api/v1/domain_blocks - * - * @param domain Domain to block. - */ - blockDomain(domain: string): Promise>; - /** - * DELETE /api/v1/domain_blocks - * - * @param domain Domain to unblock - */ - unblockDomain(domain: string): Promise>; - // ====================================== - // accounts/filters - // ====================================== - /** - * GET /api/v1/filters - * - * @return Array of filters. - */ - getFilters(): Promise>>; - /** - * GET /api/v1/filters/:id - * - * @param id The filter ID. - * @return Filter. - */ - getFilter(id: string): Promise>; - /** - * 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 - */ - createFilter( - phrase: string, - context: Array, - options?: { - irreversible?: boolean; - whole_word?: boolean; - expires_in?: string; - }, - ): Promise>; - /** - * 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 - */ - updateFilter( - id: string, - phrase: string, - context: Array, - options?: { - irreversible?: boolean; - whole_word?: boolean; - expires_in?: string; - }, - ): Promise>; - /** - * DELETE /api/v1/filters/:id - * - * @param id The filter ID. - * @return Removed filter. - */ - deleteFilter(id: string): Promise>; - // ====================================== - // accounts/reports - // ====================================== - /** - * POST /api/v1/reports - * - * @param account_id Target account ID. - * @param comment Reason of the report. - * @param options.status_ids Array of Statuses ids to attach to the report. - * @param options.forward If the account is remote, should the report be forwarded to the remote admin? - * @return Report - */ - report( - account_id: string, - comment: string, - options?: { status_ids?: Array; forward?: boolean }, - ): Promise>; - // ====================================== - // accounts/follow_requests - // ====================================== - /** - * GET /api/v1/follow_requests - * - * @param limit Maximum number of results. - * @return Array of account. - */ - getFollowRequests(limit?: number): Promise>>; - /** - * POST /api/v1/follow_requests/:id/authorize - * - * @param id Target account ID. - * @return Relationship. - */ - acceptFollowRequest(id: string): Promise>; - /** - * POST /api/v1/follow_requests/:id/reject - * - * @param id Target account ID. - * @return Relationship. - */ - rejectFollowRequest(id: string): Promise>; - // ====================================== - // 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. - */ - getEndorsements(options?: { - limit?: number; - max_id?: string; - since_id?: string; - }): Promise>>; - // ====================================== - // accounts/featured_tags - // ====================================== - /** - * GET /api/v1/featured_tags - * - * @return Array of featured tag. - */ - getFeaturedTags(): Promise>>; - /** - * POST /api/v1/featured_tags - * - * @param name Target hashtag name. - * @return FeaturedTag. - */ - createFeaturedTag(name: string): Promise>; - /** - * DELETE /api/v1/featured_tags/:id - * - * @param id Target featured tag id. - * @return Empty - */ - deleteFeaturedTag(id: string): Promise>; - /** - * GET /api/v1/featured_tags/suggestions - * - * @return Array of tag. - */ - getSuggestedTags(): Promise>>; - // ====================================== - // accounts/preferences - // ====================================== - /** - * GET /api/v1/preferences - * - * @return Preferences. - */ - getPreferences(): Promise>; - // ====================================== - // accounts/suggestions - // ====================================== - /** - * GET /api/v1/suggestions - * - * @param limit Maximum number of results. - * @return Array of accounts. - */ - getSuggestions(limit?: number): Promise>>; - // ====================================== - // accounts/tags - // ====================================== - getFollowedTags(): Promise>>; - /** - * GET /api/v1/tags/:id - * - * @param id Target hashtag id. - * @return Tag - */ - getTag(id: string): Promise>; - /** - * POST /api/v1/tags/:id/follow - * - * @param id Target hashtag id. - * @return Tag - */ - followTag(id: string): Promise>; - /** - * POST /api/v1/tags/:id/unfollow - * - * @param id Target hashtag id. - * @return Tag - */ - unfollowTag(id: string): Promise>; - // ====================================== - // 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 - */ - 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>; - /** - * GET /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - getStatus(id: string): Promise>; - /** - PUT /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status + 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>> + /** + * Favourites timeline of any user. + * + * @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. */ - 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>; - /** - * DELETE /api/v1/statuses/:id - * - * @param id The target status id. - * @return Status - */ - deleteStatus(id: string): Promise>; - /** - * GET /api/v1/statuses/:id/context - * - * Get parent and child statuses. - * @param id The target status id. - * @return Context - */ - getStatusContext( - id: string, - options?: { limit?: number; max_id?: string; since_id?: string }, - ): Promise>; - /** - * GET /api/v1/statuses/:id/history - * - * Get status edit history. - * @param id The target status id. - * @return StatusEdit - */ - getStatusHistory(id: string): Promise>>; - /** - * GET /api/v1/statuses/:id/reblogged_by - * - * @param id The target status id. - * @return Array of accounts. - */ - getStatusRebloggedBy(id: string): Promise>>; - /** - * GET /api/v1/statuses/:id/favourited_by - * - * @param id The target status id. - * @return Array of accounts. - */ - getStatusFavouritedBy(id: string): Promise>>; - /** - * POST /api/v1/statuses/:id/favourite - * - * @param id The target status id. - * @return Status. - */ - favouriteStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/unfavourite - * - * @param id The target status id. - * @return Status. - */ - unfavouriteStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/reblog - * - * @param id The target status id. - * @return Status. - */ - reblogStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/unreblog - * - * @param id The target status id. - * @return Status. - */ - unreblogStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/bookmark - * - * @param id The target status id. - * @return Status. - */ - bookmarkStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/unbookmark - * - * @param id The target status id. - * @return Status. - */ - unbookmarkStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/mute - * - * @param id The target status id. - * @return Status - */ - muteStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/unmute - * - * @param id The target status id. - * @return Status - */ - unmuteStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/pin - * @param id The target status id. - * @return Status - */ - pinStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/unpin - * - * @param id The target status id. - * @return Status - */ - unpinStatus(id: string): Promise>; - /** - * POST /api/v1/statuses/:id/react/:name - * @param id The target status id. - * @param name The name of the emoji reaction to add. - * @return Status - */ - reactStatus(id: string, name: string): Promise>; - /** - * POST /api/v1/statuses/:id/unreact/:name - * - * @param id The target status id. - * @param name The name of the emoji reaction to remove. - * @return Status - */ - unreactStatus(id: string, name: string): Promise>; - // ====================================== - // 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 - */ - uploadMedia( - file: any, - options?: { description?: string; focus?: string }, - ): Promise>; - /** - * GET /api/v1/media/:id - * - * @param id Target media ID. - * @return Attachment - */ - getMedia(id: string): Promise>; - /** - * 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 - */ - updateMedia( - id: string, - options?: { - file?: any; - description?: string; - focus?: string; - is_sensitive?: boolean; - }, - ): Promise>; - // ====================================== - // statuses/polls - // ====================================== - /** - * GET /api/v1/polls/:id - * - * @param id Target poll ID. - * @return Poll - */ - getPoll(id: string): Promise>; - /** - * 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 - */ - votePoll(id: string, choices: Array): Promise>; - // ====================================== - // 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. - */ - getScheduledStatuses(options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>>; - /** - * GET /api/v1/scheduled_statuses/:id - * - * @param id Target status ID. - * @return ScheduledStatus. - */ - getScheduledStatus(id: string): Promise>; - /** - * 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. - */ - scheduleStatus( - id: string, - scheduled_at?: string | null, - ): Promise>; - /** - * DELETE /api/v1/scheduled_statuses/:id - * - * @param id Target scheduled status ID. - */ - cancelScheduledStatus(id: string): Promise>; - // ====================================== - // 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. - */ - getPublicTimeline(options?: { - only_media?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>>; - /** - * 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. - */ - getLocalTimeline(options?: { - only_media?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>>; - /** - * 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. - */ - getTagTimeline( - hashtag: string, - options?: { - local?: boolean; - only_media?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }, - ): Promise>>; - /** - * 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. - */ - getHomeTimeline(options?: { - local?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>>; - /** - * 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. - */ - getListTimeline( - list_id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }, - ): Promise>>; - // ====================================== - // 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. - */ - getConversationTimeline(options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>>; - /** - * DELETE /api/v1/conversations/:id - * - * @param id Target conversation ID. - */ - deleteConversation(id: string): Promise>; - /** - * POST /api/v1/conversations/:id/read - * - * @param id Target conversation ID. - * @return Conversation. - */ - readConversation(id: string): Promise>; - // ====================================== - // timelines/lists - // ====================================== - /** - * GET /api/v1/lists - * - * @return Array of lists. - */ - getLists(): Promise>>; - /** - * GET /api/v1/lists/:id - * - * @param id Target list ID. - * @return List. - */ - getList(id: string): Promise>; - /** - * POST /api/v1/lists - * - * @param title List name. - * @return List. - */ - createList(title: string): Promise>; - /** - * PUT /api/v1/lists/:id - * - * @param id Target list ID. - * @param title New list name. - * @return List. - */ - updateList(id: string, title: string): Promise>; - /** - * DELETE /api/v1/lists/:id - * - * @param id Target list ID. - */ - deleteList(id: string): Promise>; - /** - * 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. - */ - getAccountsInList( - id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - }, - ): Promise>>; - /** - * POST /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param account_ids Array of account IDs to add to the list. - */ - addAccountsToList( - id: string, - account_ids: Array, - ): Promise>; - /** - * DELETE /api/v1/lists/:id/accounts - * - * @param id Target list ID. - * @param account_ids Array of account IDs to add to the list. - */ - deleteAccountsFromList( - id: string, - account_ids: Array, - ): Promise>; - // ====================================== - // timelines/markers - // ====================================== - /** - * GET /api/v1/markers - * - * @param timelines Array of timeline names, String enum anyOf home, notifications. - * @return Marker or empty object. - */ - getMarkers(timeline: Array): Promise>; - /** - * 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. - */ - saveMarkers(options?: { - home?: { last_read_id: string }; - notifications?: { last_read_id: string }; - }): Promise>; - // ====================================== - // 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. - */ - getNotifications(options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - exclude_types?: Array; - account_id?: string; - }): Promise>>; - /** - * GET /api/v1/notifications/:id - * - * @param id Target notification ID. - * @return Notification. - */ - getNotification(id: string): Promise>; - /** - * POST /api/v1/notifications/clear - */ - dismissNotifications(): Promise>; - /** - * POST /api/v1/notifications/:id/dismiss - * - * @param id Target notification ID. - */ - dismissNotification(id: string): Promise>; - /** - * 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 - */ - readNotifications(options: { id?: string; max_id?: string }): Promise< - Response> - >; - // ====================================== - // 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. - */ - subscribePushNotification( - subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, - data?: { - alerts: { - follow?: boolean; - favourite?: boolean; - reblog?: boolean; - mention?: boolean; - poll?: boolean; - }; - } | null, - ): Promise>; - /** - * GET /api/v1/push/subscription - * - * @return PushSubscription. - */ - getPushSubscription(): Promise>; - /** - * 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. - */ - updatePushSubscription( - data?: { - alerts: { - follow?: boolean; - favourite?: boolean; - reblog?: boolean; - mention?: boolean; - poll?: boolean; - }; - } | null, - ): Promise>; - /** - * DELETE /api/v1/push/subscription - */ - deletePushSubscription(): Promise>; - // ====================================== - // 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. - */ - search( - q: string, - type: "accounts" | "hashtags" | "statuses", - options?: { - limit?: number; - max_id?: string; - min_id?: string; - resolve?: boolean; - offset?: number; - following?: boolean; - account_id?: string; - exclude_unreviewed?: boolean; - }, - ): Promise>; + getAccountFavourites( + id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> + /** + * Subscribe to receive notifications for all statuses posted by a user. + * + * @param id Target account ID. + * @return Relationship. + */ + subscribeAccount(id: string): Promise> + /** + * Unsubscribe to stop receiving notifications from user statuses. + * + * @param id Target account ID. + * @return Relationship. + */ + unsubscribeAccount(id: string): Promise> + /** + * Accounts which follow the given account, if network is not hidden by the account owner. + * + * @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. + * @param options.get_all + * @param options.sleep_ms + * @return The array of accounts. + */ + getAccountFollowers( + id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + get_all?: boolean + sleep_ms?: number + } + ): Promise>> + /** + * Accounts which the given account is following, if network is not hidden by the account owner. + * + * @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. + * @param options.get_all + * @param options.sleep_ms + * @return The array of accounts. + */ + getAccountFollowing( + id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + get_all?: boolean + sleep_ms?: number + } + ): Promise>> + /** + * User lists that you have added this account to. + * + * @param id The account ID. + * @return The array of lists. + */ + getAccountLists(id: string): Promise>> + /** + * Returns an IdentityProof. + * + * @param id The account ID. + * @return Array of IdentityProof + */ + getIdentityProof(id: string): Promise>> + /** + * Follow the given account. Can also be used to update whether to show reblogs or enable notifications. + * + * @param id The account ID. + * @param reblog Receive this account's reblogs in home timeline. + * @return Relationship + */ + followAccount( + id: string, + options?: { + reblog?: boolean + } + ): Promise> + /** + * Unfollow the given account. + * + * @param id The account ID. + * @return Relationship + */ + unfollowAccount(id: string): Promise> + /** + * lock the given account. Clients should filter statuses from this account if received. + * + * @param id The account ID. + * @return Relationship + */ + blockAccount(id: string): Promise> + /** + * Unblock the given account. + * + * @param id The account ID. + * @return RElationship + */ + unblockAccount(id: string): Promise> + /** + * Mute the given account. Clients should filter statuses and notifications from this account, if received, + * + * @param id The account ID. + * @param notifications Mute notifications in addition to statuses. + * @return Relationship + */ + muteAccount(id: string, notifications: boolean): Promise> + /** + * Unmute the given account. + * + * @param id The account ID. + * @return Relationship + */ + unmuteAccount(id: string): Promise> + /** + * Add the given account to the user’s featured profiles. + * + * @param id The account ID. + * @return Relationship + */ + pinAccount(id: string): Promise> + /** + * Remove the given account from the user’s featured profiles. + * + * @param id The account ID. + * @return Relationship + */ + unpinAccount(id: string): Promise> + /** + * Find out whether a given account is followed, blocked, muted, etc. + * + * @param id The account ID. + * @return Relationship + */ + getRelationship(id: string): Promise> + /** + * Find out whether given accounts are followed, blocked, muted, etc. + * + * @param ids Array of account IDs. + * @return Array of Relationship. + */ + getRelationships(ids: Array): Promise>> + /** + * Search for matching accounts by username or display name. + * + * @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. + */ + searchAccount( + q: string, + options?: { + following?: boolean + resolve?: boolean + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> + // ====================================== + // accounts/bookmarks + // ====================================== + /** + * Statuses the user has bookmarked. + * + * @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. + */ + getBookmarks(options?: { limit?: number; max_id?: string; since_id?: string; min_id?: string }): Promise>> + // ====================================== + // accounts/favourites + // ====================================== + /** + * Statuses the user has favourited. + * + * @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. + */ + getFavourites(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> + // ====================================== + // accounts/mutes + // ====================================== + /** + * Accounts the user has muted. + * + * @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. + */ + getMutes(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> + // ====================================== + // accounts/blocks + // ====================================== + /** + * View blocked users. + * + * @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. + */ + getBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> + // ====================================== + // accounts/domain_blocks + // ====================================== + /** + * View domains the user has blocked. + * + * @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. + */ + getDomainBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> + /** + * Block a domain. + * + * @param domain Domain to block. + */ + blockDomain(domain: string): Promise> + /** + * Remove a domain block, if it exists in the user’s array of blocked domains. + * + * @param domain Domain to unblock + */ + unblockDomain(domain: string): Promise> + // ====================================== + // accounts/filters + // ====================================== + /** + * Get filters of the current user. + * + * @return Array of filters. + */ + getFilters(): Promise>> + /** + * Get a specyfic filter. + * + * @param id The filter ID. + * @return Filter. + */ + getFilter(id: string): Promise> + /** + * Create a filter. + * + * @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 + */ + createFilter( + phrase: string, + context: Array, + options?: { + irreversible?: boolean + whole_word?: boolean + expires_in?: string + } + ): Promise> + /** + * Replaces a filter’s parameters in-place. + * + * @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 + */ + updateFilter( + id: string, + phrase: string, + context: Array, + options?: { + irreversible?: boolean + whole_word?: boolean + expires_in?: string + } + ): Promise> + /** + * Remove a filter. + * + * @param id The filter ID. + * @return Removed filter. + */ + deleteFilter(id: string): Promise> + // ====================================== + // accounts/reports + // ====================================== + /** + * File a report. + * + * @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 + */ + report( + account_id: string, + options?: { + status_ids?: Array + comment: string + forward?: boolean + category?: Entity.Category + rule_ids?: Array + } + ): Promise> + // ====================================== + // accounts/follow_requests + // ====================================== + /** + * Get pending follow requests. + * + * @param limit Maximum number of results. + * @return Array of account or follow request. + */ + getFollowRequests(limit?: number): Promise>> + /** + * Accept the follow request. + * + * @param id Target account ID. Or follow request ID in Friendica. + * @return Relationship. + */ + acceptFollowRequest(id: string): Promise> + /** + * Reject the follow request. + * + * @param id Target account ID. Or follow request ID in Friendica. + * @return Relationship. + */ + rejectFollowRequest(id: string): Promise> + // ====================================== + // accounts/endorsements + // ====================================== + /** + * Accounts that the user is currently featuring on their profile. + * + * @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. + */ + getEndorsements(options?: { limit?: number; max_id?: string; since_id?: string }): Promise>> + // ====================================== + // accounts/featured_tags + // ====================================== + /** + * List all hashtags featured on your profile. + * + * @return Array of featured tag. + */ + getFeaturedTags(): Promise>> + /** + * Promote a hashtag on your profile. + * + * @param name Target hashtag name. + * @return FeaturedTag. + */ + createFeaturedTag(name: string): Promise> + /** + * Stop promoting a hashtag on your profile. + * + * @param id Target featured tag id. + * @return Empty + */ + deleteFeaturedTag(id: string): Promise> + /** + * Shows up to 10 recently-used tags. + * + * @return Array of tag. + */ + getSuggestedTags(): Promise>> + // ====================================== + // accounts/preferences + // ====================================== + /** + * Get preferences defined by the user in their account settings. + * + * @return Preferences. + */ + getPreferences(): Promise> + // ====================================== + // accounts/followed_tags + // ====================================== + /** + * View all followed tags. + * + * @return Array of tag. + */ + getFollowedTags(): Promise>> + // ====================================== + // accounts/suggestions + // ====================================== + /** + * Accounts the user has had past positive interactions with, but is not yet following. + * + * @param limit Maximum number of results. + * @return Array of accounts. + */ + getSuggestions(limit?: number): Promise>> + // ====================================== + // accounts/tags + // ====================================== + /** + * Show a hashtag and its associated information + * + * @param id Target hashtag id. + * @return Tag + */ + getTag(id: string): Promise> + /** + * Follow a hashtag. Posts containing a followed hashtag will be inserted into your home timeline. + * + * @param id Target hashtag id. + * @return Tag + */ + followTag(id: string): Promise> + /** + * Unfollow a hashtag. Posts containing this hashtag will no longer be inserted into your home timeline. + * + * @param id Target hashtag id. + * @return Tag + */ + unfollowTag(id: string): Promise> + // ====================================== + // statuses + // ====================================== + /** + * Publish a status with the given parameters. + * + * @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. + */ + 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> + /** + * Obtain information about a status. + * + * @param id The target status id. + * @return Status + */ + getStatus(id: string): Promise> + /** + * Edit a given status to change its text, sensitivity, media attachments, or poll. Note that editing a poll’s options will reset the votes. + * + * @param id The target status id. + * @param options.status Text context of status. + * @param options.spoiler_text Text to be shown as a warning or subject before the actual content. + * @param options.sensitive Mark status and attached media as sensitive? + * @param options.media_ids Array of Attachment ids. + * @param options.poll Poll object. + * @return Status + */ + 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> + /** + * Delete one of your own statuses. + * + * @param id The target status id. + * @return Status + */ + deleteStatus(id: string): Promise> + /** + * Get parent and child statuses in context. + * View statuses above and below this status in the thread. + * + * @param id The target status id. + * @return Context + */ + getStatusContext(id: string, options?: { limit?: number; max_id?: string; since_id?: string }): Promise> + /** + * View status source. + * Obtain the source properties for a status so that it can be edited. + * + * @param id The target status id. + * @return StatusSource + */ + getStatusSource(id: string): Promise> + /** + * See who boosted a status. + * + * @param id The target status id. + * @return Array of accounts. + */ + getStatusRebloggedBy(id: string): Promise>> + /** + * See who favourited a status + * + * @param id The target status id. + * @return Array of accounts. + */ + getStatusFavouritedBy(id: string): Promise>> + /** + * Favourite a status. + * + * @param id The target status id. + * @return Status. + */ + favouriteStatus(id: string): Promise> + /** + * Undo favourite of a status. + * + * @param id The target status id. + * @return Status. + */ + unfavouriteStatus(id: string): Promise> + /** + * Boost a status. + * + * @param id The target status id. + * @return Status. + */ + reblogStatus(id: string): Promise> + /** + * Undo boost of a status. + * + * @param id The target status id. + * @return Status. + */ + unreblogStatus(id: string): Promise> + /** + * Bookmark a status + * + * @param id The target status id. + * @return Status. + */ + bookmarkStatus(id: string): Promise> + /** + * Undo bookmark of a status. + * + * @param id The target status id. + * @return Status. + */ + unbookmarkStatus(id: string): Promise> + /** + * Mute a conversation. + * + * @param id The target status id. + * @return Status + */ + muteStatus(id: string): Promise> + /** + * Unmute a conversation. + * + * @param id The target status id. + * @return Status + */ + unmuteStatus(id: string): Promise> + /** + * Pin status to profile. + * + * @param id The target status id. + * @return Status + */ + pinStatus(id: string): Promise> + /** + * Unpin status from profile. + * + * @param id The target status id. + * @return Status + */ + unpinStatus(id: string): Promise> + // ====================================== + // statuses/media + // ====================================== + /** + * Upload media as an attachment. + * + * @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 + */ + uploadMedia(file: any, options?: { description?: string; focus?: string }): Promise> + /** + * Get media attachment. + * + * @param id Target media ID. + * @return Attachment + */ + getMedia(id: string): Promise> + /** + * Update media attachment. + * + * @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 + */ + updateMedia( + id: string, + options?: { + file?: any + description?: string + focus?: string + is_sensitive?: boolean + } + ): Promise> + // ====================================== + // statuses/polls + // ====================================== + /** + * View a poll. + * + * @param id Target poll ID. + * @return Poll + */ + getPoll(id: string): Promise> + /** + * Vote on a poll. + * + * @param id Target poll ID. + * @param choices Array of own votes containing index for each option (starting from 0). + * @return Poll + */ + votePoll(id: string, choices: Array, status_id?: string | null): Promise> + // ====================================== + // statuses/scheduled_statuses + // ====================================== + /** + * View 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. + */ + getScheduledStatuses(options?: { + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> + /** + * View a single scheduled status. + * + * @param id Target status ID. + * @return ScheduledStatus. + */ + getScheduledStatus(id: string): Promise> + /** + * Update a scheduled status’s publishing date. + * + * @param id Target scheduled status ID. + * @param scheduled_at ISO 8601 Datetime at which the status will be published. + * @return ScheduledStatus. + */ + scheduleStatus(id: string, scheduled_at?: string | null): Promise> + /** + * Cancel a scheduled status. + * + * @param id Target scheduled status ID. + */ + cancelScheduledStatus(id: string): Promise> + // ====================================== + // timelines + // ====================================== + /** + * View public timeline. + * + * @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. + */ + getPublicTimeline(options?: { + only_media?: boolean + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> + /** + * View local timeline. + * + * @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. + */ + getLocalTimeline(options?: { + only_media?: boolean + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> + /** + * View hashtag timeline. + * + * @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. + */ + getTagTimeline( + hashtag: string, + options?: { + local?: boolean + only_media?: boolean + limit?: number + max_id?: string + since_id?: string + min_id?: string + } + ): Promise>> + /** + * View home timeline. + * + * @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. + */ + getHomeTimeline(options?: { + local?: boolean + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> + /** + * View list timeline. + * + * @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. + */ + getListTimeline( + list_id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + min_id?: string + } + ): Promise>> + // ====================================== + // timelines/conversations + // ====================================== + /** + * View all 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. + */ + getConversationTimeline(options?: { + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> + /** + * Remove a conversation. + * + * @param id Target conversation ID. + */ + deleteConversation(id: string): Promise> + /** + * Mark a conversation as read. + * + * @param id Target conversation ID. + * @return Conversation. + */ + readConversation(id: string): Promise> + // ====================================== + // timelines/lists + // ====================================== + /** + * View your lists. + * + * @return Array of lists. + */ + getLists(): Promise>> + /** + * Show a single list. + * + * @param id Target list ID. + * @return List. + */ + getList(id: string): Promise> + /** + * Create a list. + * + * @param title List name. + * @return List. + */ + createList(title: string): Promise> + /** + * Update a list. + * + * @param id Target list ID. + * @param title New list name. + * @return List. + */ + updateList(id: string, title: string): Promise> + /** + * Delete a list. + * + * @param id Target list ID. + */ + deleteList(id: string): Promise> + /** + * View accounts in a list. + * + * @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. + */ + getAccountsInList( + id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> + /** + * Add accounts to a list. + * + * @param id Target list ID. + * @param account_ids Array of account IDs to add to the list. + */ + addAccountsToList(id: string, account_ids: Array): Promise> + /** + * Remove accounts from list. + * + * @param id Target list ID. + * @param account_ids Array of account IDs to add to the list. + */ + deleteAccountsFromList(id: string, account_ids: Array): Promise> + // ====================================== + // timelines/markers + // ====================================== + /** + * Get saved timeline positions. + * + * @param timelines Array of timeline names, String enum anyOf home, notifications. + * @return Marker or empty object. + */ + getMarkers(timeline: Array): Promise>> + /** + * Save your position in a timeline. + * + * @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. + */ + saveMarkers(options?: { home?: { last_read_id: string }; notifications?: { last_read_id: string } }): Promise> + // ====================================== + // notifications + // ====================================== + /** + * Get all 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. + */ + getNotifications(options?: { + limit?: number + max_id?: string + since_id?: string + min_id?: string + exclude_types?: Array + account_id?: string + }): Promise>> + /** + * Get a single notification. + * + * @param id Target notification ID. + * @return Notification. + */ + getNotification(id: string): Promise> + /** + * Dismiss all notifications. + */ + dismissNotifications(): Promise> + /** + * Dismiss a single notification. + * + * @param id Target notification ID. + */ + dismissNotification(id: string): Promise> + /** + * Mark as read all unread notifications. + * + * @param id A single notification ID to read + * @param max_id Read all notifications up to this ID + * @return Array of notifications + */ + readNotifications(options: { id?: string; max_id?: string }): Promise>> + // ====================================== + // notifications/push + // ====================================== + /** + * Subscribe to push notifications. + * + * @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. + */ + subscribePushNotification( + subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, + data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean } } | null + ): Promise> + /** + * Get current subscription. + * + * @return PushSubscription. + */ + getPushSubscription(): Promise> + /** + * Change types of notifications. + * + * @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. + */ + updatePushSubscription( + data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean } } | null + ): Promise> + /** + * Remove current subscription. + */ + deletePushSubscription(): Promise> + // ====================================== + // search + // ====================================== + /** + * Perform a 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. + */ + 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> - // ====================================== - // instance - // ====================================== - /** - * GET /api/v1/instance - */ - getInstance(): Promise>; + // ====================================== + // instance + // ====================================== + /** + * View server information. + */ + getInstance(): Promise> - /** - * GET /api/v1/instance/peers - */ - getInstancePeers(): Promise>>; + /** + * List of connected domains. + */ + getInstancePeers(): Promise>> - /** - * GET /api/v1/instance/activity - */ - getInstanceActivity(): Promise>>; + /** + * Weekly activity. + */ + getInstanceActivity(): Promise>> - // ====================================== - // instance/trends - // ====================================== - /** - * GET /api/v1/trends - * - * @param limit Maximum number of results to return. Defaults to 10. - */ - getInstanceTrends( - limit?: number | null, - ): Promise>>; + // ====================================== + // instance/trends + // ====================================== + /** + * View trending tags. + * + * @param limit Maximum number of results to return. Defaults to 10. + */ + getInstanceTrends(limit?: number | null): Promise>> - // ====================================== - // 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. - */ - getInstanceDirectory(options?: { - limit?: number; - offset?: number; - order?: "active" | "new"; - local?: boolean; - }): Promise>>; + // ====================================== + // instance/directory + // ====================================== + /** + * View profile 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. + */ + getInstanceDirectory(options?: { + limit?: number + offset?: number + order?: 'active' | 'new' + local?: boolean + }): Promise>> - // ====================================== - // instance/custom_emojis - // ====================================== - /** - * GET /api/v1/custom_emojis - * - * @return Array of emojis. - */ - getInstanceCustomEmojis(): Promise>>; + // ====================================== + // instance/custom_emojis + // ====================================== + /** + * View all custom emoji. + * + * @return Array of emojis. + */ + getInstanceCustomEmojis(): Promise>> - // ====================================== - // instance/announcements - // ====================================== - /** - * GET /api/v1/announcements - * - * @param with_dismissed Include announcements dismissed by the user. Defaults to false. - * @return Array of announcements. - */ - getInstanceAnnouncements( - with_dismissed?: boolean | null, - ): Promise>>; + // ====================================== + // instance/announcements + // ====================================== + /** + * View all announcements. + * + * @return Array of announcements. + */ + getInstanceAnnouncements(): Promise>> - /** - * POST /api/v1/announcements/:id/dismiss - */ - dismissInstanceAnnouncement(id: string): Promise>; + /** + * Dismiss an announcement. + * + * @param id The ID of the Announcement in the database. + */ + dismissInstanceAnnouncement(id: string): Promise>> - // ====================================== - // Emoji reactions - // ====================================== - createEmojiReaction( - id: string, - emoji: string, - ): Promise>; - deleteEmojiReaction( - id: string, - emoji: string, - ): Promise>; - getEmojiReactions(id: string): Promise>>; - getEmojiReaction( - id: string, - emoji: string, - ): Promise>; + /** + * Add a reaction to an announcement. + * + * @param id The ID of the Announcement in the database. + * @param name Unicode emoji, or the shortcode of a custom emoji. + */ + addReactionToAnnouncement(id: string, name: string): Promise>> - // ====================================== - // WebSocket - // ====================================== - userSocket(): WebSocketInterface; - publicSocket(): WebSocketInterface; - localSocket(): WebSocketInterface; - tagSocket(tag: string): WebSocketInterface; - listSocket(list_id: string): WebSocketInterface; - directSocket(): WebSocketInterface; + /** + * Remove a reaction from an announcement. + * + * @param id The ID of the Announcement in the database. + * @param name Unicode emoji, or the shortcode of a custom emoji. + */ + removeReactionFromAnnouncement(id: string, name: string): Promise>> + + // ====================================== + // Emoji reactions + // ====================================== + /** + * React to a post with a unicode emoji. + * + * @param id Target status ID. + * @param emoji A unicode emoj. + * @return Status. + **/ + createEmojiReaction(id: string, emoji: string): Promise> + /** + * Remove a reaction to a post with a unicode emoji. + * + * @param id Target status ID. + * @param emoji A unicode emoji you want to remove. + * @return Status. + **/ + deleteEmojiReaction(id: string, emoji: string): Promise> + /** + * Get an object of emoji to account mappings with accounts that reacted to the post. + * + * @param id Target status ID. + * @return Array of reaction. + **/ + getEmojiReactions(id: string): Promise>> + /** + * Get an object of emoji to account mappings with accounts that reacted to the post for a specific emoji. + * + * @param id Target status ID. + * @param emoji A unicode emoji. + * @return Reaction. + **/ + getEmojiReaction(id: string, emoji: string): Promise> + + // ====================================== + // WebSocket + // ====================================== + userSocket(): WebSocketInterface + publicSocket(): WebSocketInterface + localSocket(): WebSocketInterface + tagSocket(tag: string): WebSocketInterface + listSocket(list_id: string): WebSocketInterface + directSocket(): WebSocketInterface } export class NoImplementedError extends Error { - constructor(err?: string) { - super(err); + constructor(err?: string) { + super(err) - this.name = new.target.name; - Object.setPrototypeOf(this, new.target.prototype); - } + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } } export class ArgumentError extends Error { - constructor(err?: string) { - super(err); + constructor(err?: string) { + super(err) - this.name = new.target.name; - Object.setPrototypeOf(this, new.target.prototype); - } + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } } export class UnexpectedError extends Error { - constructor(err?: string) { - super(err); + constructor(err?: string) { + super(err) - this.name = new.target.name; - Object.setPrototypeOf(this, new.target.prototype); - } + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } } -type Instance = { - title: string; - uri: string; - urls: { - streaming_api: string; - }; - version: string; -}; +export class NodeinfoError extends Error { + constructor(err?: string) { + super(err) -/** - * Detect SNS type. - * Now support Mastodon, Pleroma and Pixelfed. - * - * @param url Base URL of SNS. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - * @return SNS name. - */ -export const detector = async ( - url: string, - proxyConfig: ProxyConfig | false = false, -): Promise<"mastodon" | "pleroma" | "misskey"> => { - let options: AxiosRequestConfig = { - headers: { - "User-Agent": DEFAULT_UA, - }, - }; - if (proxyConfig) { - options = Object.assign(options, { - httpsAgent: proxyAgent(proxyConfig), - }); - } - try { - const res = await axios.get(url + "/api/v1/instance", options); - if (res.data.version.includes("Pleroma")) { - return "pleroma"; - } else { - return "mastodon"; - } - } catch (err) { - await axios.post<{}>(url + "/api/meta", {}, options); - return "misskey"; - } -}; + this.name = new.target.name + 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. @@ -1522,11 +1425,30 @@ export const detector = async ( * @return Client instance for each SNS you specified. */ const generator = ( - baseUrl: string, - accessToken: string | null = null, - userAgent: string | null = null, - proxyConfig: ProxyConfig | false = false, -): MegalodonInterface => - new Misskey(baseUrl, accessToken, userAgent, proxyConfig); + 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; +export default generator diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index edfaa4f3cb..521cc041f1 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -1,130 +1,93 @@ -import FormData from "form-data"; -import AsyncLock from "async-lock"; +import FormData from 'form-data' -import MisskeyAPI from "./misskey/api_client"; -import { DEFAULT_UA } from "./default"; -import { ProxyConfig } from "./proxy_config"; -import OAuth from "./oauth"; -import Response from "./response"; -import Entity from "./entity"; -import { - MegalodonInterface, - WebSocketInterface, - NoImplementedError, - ArgumentError, - UnexpectedError, -} from "./megalodon"; -import MegalodonEntity from "@/entity"; -import fs from "node:fs"; -import MisskeyNotificationType from "./misskey/notification"; - -type AccountCache = { - locks: AsyncLock; - accounts: Entity.Account[]; -}; +import MisskeyAPI from './misskey/api_client' +import { DEFAULT_UA } from './default' +import { ProxyConfig } from './proxy_config' +import OAuth from './oauth' +import Response from './response' +import { MegalodonInterface, WebSocketInterface, NoImplementedError, ArgumentError, UnexpectedError } from './megalodon' +import { UnknownNotificationTypeError } from './notification' export default class Misskey implements MegalodonInterface { - public client: MisskeyAPI.Interface; - public converter: MisskeyAPI.Converter; - public baseUrl: string; - public proxyConfig: ProxyConfig | false; + public client: MisskeyAPI.Interface + public baseUrl: string + public proxyConfig: ProxyConfig | false - /** - * @param baseUrl hostname or base URL - * @param accessToken access token from OAuth2 authorization - * @param userAgent UserAgent is specified in header on request. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - */ - constructor( - baseUrl: string, - accessToken: string | null = null, - userAgent: string | null = DEFAULT_UA, - proxyConfig: ProxyConfig | false = false, - ) { - let token = ""; - if (accessToken) { - token = accessToken; - } - let agent: string = DEFAULT_UA; - if (userAgent) { - agent = userAgent; - } - this.converter = new MisskeyAPI.Converter(baseUrl); - this.client = new MisskeyAPI.Client( - baseUrl, - token, - agent, - proxyConfig, - this.converter, - ); - this.baseUrl = baseUrl; - this.proxyConfig = proxyConfig; - } + /** + * @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 MisskeyAPI.Client(baseUrl, token, agent, proxyConfig) + this.baseUrl = baseUrl + this.proxyConfig = proxyConfig + } - private baseUrlToHost(baseUrl: string): string { - return baseUrl.replace("https://", ""); - } + public cancel(): void { + return this.client.cancel() + } - public cancel(): void { - return this.client.cancel(); - } + public async registerApp( + client_name: string, + options: Partial<{ scopes: Array; redirect_uris: string; website: string }> = { + scopes: MisskeyAPI.DEFAULT_SCOPE, + redirect_uris: this.baseUrl + } + ): Promise { + return this.createApp(client_name, options).then(async appData => { + return this.generateAuthUrlAndToken(appData.client_secret).then(session => { + appData.url = session.url + appData.session_token = session.token + return appData + }) + }) + } - public async registerApp( - client_name: string, - options: Partial<{ - scopes: Array; - redirect_uris: string; - website: string; - }> = { - scopes: MisskeyAPI.DEFAULT_SCOPE, - redirect_uris: this.baseUrl, - }, - ): Promise { - return this.createApp(client_name, options).then(async (appData) => { - return this.generateAuthUrlAndToken(appData.client_secret).then( - (session) => { - appData.url = session.url; - appData.session_token = session.token; - return appData; - }, - ); - }); - } + /** + * POST /api/app/create + * + * Create an application. + * @param client_name Your application's name. + * @param options Form data. + */ + public async createApp( + client_name: string, + options: Partial<{ scopes: Array; redirect_uris: string; website: string }> = { + scopes: MisskeyAPI.DEFAULT_SCOPE, + redirect_uris: this.baseUrl + } + ): Promise { + const redirect_uris = options.redirect_uris || this.baseUrl + const scopes = options.scopes || MisskeyAPI.DEFAULT_SCOPE - /** - * POST /api/app/create - * - * Create an application. - * @param client_name Your application's name. - * @param options Form data. - */ - public async createApp( - client_name: string, - options: Partial<{ - scopes: Array; - redirect_uris: string; - website: string; - }> = { - scopes: MisskeyAPI.DEFAULT_SCOPE, - redirect_uris: this.baseUrl, - }, - ): Promise { - const redirect_uris = options.redirect_uris || this.baseUrl; - const scopes = options.scopes || MisskeyAPI.DEFAULT_SCOPE; + const params: { + name: string + description: string + permission: Array + callbackUrl: string + } = { + name: client_name, + description: '', + permission: scopes, + callbackUrl: redirect_uris + } - const params: { - name: string; - description: string; - permission: Array; - callbackUrl: string; - } = { - name: client_name, - description: "", - permission: scopes, - callbackUrl: redirect_uris, - }; - - /** + /** * The response is: { "id": "xxxxxxxxxx", @@ -136,3301 +99,2147 @@ export default class Misskey implements MegalodonInterface { "secret": "string" } */ - return this.client - .post("/api/app/create", params) - .then((res: Response) => { - const appData: OAuth.AppDataFromServer = { - id: res.data.id, - name: res.data.name, - website: null, - redirect_uri: res.data.callbackUrl, - client_id: "", - client_secret: res.data.secret, - }; - return OAuth.AppData.from(appData); - }); - } - - /** - * POST /api/auth/session/generate - */ - public async generateAuthUrlAndToken( - clientSecret: string, - ): Promise { - return this.client - .post("/api/auth/session/generate", { - appSecret: clientSecret, - }) - .then((res: Response) => res.data); - } - - // ====================================== - // apps - // ====================================== - public async verifyAppCredentials(): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // apps/oauth - // ====================================== - /** - * POST /api/auth/session/userkey - * - * @param _client_id This parameter is not used in this method. - * @param client_secret Application secret key which will be provided in createApp. - * @param session_token Session token string which will be provided in generateAuthUrlAndToken. - * @param _redirect_uri This parameter is not used in this method. - */ - public async fetchAccessToken( - _client_id: string | null, - client_secret: string, - session_token: string, - _redirect_uri?: string, - ): Promise { - return this.client - .post("/api/auth/session/userkey", { - appSecret: client_secret, - token: session_token, - }) - .then((res) => { - const token = new OAuth.TokenData( - res.data.accessToken, - "misskey", - "", - 0, - null, - null, - ); - return token; - }); - } - - public async refreshToken( - _client_id: string, - _client_secret: string, - _refresh_token: string, - ): Promise { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async revokeToken( - _client_id: string, - _client_secret: string, - _token: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // accounts - // ====================================== - public async registerAccount( - _username: string, - _email: string, - _password: string, - _agreement: boolean, - _locale: string, - _reason?: string | null, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - /** - * POST /api/i - */ - public async verifyAccountCredentials(): Promise> { - return this.client - .post("/api/i") - .then((res) => { - return Object.assign(res, { - data: this.converter.userDetail( - res.data, - this.baseUrlToHost(this.baseUrl), - ), - }); - }); - } - - /** - * POST /api/i/update - */ - public async updateCredentials(options?: { - discoverable?: boolean; - bot?: boolean; - display_name?: string; - note?: string; - avatar?: string; - header?: string; - locked?: boolean; - source?: { - privacy?: string; - sensitive?: boolean; - language?: string; - } | null; - fields_attributes?: Array<{ name: string; value: string }>; - }): Promise> { - let params = {}; - if (options) { - if (options.bot !== undefined) { - params = Object.assign(params, { - isBot: options.bot, - }); - } - if (options.display_name) { - params = Object.assign(params, { - name: options.display_name, - }); - } - if (options.note) { - params = Object.assign(params, { - description: options.note, - }); - } - if (options.locked !== undefined) { - params = Object.assign(params, { - isLocked: options.locked, - }); - } - if (options.source) { - if (options.source.language) { - params = Object.assign(params, { - lang: options.source.language, - }); - } - if (options.source.sensitive) { - params = Object.assign(params, { - alwaysMarkNsfw: options.source.sensitive, - }); - } - } - } - return this.client - .post("/api/i", params) - .then((res) => { - return Object.assign(res, { - data: this.converter.userDetail( - res.data, - this.baseUrlToHost(this.baseUrl), - ), - }); - }); - } - - /** - * POST /api/users/show - */ - public async getAccount(id: string): Promise> { - return this.client - .post("/api/users/show", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.userDetail( - res.data, - this.baseUrlToHost(this.baseUrl), - ), - }); - }); - } - - public async getAccountByName( - user: string, - host: string | null, - ): Promise> { - return this.client - .post("/api/users/show", { - username: user, - host: host ?? null, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.userDetail( - res.data, - this.baseUrlToHost(this.baseUrl), - ), - }); - }); - } - - /** - * POST /api/users/notes - */ - public async getAccountStatuses( - id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - pinned?: boolean; - exclude_replies: boolean; - exclude_reblogs: boolean; - only_media?: boolean; - }, - ): Promise>> { - const accountCache = this.getFreshAccountCache(); - - if (options?.pinned) { - return this.client - .post("/api/users/show", { - userId: id, - }) - .then(async (res) => { - if (res.data.pinnedNotes) { - return { - ...res, - data: await Promise.all( - res.data.pinnedNotes.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ), - }; - } - return { ...res, data: [] }; - }); - } - - let params = { - userId: id, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - if (options.exclude_replies) { - params = Object.assign(params, { - includeReplies: false, - }); - } - if (options.exclude_reblogs) { - params = Object.assign(params, { - includeMyRenotes: false, - }); - } - if (options.only_media) { - params = Object.assign(params, { - withFiles: options.only_media, - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - return this.client - .post>("/api/users/notes", params) - .then(async (res) => { - const statuses: Array = await Promise.all( - res.data.map((note) => - this.noteWithDetails( - note, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ); - return Object.assign(res, { - data: statuses, - }); - }); - } - - public async getAccountFavourites( - id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - }, - ): Promise>> { - const accountCache = this.getFreshAccountCache(); - - let params = { - userId: id, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit <= 100 ? options.limit : 100, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - } - return this.client - .post>("/api/users/reactions", params) - .then(async (res) => { - return Object.assign(res, { - data: await Promise.all( - res.data.map((fav) => - this.noteWithDetails( - fav.note, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ), - }); - }); - } - - public async subscribeAccount( - _id: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async unsubscribeAccount( - _id: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - /** - * POST /api/users/followers - */ - public async getAccountFollowers( - id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - }, - ): Promise>> { - let params = { - userId: id, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit <= 100 ? options.limit : 100, - }); - } else { - params = Object.assign(params, { - limit: 40, - }); - } - } else { - params = Object.assign(params, { - limit: 40, - }); - } - return this.client - .post>("/api/users/followers", params) - .then(async (res) => { - return Object.assign(res, { - data: await Promise.all( - res.data.map(async (f) => - this.getAccount(f.followerId).then((p) => p.data), - ), - ), - }); - }); - } - - /** - * POST /api/users/following - */ - public async getAccountFollowing( - id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - }, - ): Promise>> { - let params = { - userId: id, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit <= 100 ? options.limit : 100, - }); - } - } - return this.client - .post>("/api/users/following", params) - .then(async (res) => { - return Object.assign(res, { - data: await Promise.all( - res.data.map(async (f) => - this.getAccount(f.followeeId).then((p) => p.data), - ), - ), - }); - }); - } - - public async getAccountLists( - _id: string, - ): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async getIdentityProof( - _id: string, - ): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - /** - * POST /api/following/create - */ - public async followAccount( - id: string, - _options?: { reblog?: boolean }, - ): Promise> { - await this.client.post<{}>("/api/following/create", { - userId: id, - }); - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - /** - * POST /api/following/delete - */ - public async unfollowAccount( - id: string, - ): Promise> { - await this.client.post<{}>("/api/following/delete", { - userId: id, - }); - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - /** - * POST /api/blocking/create - */ - public async blockAccount( - id: string, - ): Promise> { - await this.client.post<{}>("/api/blocking/create", { - userId: id, - }); - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - /** - * POST /api/blocking/delete - */ - public async unblockAccount( - id: string, - ): Promise> { - await this.client.post<{}>("/api/blocking/delete", { - userId: id, - }); - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - /** - * POST /api/mute/create - */ - public async muteAccount( - id: string, - _notifications: boolean, - ): Promise> { - await this.client.post<{}>("/api/mute/create", { - userId: id, - }); - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - /** - * POST /api/mute/delete - */ - public async unmuteAccount( - id: string, - ): Promise> { - await this.client.post<{}>("/api/mute/delete", { - userId: id, - }); - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - public async pinAccount(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async unpinAccount( - _id: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - /** - * POST /api/users/relation - * - * @param id The accountID, for example `'1sdfag'` - */ - public async getRelationship( - id: string, - ): Promise> { - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - /** - * POST /api/users/relation - * - * @param id Array of account ID, for example `['1sdfag', 'ds12aa']`. - */ - public async getRelationships( - ids: Array, - ): Promise>> { - return Promise.all(ids.map((id) => this.getRelationship(id))).then( - (results) => ({ - ...results[0], - data: results.map((r) => r.data), - }), - ); - } - - /** - * POST /api/users/search - */ - public async searchAccount( - q: string, - options?: { - following?: boolean; - resolve?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - }, - ): Promise>> { - let params = { - query: q, - detail: true, - }; - if (options) { - if (options.resolve !== undefined) { - params = Object.assign(params, { - localOnly: options.resolve, - }); - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 40, - }); - } - } else { - params = Object.assign(params, { - limit: 40, - }); - } - return this.client - .post>("/api/users/search", params) - .then((res) => { - return Object.assign(res, { - data: res.data.map((u) => - this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), - ), - }); - }); - } - - // ====================================== - // accounts/bookmarks - // ====================================== - /** - * POST /api/i/favorites - */ - public async getBookmarks(options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>> { - const accountCache = this.getFreshAccountCache(); - - let params = {}; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit <= 100 ? options.limit : 100, - }); - } else { - params = Object.assign(params, { - limit: 40, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 40, - }); - } - return this.client - .post>("/api/i/favorites", params) - .then(async (res) => { - return Object.assign(res, { - data: await Promise.all( - res.data.map((s) => - this.noteWithDetails( - s.note, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ), - }); - }); - } - - // ====================================== - // accounts/favourites - // ====================================== - public async getFavourites(options?: { - limit?: number; - max_id?: string; - min_id?: string; - }): Promise>> { - const userId = await this.client - .post("/api/i") - .then((res) => res.data.id); - return this.getAccountFavourites(userId, options); - } - - // ====================================== - // accounts/mutes - // ====================================== - /** - * POST /api/mute/list - */ - public async getMutes(options?: { - limit?: number; - max_id?: string; - min_id?: string; - }): Promise>> { - let params = {}; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 40, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 40, - }); - } - return this.client - .post>("/api/mute/list", params) - .then((res) => { - return Object.assign(res, { - data: res.data.map((mute) => - this.converter.userDetail( - mute.mutee, - this.baseUrlToHost(this.baseUrl), - ), - ), - }); - }); - } - - // ====================================== - // accounts/blocks - // ====================================== - /** - * POST /api/blocking/list - */ - public async getBlocks(options?: { - limit?: number; - max_id?: string; - min_id?: string; - }): Promise>> { - let params = {}; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 40, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 40, - }); - } - return this.client - .post>("/api/blocking/list", params) - .then((res) => { - return Object.assign(res, { - data: res.data.map((blocking) => - this.converter.userDetail( - blocking.blockee, - this.baseUrlToHost(this.baseUrl), - ), - ), - }); - }); - } - - // ====================================== - // accounts/domain_blocks - // ====================================== - public async getDomainBlocks(_options?: { - limit?: number; - max_id?: string; - min_id?: string; - }): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async blockDomain(_domain: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async unblockDomain(_domain: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // accounts/filters - // ====================================== - public async getFilters(): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async getFilter(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async createFilter( - _phrase: string, - _context: Array, - _options?: { - irreversible?: boolean; - whole_word?: boolean; - expires_in?: string; - }, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async updateFilter( - _id: string, - _phrase: string, - _context: Array, - _options?: { - irreversible?: boolean; - whole_word?: boolean; - expires_in?: string; - }, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async deleteFilter(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // accounts/reports - // ====================================== - /** - * POST /api/users/report-abuse - */ - public async report( - account_id: string, - comment: string, - _options?: { - status_ids?: Array; - forward?: boolean; - }, - ): Promise> { - return this.client - .post<{}>("/api/users/report-abuse", { - userId: account_id, - comment: comment, - }) - .then((res) => { - return Object.assign(res, { - data: { - id: "", - action_taken: "", - comment: comment, - account_id: account_id, - status_ids: [], - }, - }); - }); - } - - // ====================================== - // accounts/follow_requests - // ====================================== - /** - * POST /api/following/requests/list - */ - public async getFollowRequests( - _limit?: number, - ): Promise>> { - return this.client - .post>( - "/api/following/requests/list", - ) - .then((res) => { - return Object.assign(res, { - data: res.data.map((r) => this.converter.user(r.follower)), - }); - }); - } - - /** - * POST /api/following/requests/accept - */ - public async acceptFollowRequest( - id: string, - ): Promise> { - await this.client.post<{}>("/api/following/requests/accept", { - userId: id, - }); - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - /** - * POST /api/following/requests/reject - */ - public async rejectFollowRequest( - id: string, - ): Promise> { - await this.client.post<{}>("/api/following/requests/reject", { - userId: id, - }); - return this.client - .post("/api/users/relation", { - userId: id, - }) - .then((res) => { - return Object.assign(res, { - data: this.converter.relation(res.data), - }); - }); - } - - // ====================================== - // accounts/endorsements - // ====================================== - public async getEndorsements(_options?: { - limit?: number; - max_id?: string; - since_id?: string; - }): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // accounts/featured_tags - // ====================================== - public async getFeaturedTags(): Promise>> { - return this.getAccountFeaturedTags(); - } - - public async getAccountFeaturedTags(): Promise< - Response> - > { - const tags: Entity.FeaturedTag[] = []; - const res: Response = { - headers: undefined, - statusText: "", - status: 200, - data: tags, - }; - return new Promise((resolve) => resolve(res)); - } - - public async createFeaturedTag( - _name: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async deleteFeaturedTag(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async getSuggestedTags(): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // accounts/preferences - // ====================================== - public async getPreferences(): Promise> { - return this.client - .post("/api/i") - .then(async (res) => { - return Object.assign(res, { - data: this.converter.userPreferences( - res.data, - await this.getDefaultPostPrivacy(), - ), - }); - }); - } - - // ====================================== - // accounts/suggestions - // ====================================== - /** - * POST /api/users/recommendation - */ - public async getSuggestions( - limit?: number, - ): Promise>> { - let params = {}; - if (limit) { - params = Object.assign(params, { - limit: limit, - }); - } - return this.client - .post>( - "/api/users/recommendation", - params, - ) - .then((res) => ({ - ...res, - data: res.data.map((u) => - this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), - ), - })); - } - - // ====================================== - // accounts/tags - // ====================================== - public async getFollowedTags(): Promise>> { - const tags: Entity.Tag[] = []; - const res: Response = { - headers: undefined, - statusText: "", - status: 200, - data: tags, - }; - return new Promise((resolve) => resolve(res)); - } - - public async getTag(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async followTag(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async unfollowTag(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // statuses - // ====================================== - public async postStatus( - status: string, - options?: { - media_ids?: Array; - poll?: { - options: Array; - expires_in: number; - multiple?: boolean; - hide_totals?: boolean; - }; - in_reply_to_id?: string; - sensitive?: boolean; - spoiler_text?: string; - visibility?: "public" | "unlisted" | "private" | "direct"; - scheduled_at?: string; - language?: string; - quote_id?: string; - }, - ): Promise> { - let params = { - text: status, - }; - if (options) { - if (options.media_ids) { - params = Object.assign(params, { - fileIds: options.media_ids, - }); - } - if (options.poll) { - let pollParam = { - choices: options.poll.options, - expiresAt: null, - expiredAfter: options.poll.expires_in * 1000, - }; - if (options.poll.multiple !== undefined) { - pollParam = Object.assign(pollParam, { - multiple: options.poll.multiple, - }); - } - params = Object.assign(params, { - poll: pollParam, - }); - } - if (options.in_reply_to_id) { - params = Object.assign(params, { - replyId: options.in_reply_to_id, - }); - } - if (options.sensitive) { - params = Object.assign(params, { - cw: "", - }); - } - if (options.spoiler_text) { - params = Object.assign(params, { - cw: options.spoiler_text, - }); - } - if (options.visibility) { - params = Object.assign(params, { - visibility: this.converter.encodeVisibility(options.visibility), - }); - } - if (options.quote_id) { - params = Object.assign(params, { - renoteId: options.quote_id, - }); - } - } - return this.client - .post("/api/notes/create", params) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data.createdNote, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - /** - * POST /api/notes/show - */ - public async getStatus(id: string): Promise> { - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - private getFreshAccountCache(): AccountCache { - return { - locks: new AsyncLock(), - accounts: [], - }; - } - - public async notificationWithDetails( - n: MisskeyAPI.Entity.Notification, - host: string, - cache: AccountCache, - ): Promise { - const notification = this.converter.notification(n, host); - if (n.note) - notification.status = await this.noteWithDetails(n.note, host, cache); - if (notification.account) - notification.account = ( - await this.getAccount(notification.account.id) - ).data; - return notification; - } - - public async noteWithDetails( - n: MisskeyAPI.Entity.Note, - host: string, - cache: AccountCache, - ): Promise { - const status = await this.addUserDetailsToStatus( - this.converter.note(n, host), - cache, - ); - status.bookmarked = await this.isStatusBookmarked(n.id); - return this.addMentionsToStatus(status, cache); - } - - public async isStatusBookmarked(id: string): Promise { - return this.client - .post("/api/notes/state", { - noteId: id, - }) - .then((p) => p.data.isFavorited ?? false); - } - - public async addUserDetailsToStatus( - status: Entity.Status, - cache: AccountCache, - ): Promise { - if ( - status.account.followers_count === 0 && - status.account.followers_count === 0 && - status.account.statuses_count === 0 - ) - status.account = - (await this.getAccountCached( - status.account.id, - status.account.acct, - cache, - )) ?? status.account; - - if (status.reblog != null) - status.reblog = await this.addUserDetailsToStatus(status.reblog, cache); - - if (status.quote != null) - status.quote = await this.addUserDetailsToStatus(status.quote, cache); - - return status; - } - - public async addMentionsToStatus( - status: Entity.Status, - cache: AccountCache, - ): Promise { - if (status.mentions.length > 0) return status; - - if (status.reblog != null) - status.reblog = await this.addMentionsToStatus(status.reblog, cache); - - if (status.quote != null) - status.quote = await this.addMentionsToStatus(status.quote, cache); - - const idx = status.account.acct.indexOf("@"); - const origin = idx < 0 ? null : status.account.acct.substring(idx + 1); - - status.mentions = ( - await this.getMentions(status.plain_content!, origin, cache) - ).filter((p) => p != null); - for (const m of status.mentions.filter( - (value, index, array) => array.indexOf(value) === index, - )) { - const regexFull = new RegExp( - `(?<=^|\\s|>)@${m.acct}(?=[^a-zA-Z0-9]|$)`, - "gi", - ); - const regexLocalUser = new RegExp( - `(?<=^|\\s|>)@${m.acct}@${this.baseUrlToHost( - this.baseUrl, - )}(?=[^a-zA-Z0-9]|$)`, - "gi", - ); - const regexRemoteUser = new RegExp( - `(?<=^|\\s|>)@${m.username}(?=[^a-zA-Z0-9@]|$)`, - "gi", - ); - - if (m.acct == m.username) { - status.content = status.content.replace(regexLocalUser, `@${m.acct}`); - } else if (!status.content.match(regexFull)) { - status.content = status.content.replace(regexRemoteUser, `@${m.acct}`); - } - - status.content = status.content.replace( - regexFull, - `@${m.acct}`, - ); - } - return status; - } - - public async getMentions( - text: string, - origin: string | null, - cache: AccountCache, - ): Promise { - const mentions: Entity.Mention[] = []; - - if (text == undefined) return mentions; - - const mentionMatch = text.matchAll( - /(?<=^|\s)@(?[a-zA-Z0-9_]+)(?:@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)|)(?=[^a-zA-Z0-9]|$)/g, - ); - - for (const m of mentionMatch) { - try { - if (m.groups == null) continue; - - const account = await this.getAccountByNameCached( - m.groups.user, - m.groups.host ?? origin, - cache, - ); - - if (account == null) continue; - - mentions.push({ - id: account.id, - url: account.url, - username: account.username, - acct: account.acct, - }); - } catch {} - } - - return mentions; - } - - public async getAccountByNameCached( - user: string, - host: string | null, - cache: AccountCache, - ): Promise { - const acctToFind = host == null ? user : `${user}@${host}`; - - return await cache.locks.acquire(acctToFind, async () => { - const cacheHit = cache.accounts.find((p) => p.acct === acctToFind); - const account = - cacheHit ?? (await this.getAccountByName(user, host ?? null)).data; - - if (!account) { - return null; - } - - if (cacheHit == null) { - cache.accounts.push(account); - } - - return account; - }); - } - - public async getAccountCached( - id: string, - acct: string, - cache: AccountCache, - ): Promise { - return await cache.locks.acquire(acct, async () => { - const cacheHit = cache.accounts.find((p) => p.id === id); - const account = cacheHit ?? (await this.getAccount(id)).data; - - if (!account) { - return null; - } - - if (cacheHit == null) { - cache.accounts.push(account); - } - - return account; - }); - } - - public async editStatus( - _id: string, - _options: { - status?: string; - spoiler_text?: string; - sensitive?: boolean; - media_ids?: Array; - poll?: { - options?: Array; - expires_in?: number; - multiple?: boolean; - hide_totals?: boolean; - }; - }, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - /** - * POST /api/notes/delete - */ - public async deleteStatus(id: string): Promise> { - return this.client.post<{}>("/api/notes/delete", { - noteId: id, - }); - } - - /** - * POST /api/notes/children - */ - public async getStatusContext( - id: string, - options?: { limit?: number; max_id?: string; since_id?: string }, - ): Promise> { - let params = { - noteId: id, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - depth: 12, - }); - } else { - params = Object.assign(params, { - limit: 30, - depth: 12, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - } else { - params = Object.assign(params, { - limit: 30, - depth: 12, - }); - } - return this.client - .post>("/api/notes/children", params) - .then(async (res) => { - const accountCache = this.getFreshAccountCache(); - const conversation = await this.client.post< - Array - >("/api/notes/conversation", params); - const parents = await Promise.all( - conversation.data.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ); - - const context: Entity.Context = { - ancestors: parents.reverse(), - descendants: this.dfs( - await Promise.all( - res.data.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ), - ), - }; - return { - ...res, - data: context, - }; - }); - } - - private dfs(graph: Entity.Status[]) { - // we don't need to run dfs if we have zero or one elements - if (graph.length <= 1) { - return graph; - } - - // sort the graph first, so we can grab the correct starting point - graph = graph.sort((a, b) => { - if (a.id < b.id) return -1; - if (a.id > b.id) return 1; - return 0; - }); - - const initialPostId = graph[0].in_reply_to_id; - - // populate stack with all top level replies - const stack = graph - .filter((reply) => reply.in_reply_to_id === initialPostId) - .reverse(); - const visited = new Set(); - const result = []; - - while (stack.length) { - const currentPost = stack.pop(); - - if (currentPost === undefined) return result; - - if (!visited.has(currentPost)) { - visited.add(currentPost); - result.push(currentPost); - - for (const reply of graph - .filter((reply) => reply.in_reply_to_id === currentPost.id) - .reverse()) { - stack.push(reply); - } - } - } - - return result; - } - - public async getStatusHistory(): Promise>> { - // FIXME: stub, implement once we have note edit history in the database - const history: Entity.StatusEdit[] = []; - const res: Response = { - headers: undefined, - statusText: "", - status: 200, - data: history, - }; - return new Promise((resolve) => resolve(res)); - } - - /** - * POST /api/notes/renotes - */ - public async getStatusRebloggedBy( - id: string, - ): Promise>> { - return this.client - .post>("/api/notes/renotes", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: ( - await Promise.all(res.data.map((n) => this.getAccount(n.user.id))) - ).map((p) => p.data), - })); - } - - public async getStatusFavouritedBy( - id: string, - ): Promise>> { - return this.client - .post>("/api/notes/reactions", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: ( - await Promise.all(res.data.map((n) => this.getAccount(n.user.id))) - ).map((p) => p.data), - })); - } - - public async favouriteStatus(id: string): Promise> { - return this.createEmojiReaction(id, await this.getDefaultFavoriteEmoji()); - } - - private async getDefaultFavoriteEmoji(): Promise { - // NOTE: get-unsecure is calckey's extension. - // Misskey doesn't have this endpoint and regular `/i/registry/get` won't work - // unless you have a 'nativeToken', which is reserved for the frontend webapp. - - return await this.client - .post>("/api/i/registry/get-unsecure", { - key: "reactions", - scope: ["client", "base"], - }) - .then((res) => res.data[0] ?? "⭐"); - } - - private async getDefaultPostPrivacy(): Promise< - "public" | "unlisted" | "private" | "direct" - > { - // NOTE: get-unsecure is calckey's extension. - // Misskey doesn't have this endpoint and regular `/i/registry/get` won't work - // unless you have a 'nativeToken', which is reserved for the frontend webapp. - - return this.client - .post("/api/i/registry/get-unsecure", { - key: "defaultNoteVisibility", - scope: ["client", "base"], - }) - .then((res) => { - if ( - !res.data || - (res.data != "public" && - res.data != "home" && - res.data != "followers" && - res.data != "specified") - ) - return "public"; - return this.converter.visibility(res.data); - }) - .catch((_) => "public"); - } - - public async unfavouriteStatus(id: string): Promise> { - // NOTE: Misskey allows only one reaction per status, so we don't need to care what that emoji was. - return this.deleteEmojiReaction(id, ""); - } - - /** - * POST /api/notes/create - */ - public async reblogStatus(id: string): Promise> { - return this.client - .post("/api/notes/create", { - renoteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data.createdNote, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - /** - * POST /api/notes/unrenote - */ - public async unreblogStatus(id: string): Promise> { - await this.client.post<{}>("/api/notes/unrenote", { - noteId: id, - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - /** - * POST /api/notes/favorites/create - */ - public async bookmarkStatus(id: string): Promise> { - await this.client.post<{}>("/api/notes/favorites/create", { - noteId: id, - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - /** - * POST /api/notes/favorites/delete - */ - public async unbookmarkStatus(id: string): Promise> { - await this.client.post<{}>("/api/notes/favorites/delete", { - noteId: id, - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - public async muteStatus(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async unmuteStatus(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - /** - * POST /api/i/pin - */ - public async pinStatus(id: string): Promise> { - await this.client.post<{}>("/api/i/pin", { - noteId: id, - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - /** - * POST /api/i/unpin - */ - public async unpinStatus(id: string): Promise> { - await this.client.post<{}>("/api/i/unpin", { - noteId: id, - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - /** - * Convert a Unicode emoji or custom emoji name to a Misskey reaction. - * @see Misskey's reaction-lib.ts - */ - private reactionName(name: string): string { - // See: https://github.com/tc39/proposal-regexp-unicode-property-escapes#matching-emoji - const isUnicodeEmoji = - /\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu.test( - name, - ); - if (isUnicodeEmoji) { - return name; - } - return `:${name}:`; - } - - /** - * POST /api/notes/reactions/create - */ - public async reactStatus( - id: string, - name: string, - ): Promise> { - await this.client.post<{}>("/api/notes/reactions/create", { - noteId: id, - reaction: this.reactionName(name), - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - /** - * POST /api/notes/reactions/delete - */ - public async unreactStatus( - id: string, - name: string, - ): Promise> { - await this.client.post<{}>("/api/notes/reactions/delete", { - noteId: id, - reaction: this.reactionName(name), - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - // ====================================== - // statuses/media - // ====================================== - /** - * POST /api/drive/files/create - */ - public async uploadMedia( - file: any, - options?: { description?: string; focus?: string }, - ): Promise> { - const formData = new FormData(); - formData.append("file", fs.createReadStream(file.path), { - contentType: file.mimetype, - }); - - if (file.originalname != null && file.originalname !== "file") - formData.append("name", file.originalname); - - if (options?.description != null) - formData.append("comment", options.description); - - let headers: { [key: string]: string } = {}; - if (typeof formData.getHeaders === "function") { - headers = formData.getHeaders(); - } - return this.client - .post( - "/api/drive/files/create", - formData, - headers, - ) - .then((res) => ({ ...res, data: this.converter.file(res.data) })); - } - - public async getMedia(id: string): Promise> { - const res = await this.client.post( - "/api/drive/files/show", - { fileId: id }, - ); - return { ...res, data: this.converter.file(res.data) }; - } - - /** - * POST /api/drive/files/update - */ - public async updateMedia( - id: string, - options?: { - file?: any; - description?: string; - focus?: string; - is_sensitive?: boolean; - }, - ): Promise> { - let params = { - fileId: id, - }; - if (options) { - if (options.is_sensitive !== undefined) { - params = Object.assign(params, { - isSensitive: options.is_sensitive, - }); - } - - if (options.description !== undefined) { - params = Object.assign(params, { - comment: options.description, - }); - } - } - return this.client - .post("/api/drive/files/update", params) - .then((res) => ({ ...res, data: this.converter.file(res.data) })); - } - - // ====================================== - // statuses/polls - // ====================================== - public async getPoll(id: string): Promise> { - const res = await this.getStatus(id); - if (res.data.poll == null) throw new Error("poll not found"); - return { ...res, data: res.data.poll }; - } - - /** - * POST /api/notes/polls/vote - */ - public async votePoll( - id: string, - choices: Array, - ): Promise> { - if (!id) { - return new Promise((_, reject) => { - const err = new ArgumentError("id is required"); - reject(err); - }); - } - - for (const c of choices) { - const params = { - noteId: id, - choice: +c, - }; - await this.client.post<{}>("/api/notes/polls/vote", params); - } - - const res = await this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => { - const note = await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ); - return { ...res, data: note.poll }; - }); - if (!res.data) { - return new Promise((_, reject) => { - const err = new UnexpectedError("poll does not exist"); - reject(err); - }); - } - return { ...res, data: res.data }; - } - - // ====================================== - // statuses/scheduled_statuses - // ====================================== - public async getScheduledStatuses(_options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async getScheduledStatus( - _id: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async scheduleStatus( - _id: string, - _scheduled_at?: string | null, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async cancelScheduledStatus(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // timelines - // ====================================== - /** - * POST /api/notes/global-timeline - */ - public async getPublicTimeline(options?: { - only_media?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>> { - const accountCache = this.getFreshAccountCache(); - - let params = {}; - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - withFiles: options.only_media, - }); - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - return this.client - .post>("/api/notes/global-timeline", params) - .then(async (res) => ({ - ...res, - data: ( - await Promise.all( - res.data.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ) - ).sort(this.sortByIdDesc), - })); - } - - /** - * POST /api/notes/local-timeline - */ - public async getLocalTimeline(options?: { - only_media?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>> { - const accountCache = this.getFreshAccountCache(); - - let params = {}; - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - withFiles: options.only_media, - }); - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - return this.client - .post>("/api/notes/local-timeline", params) - .then(async (res) => ({ - ...res, - data: ( - await Promise.all( - res.data.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ) - ).sort(this.sortByIdDesc), - })); - } - - /** - * POST /api/notes/search-by-tag - */ - public async getTagTimeline( - hashtag: string, - options?: { - local?: boolean; - only_media?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }, - ): Promise>> { - const accountCache = this.getFreshAccountCache(); - - let params = { - tag: hashtag, - }; - if (options) { - if (options.only_media !== undefined) { - params = Object.assign(params, { - withFiles: options.only_media, - }); - } - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - return this.client - .post>("/api/notes/search-by-tag", params) - .then(async (res) => ({ - ...res, - data: ( - await Promise.all( - res.data.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ) - ).sort(this.sortByIdDesc), - })); - } - - /** - * POST /api/notes/timeline - */ - public async getHomeTimeline(options?: { - local?: boolean; - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>> { - const accountCache = this.getFreshAccountCache(); - - let params = { - withFiles: false, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - return this.client - .post>("/api/notes/timeline", params) - .then(async (res) => ({ - ...res, - data: ( - await Promise.all( - res.data.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ) - ).sort(this.sortByIdDesc), - })); - } - - /** - * POST /api/notes/user-list-timeline - */ - public async getListTimeline( - list_id: string, - options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }, - ): Promise>> { - const accountCache = this.getFreshAccountCache(); - - let params = { - listId: list_id, - withFiles: false, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - return this.client - .post>( - "/api/notes/user-list-timeline", - params, - ) - .then(async (res) => ({ - ...res, - data: ( - await Promise.all( - res.data.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ) - ).sort(this.sortByIdDesc), - })); - } - - // ====================================== - // timelines/conversations - // ====================================== - /** - * POST /api/notes/mentions - */ - public async getConversationTimeline(options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - }): Promise>> { - let params = { - visibility: "specified", - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - return this.client - .post>("/api/notes/mentions", params) - .then((res) => ({ - ...res, - data: res.data.map((n) => - this.converter.noteToConversation( - n, - this.baseUrlToHost(this.baseUrl), - ), - ), - })); - // FIXME: ^ this should also parse mentions - } - - public async deleteConversation(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async readConversation( - _id: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - private sortByIdDesc(a: Entity.Status, b: Entity.Status): number { - if (a.id < b.id) return 1; - if (a.id > b.id) return -1; - - return 0; - } - - // ====================================== - // timelines/lists - // ====================================== - /** - * POST /api/users/lists/list - */ - public async getLists(): Promise>> { - return this.client - .post>("/api/users/lists/list") - .then((res) => ({ - ...res, - data: res.data.map((l) => this.converter.list(l)), - })); - } - - /** - * POST /api/users/lists/show - */ - public async getList(id: string): Promise> { - return this.client - .post("/api/users/lists/show", { - listId: id, - }) - .then((res) => ({ ...res, data: this.converter.list(res.data) })); - } - - /** - * POST /api/users/lists/create - */ - public async createList(title: string): Promise> { - return this.client - .post("/api/users/lists/create", { - name: title, - }) - .then((res) => ({ ...res, data: this.converter.list(res.data) })); - } - - /** - * POST /api/users/lists/update - */ - public async updateList( - id: string, - title: string, - ): Promise> { - return this.client - .post("/api/users/lists/update", { - listId: id, - name: title, - }) - .then((res) => ({ ...res, data: this.converter.list(res.data) })); - } - - /** - * POST /api/users/lists/delete - */ - public async deleteList(id: string): Promise> { - return this.client.post<{}>("/api/users/lists/delete", { - listId: id, - }); - } - - /** - * POST /api/users/lists/show - */ - public async getAccountsInList( - id: string, - _options?: { - limit?: number; - max_id?: string; - since_id?: string; - }, - ): Promise>> { - const res = await this.client.post( - "/api/users/lists/show", - { - listId: id, - }, - ); - const promise = res.data.userIds.map((userId) => this.getAccount(userId)); - const accounts = await Promise.all(promise); - return { ...res, data: accounts.map((r) => r.data) }; - } - - /** - * POST /api/users/lists/push - */ - public async addAccountsToList( - id: string, - account_ids: Array, - ): Promise> { - return this.client.post<{}>("/api/users/lists/push", { - listId: id, - userId: account_ids[0], - }); - } - - /** - * POST /api/users/lists/pull - */ - public async deleteAccountsFromList( - id: string, - account_ids: Array, - ): Promise> { - return this.client.post<{}>("/api/users/lists/pull", { - listId: id, - userId: account_ids[0], - }); - } - - // ====================================== - // timelines/markers - // ====================================== - public async getMarkers( - _timeline: Array, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async saveMarkers(_options?: { - home?: { last_read_id: string }; - notifications?: { last_read_id: string }; - }): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // notifications - // ====================================== - /** - * POST /api/i/notifications - */ - public async getNotifications(options?: { - limit?: number; - max_id?: string; - since_id?: string; - min_id?: string; - exclude_type?: Array; - account_id?: string; - }): Promise>> { - let params = {}; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit <= 100 ? options.limit : 100, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.since_id) { - params = Object.assign(params, { - sinceId: options.since_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - if (options.exclude_type) { - params = Object.assign(params, { - excludeType: options.exclude_type.map((e) => - this.converter.encodeNotificationType(e), - ), - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - const cache = this.getFreshAccountCache(); - return this.client - .post>( - "/api/i/notifications", - params, - ) - .then(async (res) => ({ - ...res, - data: await Promise.all( - res.data - .filter( - (p) => p.type != MisskeyNotificationType.FollowRequestAccepted, - ) // these aren't supported on mastodon - .map((n) => - this.notificationWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - cache, - ), - ), - ), - })); - } - - public async getNotification( - _id: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - /** - * POST /api/notifications/mark-all-as-read - */ - public async dismissNotifications(): Promise> { - return this.client.post<{}>("/api/notifications/mark-all-as-read"); - } - - public async dismissNotification(_id: string): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async readNotifications(_options: { - id?: string; - max_id?: string; - }): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("mastodon does not support"); - reject(err); - }); - } - - // ====================================== - // notifications/push - // ====================================== - public async subscribePushNotification( - _subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, - _data?: { - alerts: { - follow?: boolean; - favourite?: boolean; - reblog?: boolean; - mention?: boolean; - poll?: boolean; - }; - } | null, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async getPushSubscription(): Promise< - Response - > { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async updatePushSubscription( - _data?: { - alerts: { - follow?: boolean; - favourite?: boolean; - reblog?: boolean; - mention?: boolean; - poll?: boolean; - }; - } | null, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - /** - * DELETE /api/v1/push/subscription - */ - public async deletePushSubscription(): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // search - // ====================================== - public async search( - q: string, - type: "accounts" | "hashtags" | "statuses", - options?: { - limit?: number; - max_id?: string; - min_id?: string; - resolve?: boolean; - offset?: number; - following?: boolean; - account_id?: string; - exclude_unreviewed?: boolean; - }, - ): Promise> { - const accountCache = this.getFreshAccountCache(); - - switch (type) { - case "accounts": { - if (q.startsWith("http://") || q.startsWith("https://")) { - return this.client - .post("/api/ap/show", { uri: q }) - .then(async (res) => { - if (res.status != 200 || res.data.type != "User") { - res.status = 200; - res.statusText = "OK"; - res.data = { - accounts: [], - statuses: [], - hashtags: [], - }; - - return res; - } - - const account = await this.converter.userDetail( - res.data.object as MisskeyAPI.Entity.UserDetail, - this.baseUrlToHost(this.baseUrl), - ); - - return { - ...res, - data: { - accounts: - options?.max_id && options?.max_id >= account.id - ? [] - : [account], - statuses: [], - hashtags: [], - }, - }; - }); - } - let params = { - query: q, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } else { - params = Object.assign(params, { - limit: 20, - }); - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset, - }); - } - if (options.resolve) { - params = Object.assign(params, { - localOnly: options.resolve, - }); - } - } else { - params = Object.assign(params, { - limit: 20, - }); - } - - try { - const match = q.match( - /^@(?[a-zA-Z0-9_]+)(?:@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)|)$/, - ); - if (match) { - const lookupQuery = { - username: match.groups?.user, - host: match.groups?.host, - }; - - const result = await this.client - .post( - "/api/users/show", - lookupQuery, - ) - .then((res) => ({ - ...res, - data: { - accounts: [ - this.converter.userDetail( - res.data, - this.baseUrlToHost(this.baseUrl), - ), - ], - statuses: [], - hashtags: [], - }, - })); - - if (result.status !== 200) { - result.status = 200; - result.statusText = "OK"; - result.data = { - accounts: [], - statuses: [], - hashtags: [], - }; - } - - return result; - } - } catch {} - - return this.client - .post>( - "/api/users/search", - params, - ) - .then((res) => ({ - ...res, - data: { - accounts: res.data.map((u) => - this.converter.userDetail(u, this.baseUrlToHost(this.baseUrl)), - ), - statuses: [], - hashtags: [], - }, - })); - } - case "statuses": { - if (q.startsWith("http://") || q.startsWith("https://")) { - return this.client - .post("/api/ap/show", { uri: q }) - .then(async (res) => { - if (res.status != 200 || res.data.type != "Note") { - res.status = 200; - res.statusText = "OK"; - res.data = { - accounts: [], - statuses: [], - hashtags: [], - }; - - return res; - } - - const post = await this.noteWithDetails( - res.data.object as MisskeyAPI.Entity.Note, - this.baseUrlToHost(this.baseUrl), - accountCache, - ); - - return { - ...res, - data: { - accounts: [], - statuses: - options?.max_id && options.max_id >= post.id ? [] : [post], - hashtags: [], - }, - }; - }); - } - let params = { - query: q, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset, - }); - } - if (options.max_id) { - params = Object.assign(params, { - untilId: options.max_id, - }); - } - if (options.min_id) { - params = Object.assign(params, { - sinceId: options.min_id, - }); - } - if (options.account_id) { - params = Object.assign(params, { - userId: options.account_id, - }); - } - } - return this.client - .post>("/api/notes/search", params) - .then(async (res) => ({ - ...res, - data: { - accounts: [], - statuses: await Promise.all( - res.data.map((n) => - this.noteWithDetails( - n, - this.baseUrlToHost(this.baseUrl), - accountCache, - ), - ), - ), - hashtags: [], - }, - })); - } - case "hashtags": { - let params = { - query: q, - }; - if (options) { - if (options.limit) { - params = Object.assign(params, { - limit: options.limit, - }); - } - if (options.offset) { - params = Object.assign(params, { - offset: options.offset, - }); - } - } - return this.client - .post>("/api/hashtags/search", params) - .then((res) => ({ - ...res, - data: { - accounts: [], - statuses: [], - hashtags: res.data.map((h) => ({ - name: h, - url: h, - history: null, - following: false, - })), - }, - })); - } - } - } - - // ====================================== - // instance - // ====================================== - /** - * POST /api/meta - * POST /api/stats - */ - public async getInstance(): Promise> { - const meta = await this.client - .post("/api/meta", { "detail": true }) - .then((res) => res.data); - return this.client - .post("/api/stats", { "detail": true }) - .then((res) => ({ ...res, data: this.converter.meta(meta, res.data) })); - } - - public async getInstancePeers(): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public async getInstanceActivity(): Promise< - Response> - > { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // instance/trends - // ====================================== - /** - * POST /api/hashtags/trend - */ - public async getInstanceTrends( - _limit?: number | null, - ): Promise>> { - return this.client - .post>("/api/hashtags/trend") - .then((res) => ({ - ...res, - data: res.data.map((h) => this.converter.hashtag(h)), - })); - } - - // ====================================== - // instance/directory - // ====================================== - public async getInstanceDirectory(_options?: { - limit?: number; - offset?: number; - order?: "active" | "new"; - local?: boolean; - }): Promise>> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - // ====================================== - // instance/custom_emojis - // ====================================== - /** - * POST /api/meta - */ - public async getInstanceCustomEmojis(): Promise< - Response> - > { - return this.client - .post("/api/emojis") - .then((res) => ({ - ...res, - data: res.data.emojis.map((e: any) => this.converter.emoji(e)), - })); - } - - // ====================================== - // instance/announcements - // ====================================== - public async getInstanceAnnouncements( - with_dismissed?: boolean | null, - ): Promise>> { - let params = {}; - if (with_dismissed) { - params = Object.assign(params, { - withUnreads: with_dismissed, - }); - } - return this.client - .post>("/api/announcements", params) - .then((res) => ({ - ...res, - data: res.data.map((t) => this.converter.announcement(t)), - })); - } - - public async dismissInstanceAnnouncement(id: string): Promise> { - return this.client.post<{}>("/api/i/read-announcement", { - announcementId: id, - }); - } - - // ====================================== - // Emoji reactions - // ====================================== - /** - * POST /api/notes/reactions/create - * - * @param {string} id Target note ID. - * @param {string} emoji Reaction emoji string. This string is raw unicode emoji. - */ - public async createEmojiReaction( - id: string, - emoji: string, - ): Promise> { - await this.client.post<{}>("/api/notes/reactions/create", { - noteId: id, - reaction: emoji, - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - /** - * POST /api/notes/reactions/delete - */ - public async deleteEmojiReaction( - id: string, - _emoji: string, - ): Promise> { - await this.client.post<{}>("/api/notes/reactions/delete", { - noteId: id, - }); - return this.client - .post("/api/notes/show", { - noteId: id, - }) - .then(async (res) => ({ - ...res, - data: await this.noteWithDetails( - res.data, - this.baseUrlToHost(this.baseUrl), - this.getFreshAccountCache(), - ), - })); - } - - public async getEmojiReactions( - id: string, - ): Promise>> { - return this.client - .post>("/api/notes/reactions", { - noteId: id, - }) - .then((res) => ({ - ...res, - data: this.converter.reactions(res.data), - })); - } - - public async getEmojiReaction( - _id: string, - _emoji: string, - ): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError("misskey does not support"); - reject(err); - }); - } - - public userSocket(): WebSocketInterface { - return this.client.socket("user"); - } - - public publicSocket(): WebSocketInterface { - return this.client.socket("globalTimeline"); - } - - public localSocket(): WebSocketInterface { - return this.client.socket("localTimeline"); - } - - public tagSocket(_tag: string): WebSocketInterface { - throw new NoImplementedError("TODO: implement"); - } - - public listSocket(list_id: string): WebSocketInterface { - return this.client.socket("list", list_id); - } - - public directSocket(): WebSocketInterface { - return this.client.socket("conversation"); - } + return this.client.post('/api/app/create', params).then((res: Response) => { + const appData: OAuth.AppDataFromServer = { + id: res.data.id, + name: res.data.name, + website: null, + redirect_uri: res.data.callbackUrl, + client_id: '', + client_secret: res.data.secret + } + return OAuth.AppData.from(appData) + }) + } + + /** + * POST /api/auth/session/generate + */ + public async generateAuthUrlAndToken(clientSecret: string): Promise { + return this.client + .post('/api/auth/session/generate', { + appSecret: clientSecret + }) + .then((res: Response) => res.data) + } + + // ====================================== + // apps + // ====================================== + public async verifyAppCredentials(): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // apps/oauth + // ====================================== + /** + * POST /api/auth/session/userkey + * + * @param _client_id This parameter is not used in this method. + * @param client_secret Application secret key which will be provided in createApp. + * @param session_token Session token string which will be provided in generateAuthUrlAndToken. + * @param _redirect_uri This parameter is not used in this method. + */ + public async fetchAccessToken( + _client_id: string | null, + client_secret: string, + session_token: string, + _redirect_uri?: string + ): Promise { + return this.client + .post('/api/auth/session/userkey', { + appSecret: client_secret, + token: session_token + }) + .then(res => { + const token = new OAuth.TokenData(res.data.accessToken, 'misskey', '', 0, null, null) + return token + }) + } + + public async refreshToken(_client_id: string, _client_secret: string, _refresh_token: string): Promise { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async revokeToken(_client_id: string, _client_secret: string, _token: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // accounts + // ====================================== + public async registerAccount( + _username: string, + _email: string, + _password: string, + _agreement: boolean, + _locale: string, + _reason?: string | null + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/i + */ + public async verifyAccountCredentials(): Promise> { + return this.client.post('/api/i').then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.userDetail(res.data) + }) + }) + } + + /** + * POST /api/i/update + */ + public async updateCredentials(options?: { + discoverable?: boolean + bot?: boolean + display_name?: string + note?: string + avatar?: string + header?: string + locked?: boolean + source?: { + privacy?: string + sensitive?: boolean + language?: string + } | null + fields_attributes?: Array<{ name: string; value: string }> + }): Promise> { + let params = {} + if (options) { + if (options.bot !== undefined) { + params = Object.assign(params, { + isBot: options.bot + }) + } + if (options.display_name) { + params = Object.assign(params, { + name: options.display_name + }) + } + if (options.note) { + params = Object.assign(params, { + description: options.note + }) + } + if (options.locked !== undefined) { + params = Object.assign(params, { + isLocked: options.locked + }) + } + if (options.source) { + if (options.source.language) { + params = Object.assign(params, { + lang: options.source.language + }) + } + if (options.source.sensitive) { + params = Object.assign(params, { + alwaysMarkNsfw: options.source.sensitive + }) + } + } + } + return this.client.post('/api/i', params).then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.userDetail(res.data) + }) + }) + } + + /** + * POST /api/users/show + */ + public async getAccount(id: string): Promise> { + return this.client + .post('/api/users/show', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.userDetail(res.data) + }) + }) + } + + /** + * POST /api/users/notes + */ + public async getAccountStatuses( + id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + pinned?: boolean + exclude_replies: boolean + exclude_reblogs: boolean + only_media?: boolean + } + ): Promise>> { + if (options && options.pinned) { + return this.client + .post('/api/users/show', { + userId: id + }) + .then(res => { + if (res.data.pinnedNotes) { + return { ...res, data: res.data.pinnedNotes.map(n => MisskeyAPI.Converter.note(n)) } + } + return { ...res, data: [] } + }) + } + + let params = { + userId: id + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + if (options.exclude_replies) { + params = Object.assign(params, { + includeReplies: false + }) + } + if (options.exclude_reblogs) { + params = Object.assign(params, { + includeMyRenotes: false + }) + } + if (options.only_media) { + params = Object.assign(params, { + withFiles: options.only_media + }) + } + } + return this.client.post>('/api/users/notes', params).then(res => { + const statuses: Array = res.data.map(note => MisskeyAPI.Converter.note(note)) + return Object.assign(res, { + data: statuses + }) + }) + } + + public async getAccountFavourites( + _id: string, + _options?: { + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async subscribeAccount(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async unsubscribeAccount(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/users/followers + */ + public async getAccountFollowers( + id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> { + let params = { + userId: id + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + } + return this.client.post>('/api/users/followers', params).then(res => { + return Object.assign(res, { + data: res.data.map(f => MisskeyAPI.Converter.follower(f)) + }) + }) + } + + /** + * POST /api/users/following + */ + public async getAccountFollowing( + id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> { + let params = { + userId: id + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + } + return this.client.post>('/api/users/following', params).then(res => { + return Object.assign(res, { + data: res.data.map(f => MisskeyAPI.Converter.following(f)) + }) + }) + } + + public async getAccountLists(_id: string): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async getIdentityProof(_id: string): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/following/create + */ + public async followAccount(id: string, _options?: { reblog?: boolean }): Promise> { + await this.client.post<{}>('/api/following/create', { + userId: id + }) + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + /** + * POST /api/following/delete + */ + public async unfollowAccount(id: string): Promise> { + await this.client.post<{}>('/api/following/delete', { + userId: id + }) + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + /** + * POST /api/blocking/create + */ + public async blockAccount(id: string): Promise> { + await this.client.post<{}>('/api/blocking/create', { + userId: id + }) + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + /** + * POST /api/blocking/delete + */ + public async unblockAccount(id: string): Promise> { + await this.client.post<{}>('/api/blocking/delete', { + userId: id + }) + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + /** + * POST /api/mute/create + */ + public async muteAccount(id: string, _notifications: boolean): Promise> { + await this.client.post<{}>('/api/mute/create', { + userId: id + }) + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + /** + * POST /api/mute/delete + */ + public async unmuteAccount(id: string): Promise> { + await this.client.post<{}>('/api/mute/delete', { + userId: id + }) + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + public async pinAccount(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async unpinAccount(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/users/relation + * + * @param id The accountID, for example `'1sdfag'` + */ + public async getRelationship(id: string): Promise> { + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + /** + * POST /api/users/relation + * + * @param id Array of account ID, for example `['1sdfag', 'ds12aa']`. + */ + public async getRelationships(ids: Array): Promise>> { + return Promise.all(ids.map(id => this.getRelationship(id))).then(results => ({ + ...results[0], + data: results.map(r => r.data) + })) + } + + /** + * POST /api/users/search + */ + public async searchAccount( + q: string, + options?: { + following?: boolean + resolve?: boolean + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> { + let params = { + query: q, + detail: true + } + if (options) { + if (options.resolve !== undefined) { + params = Object.assign(params, { + localOnly: options.resolve + }) + } + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + } + return this.client.post>('/api/users/search', params).then(res => { + return Object.assign(res, { + data: res.data.map(u => MisskeyAPI.Converter.userDetail(u)) + }) + }) + } + + // ====================================== + // accounts/bookmarks + // ====================================== + 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) + }) + } + + // ====================================== + // accounts/favourites + // ====================================== + /** + * POST /api/i/favorites + */ + 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)) + }) + }) + } + + // ====================================== + // accounts/mutes + // ====================================== + /** + * POST /api/mute/list + */ + public async getMutes(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { + let params = {} + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + 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/mute/list', params).then(res => { + return Object.assign(res, { + data: res.data.map(mute => MisskeyAPI.Converter.userDetail(mute.mutee)) + }) + }) + } + + // ====================================== + // accounts/blocks + // ====================================== + /** + * POST /api/blocking/list + */ + public async getBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { + let params = {} + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + 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/blocking/list', params).then(res => { + return Object.assign(res, { + data: res.data.map(blocking => MisskeyAPI.Converter.userDetail(blocking.blockee)) + }) + }) + } + + // ====================================== + // accounts/domain_blocks + // ====================================== + public async getDomainBlocks(_options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async blockDomain(_domain: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async unblockDomain(_domain: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // accounts/filters + // ====================================== + public async getFilters(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async getFilter(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async createFilter( + _phrase: string, + _context: Array, + _options?: { + irreversible?: boolean + whole_word?: boolean + expires_in?: string + } + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async updateFilter( + _id: string, + _phrase: string, + _context: Array, + _options?: { + irreversible?: boolean + whole_word?: boolean + expires_in?: string + } + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async deleteFilter(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // accounts/reports + // ====================================== + /** + * POST /api/users/report-abuse + */ + public async report( + account_id: string, + options: { + status_ids?: Array + comment: string + forward?: boolean + category: Entity.Category + rule_ids?: Array + } + ): Promise> { + const category: Entity.Category = 'other' + return this.client + .post<{}>('/api/users/report-abuse', { + userId: account_id, + comment: options.comment + }) + .then(res => { + return Object.assign(res, { + data: { + id: '', + action_taken: false, + action_taken_at: null, + comment: options.comment, + category: category, + forwarded: false, + status_ids: null, + rule_ids: null + } + }) + }) + } + + // ====================================== + // accounts/follow_requests + // ====================================== + /** + * POST /api/following/requests/list + */ + public async getFollowRequests(_limit?: number): Promise>> { + return this.client.post>('/api/following/requests/list').then(res => { + return Object.assign(res, { + data: res.data.map(r => MisskeyAPI.Converter.user(r.follower)) + }) + }) + } + + /** + * POST /api/following/requests/accept + */ + public async acceptFollowRequest(id: string): Promise> { + await this.client.post<{}>('/api/following/requests/accept', { + userId: id + }) + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + /** + * POST /api/following/requests/reject + */ + public async rejectFollowRequest(id: string): Promise> { + await this.client.post<{}>('/api/following/requests/reject', { + userId: id + }) + return this.client + .post('/api/users/relation', { + userId: id + }) + .then(res => { + return Object.assign(res, { + data: MisskeyAPI.Converter.relation(res.data) + }) + }) + } + + // ====================================== + // accounts/endorsements + // ====================================== + public async getEndorsements(_options?: { + limit?: number + max_id?: string + since_id?: string + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // accounts/featured_tags + // ====================================== + public async getFeaturedTags(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async createFeaturedTag(_name: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async deleteFeaturedTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async getSuggestedTags(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // accounts/preferences + // ====================================== + public async getPreferences(): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // accounts/followed_tags + // ====================================== + public async getFollowedTags(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // accounts/suggestions + // ====================================== + /** + * POST /api/users/recommendation + */ + public async getSuggestions(limit?: number): Promise>> { + let params = {} + if (limit) { + params = Object.assign(params, { + limit: limit + }) + } + return this.client + .post>('/api/users/recommendation', params) + .then(res => ({ ...res, data: res.data.map(u => MisskeyAPI.Converter.userDetail(u)) })) + } + + // ====================================== + // accounts/tags + // ====================================== + public async getTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async followTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async unfollowTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // statuses + // ====================================== + public async postStatus( + status: string, + options?: { + media_ids?: Array + poll?: { options: Array; expires_in: number; multiple?: boolean; hide_totals?: boolean } + in_reply_to_id?: string + sensitive?: boolean + spoiler_text?: string + visibility?: 'public' | 'unlisted' | 'private' | 'direct' + scheduled_at?: string + language?: string + quote_id?: string + } + ): Promise> { + let params = { + text: status + } + if (options) { + if (options.media_ids) { + params = Object.assign(params, { + fileIds: options.media_ids + }) + } + if (options.poll) { + let pollParam = { + choices: options.poll.options, + expiresAt: null, + expiredAfter: options.poll.expires_in + } + if (options.poll.multiple !== undefined) { + pollParam = Object.assign(pollParam, { + multiple: options.poll.multiple + }) + } + params = Object.assign(params, { + poll: pollParam + }) + } + if (options.in_reply_to_id) { + params = Object.assign(params, { + replyId: options.in_reply_to_id + }) + } + if (options.sensitive) { + params = Object.assign(params, { + cw: '' + }) + } + if (options.spoiler_text) { + params = Object.assign(params, { + cw: options.spoiler_text + }) + } + if (options.visibility) { + params = Object.assign(params, { + visibility: MisskeyAPI.Converter.encodeVisibility(options.visibility) + }) + } + if (options.quote_id) { + params = Object.assign(params, { + renoteId: options.quote_id + }) + } + } + return this.client + .post('/api/notes/create', params) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data.createdNote) })) + } + + /** + * POST /api/notes/show + */ + public async getStatus(id: string): Promise> { + return this.client + .post('/api/notes/show', { + noteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data) })) + } + + public async editStatus( + _id: string, + _options: { + status?: string + spoiler_text?: string + sensitive?: boolean + media_ids?: Array + poll?: { options?: Array; expires_in?: number; multiple?: boolean; hide_totals?: boolean } + } + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/notes/delete + */ + public async deleteStatus(id: string): Promise> { + return this.client.post<{}>('/api/notes/delete', { + noteId: id + }) + } + + /** + * POST /api/notes/children + */ + public async getStatusContext( + id: string, + options?: { limit?: number; max_id?: string; since_id?: string } + ): Promise> { + let params = { + noteId: id + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + } + return this.client.post>('/api/notes/children', params).then(res => { + const context: Entity.Context = { + ancestors: [], + descendants: res.data.map(n => MisskeyAPI.Converter.note(n)) + } + return { + ...res, + data: context + } + }) + } + + public async getStatusSource(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/notes/renotes + */ + public async getStatusRebloggedBy(id: string): Promise>> { + return this.client + .post>('/api/notes/renotes', { + noteId: id + }) + .then(res => ({ + ...res, + data: res.data.map(n => MisskeyAPI.Converter.user(n.user)) + })) + } + + public async getStatusFavouritedBy(_id: string): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/notes/favorites/create + */ + public async favouriteStatus(id: string): Promise> { + await this.client.post<{}>('/api/notes/favorites/create', { + noteId: id + }) + return this.client + .post('/api/notes/show', { + noteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data) })) + } + + /** + * POST /api/notes/favorites/delete + */ + public async unfavouriteStatus(id: string): Promise> { + await this.client.post<{}>('/api/notes/favorites/delete', { + noteId: id + }) + return this.client + .post('/api/notes/show', { + noteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data) })) + } + + /** + * POST /api/notes/create + */ + public async reblogStatus(id: string): Promise> { + return this.client + .post('/api/notes/create', { + renoteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data.createdNote) })) + } + + /** + * POST /api/notes/unrenote + */ + public async unreblogStatus(id: string): Promise> { + await this.client.post<{}>('/api/notes/unrenote', { + noteId: id + }) + return this.client + .post('/api/notes/show', { + noteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data) })) + } + + public async bookmarkStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async unbookmarkStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async muteStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async unmuteStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/i/pin + */ + public async pinStatus(id: string): Promise> { + await this.client.post<{}>('/api/i/pin', { + noteId: id + }) + return this.client + .post('/api/notes/show', { + noteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data) })) + } + + /** + * POST /api/i/unpin + */ + public async unpinStatus(id: string): Promise> { + await this.client.post<{}>('/api/i/unpin', { + noteId: id + }) + return this.client + .post('/api/notes/show', { + noteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data) })) + } + + // ====================================== + // statuses/media + // ====================================== + /** + * POST /api/drive/files/create + */ + public async uploadMedia(file: any, _options?: { description?: string; focus?: string }): Promise> { + const formData = new FormData() + formData.append('file', file) + let headers: { [key: string]: string } = {} + if (typeof formData.getHeaders === 'function') { + headers = formData.getHeaders() + } + return this.client + .post('/api/drive/files/create', formData, headers) + .then(res => ({ ...res, data: MisskeyAPI.Converter.file(res.data) })) + } + + public async getMedia(id: string): Promise> { + const res = await this.client.post('/api/drive/files/show', { fileId: id }) + return { ...res, data: MisskeyAPI.Converter.file(res.data) } + } + + /** + * POST /api/drive/files/update + */ + public async updateMedia( + id: string, + options?: { + file?: any + description?: string + focus?: string + is_sensitive?: boolean + } + ): Promise> { + let params = { + fileId: id + } + if (options) { + if (options.is_sensitive !== undefined) { + params = Object.assign(params, { + isSensitive: options.is_sensitive + }) + } + } + return this.client + .post('/api/drive/files/update', params) + .then(res => ({ ...res, data: MisskeyAPI.Converter.file(res.data) })) + } + + // ====================================== + // statuses/polls + // ====================================== + public async getPoll(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/notes/polls/vote + */ + public async votePoll(_id: string, choices: Array, status_id?: string | null): Promise> { + if (!status_id) { + return new Promise((_, reject) => { + const err = new ArgumentError('status_id is required') + reject(err) + }) + } + const params = { + noteId: status_id, + choice: choices[0] + } + await this.client.post<{}>('/api/notes/polls/vote', params) + const res = await this.client + .post('/api/notes/show', { + noteId: status_id + }) + .then(res => { + const note = MisskeyAPI.Converter.note(res.data) + return { ...res, data: note.poll } + }) + if (!res.data) { + return new Promise((_, reject) => { + const err = new UnexpectedError('poll does not exist') + reject(err) + }) + } + return { ...res, data: res.data } + } + + // ====================================== + // statuses/scheduled_statuses + // ====================================== + public async getScheduledStatuses(_options?: { + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async getScheduledStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async scheduleStatus(_id: string, _scheduled_at?: string | null): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async cancelScheduledStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // timelines + // ====================================== + /** + * POST /api/notes/global-timeline + */ + public async getPublicTimeline(options?: { + only_media?: boolean + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> { + let params = {} + if (options) { + if (options.only_media !== undefined) { + params = Object.assign(params, { + withFiles: options.only_media + }) + } + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + } + return this.client + .post>('/api/notes/global-timeline', params) + .then(res => ({ ...res, data: res.data.map(n => MisskeyAPI.Converter.note(n)) })) + } + + /** + * POST /api/notes/local-timeline + */ + public async getLocalTimeline(options?: { + only_media?: boolean + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> { + let params = {} + if (options) { + if (options.only_media !== undefined) { + params = Object.assign(params, { + withFiles: options.only_media + }) + } + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + } + return this.client + .post>('/api/notes/local-timeline', params) + .then(res => ({ ...res, data: res.data.map(n => MisskeyAPI.Converter.note(n)) })) + } + + /** + * POST /api/notes/search-by-tag + */ + public async getTagTimeline( + hashtag: string, + options?: { + local?: boolean + only_media?: boolean + limit?: number + max_id?: string + since_id?: string + min_id?: string + } + ): Promise>> { + let params = { + tag: hashtag + } + if (options) { + if (options.only_media !== undefined) { + params = Object.assign(params, { + withFiles: options.only_media + }) + } + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + } + return this.client + .post>('/api/notes/search-by-tag', params) + .then(res => ({ ...res, data: res.data.map(n => MisskeyAPI.Converter.note(n)) })) + } + + /** + * POST /api/notes/timeline + */ + public async getHomeTimeline(options?: { + local?: boolean + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> { + let params = { + withFiles: false + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + } + return this.client + .post>('/api/notes/timeline', params) + .then(res => ({ ...res, data: res.data.map(n => MisskeyAPI.Converter.note(n)) })) + } + + /** + * POST /api/notes/user-list-timeline + */ + public async getListTimeline( + list_id: string, + options?: { + limit?: number + max_id?: string + since_id?: string + min_id?: string + } + ): Promise>> { + let params = { + listId: list_id, + withFiles: false + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + } + return this.client + .post>('/api/notes/user-list-timeline', params) + .then(res => ({ ...res, data: res.data.map(n => MisskeyAPI.Converter.note(n)) })) + } + + // ====================================== + // timelines/conversations + // ====================================== + /** + * POST /api/notes/mentions + */ + public async getConversationTimeline(options?: { + limit?: number + max_id?: string + since_id?: string + min_id?: string + }): Promise>> { + let params = { + visibility: 'specified' + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + } + return this.client + .post>('/api/notes/mentions', params) + .then(res => ({ ...res, data: res.data.map(n => MisskeyAPI.Converter.noteToConversation(n)) })) + } + + public async deleteConversation(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async readConversation(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // timelines/lists + // ====================================== + /** + * POST /api/users/lists/list + */ + public async getLists(): Promise>> { + return this.client + .post>('/api/users/lists/list') + .then(res => ({ ...res, data: res.data.map(l => MisskeyAPI.Converter.list(l)) })) + } + + /** + * POST /api/users/lists/show + */ + public async getList(id: string): Promise> { + return this.client + .post('/api/users/lists/show', { + listId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.list(res.data) })) + } + + /** + * POST /api/users/lists/create + */ + public async createList(title: string): Promise> { + return this.client + .post('/api/users/lists/create', { + name: title + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.list(res.data) })) + } + + /** + * POST /api/users/lists/update + */ + public async updateList(id: string, title: string): Promise> { + return this.client + .post('/api/users/lists/update', { + listId: id, + name: title + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.list(res.data) })) + } + + /** + * POST /api/users/lists/delete + */ + public async deleteList(id: string): Promise> { + return this.client.post<{}>('/api/users/lists/delete', { + listId: id + }) + } + + /** + * POST /api/users/lists/show + */ + public async getAccountsInList( + id: string, + _options?: { + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> { + const res = await this.client.post('/api/users/lists/show', { + listId: id + }) + const promise = res.data.userIds.map(userId => this.getAccount(userId)) + const accounts = await Promise.all(promise) + return { ...res, data: accounts.map(r => r.data) } + } + + /** + * POST /api/users/lists/push + */ + public async addAccountsToList(id: string, account_ids: Array): Promise> { + return this.client.post<{}>('/api/users/lists/push', { + listId: id, + userId: account_ids[0] + }) + } + + /** + * POST /api/users/lists/pull + */ + public async deleteAccountsFromList(id: string, account_ids: Array): Promise> { + return this.client.post<{}>('/api/users/lists/pull', { + listId: id, + userId: account_ids[0] + }) + } + + // ====================================== + // timelines/markers + // ====================================== + public async getMarkers(_timeline: Array): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async saveMarkers(_options?: { + home?: { last_read_id: string } + notifications?: { last_read_id: string } + }): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // notifications + // ====================================== + /** + * POST /api/i/notifications + */ + public async getNotifications(options?: { + limit?: number + max_id?: string + since_id?: string + min_id?: string + exclude_type?: Array + account_id?: string + }): Promise>> { + let params = {} + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.since_id) { + params = Object.assign(params, { + sinceId: options.since_id + }) + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + if (options.exclude_type) { + params = Object.assign(params, { + excludeType: options.exclude_type.map(e => MisskeyAPI.Converter.encodeNotificationType(e)) + }) + } + } + const res = await this.client.post>('/api/i/notifications', params) + const notifications: Array = res.data.flatMap(n => { + const notify = MisskeyAPI.Converter.notification(n) + if (notify instanceof UnknownNotificationTypeError) { + return [] + } + return notify + }) + + return { ...res, data: notifications } + } + + public async getNotification(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * POST /api/notifications/mark-all-as-read + */ + public async dismissNotifications(): Promise> { + return this.client.post<{}>('/api/notifications/mark-all-as-read') + } + + public async dismissNotification(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async readNotifications(_options: { + id?: string + max_id?: string + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('mastodon does not support') + reject(err) + }) + } + + // ====================================== + // notifications/push + // ====================================== + public async subscribePushNotification( + _subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, + _data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean } } | null + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async getPushSubscription(): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async updatePushSubscription( + _data?: { alerts: { follow?: boolean; favourite?: boolean; reblog?: boolean; mention?: boolean; poll?: boolean } } | null + ): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + /** + * DELETE /api/v1/push/subscription + */ + public async deletePushSubscription(): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // search + // ====================================== + public async search( + q: string, + 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> { + switch (options.type) { + case 'accounts': { + let params = { + query: q + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.offset) { + params = Object.assign(params, { + offset: options.offset + }) + } + if (options.resolve) { + params = Object.assign(params, { + localOnly: options.resolve + }) + } + } + return this.client.post>('/api/users/search', params).then(res => ({ + ...res, + data: { + accounts: res.data.map(u => MisskeyAPI.Converter.userDetail(u)), + statuses: [], + hashtags: [] + } + })) + } + case 'statuses': { + let params = { + query: q + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.offset) { + params = Object.assign(params, { + offset: options.offset + }) + } + if (options.max_id) { + params = Object.assign(params, { + untilId: options.max_id + }) + } + if (options.min_id) { + params = Object.assign(params, { + sinceId: options.min_id + }) + } + if (options.account_id) { + params = Object.assign(params, { + userId: options.account_id + }) + } + } + return this.client.post>('/api/notes/search', params).then(res => ({ + ...res, + data: { + accounts: [], + statuses: res.data.map(n => MisskeyAPI.Converter.note(n)), + hashtags: [] + } + })) + } + case 'hashtags': { + let params = { + query: q + } + if (options) { + if (options.limit) { + params = Object.assign(params, { + limit: options.limit + }) + } + if (options.offset) { + params = Object.assign(params, { + offset: options.offset + }) + } + } + return this.client.post>('/api/hashtags/search', params).then(res => ({ + ...res, + data: { + accounts: [], + statuses: [], + hashtags: res.data.map(h => ({ name: h, url: h, history: [], following: false })) + } + })) + } + } + } + + // ====================================== + // instance + // ====================================== + /** + * POST /api/meta + * POST /api/stats + */ + public async getInstance(): Promise> { + const meta = await this.client + .post('/api/meta', { detail: true }) + .then(res => res.data) + return this.client + .post('/api/stats') + .then(res => ({ ...res, data: MisskeyAPI.Converter.meta(meta, res.data) })) + } + + public async getInstancePeers(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async getInstanceActivity(): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // instance/trends + // ====================================== + /** + * POST /api/hashtags/trend + */ + public async getInstanceTrends(_limit?: number | null): Promise>> { + return this.client + .post>('/api/hashtags/trend') + .then(res => ({ ...res, data: res.data.map(h => MisskeyAPI.Converter.hashtag(h)) })) + } + + // ====================================== + // instance/directory + // ====================================== + public async getInstanceDirectory(_options?: { + limit?: number + offset?: number + order?: 'active' | 'new' + local?: boolean + }): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // instance/custom_emojis + // ====================================== + /** + * GET /api/emojis + */ + public async getInstanceCustomEmojis(): Promise>> { + return this.client + .get<{ emojis: Array }>('/api/emojis') + .then(res => ({ ...res, data: res.data.emojis.map(e => MisskeyAPI.Converter.emoji(e)) })) + } + + // ====================================== + // instance/announcements + // ====================================== + /** + * GET /api/announcements + * + * @return Array of announcements. + */ + public async getInstanceAnnouncements(): Promise>> { + return this.client + .post>('/api/announcements') + .then(res => ({ ...res, data: res.data.map(a => MisskeyAPI.Converter.announcement(a)) })) + } + + public async dismissInstanceAnnouncement(_id: string): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async addReactionToAnnouncement(_id: string, _name: string): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public async removeReactionFromAnnouncement(_id: string, _name: string): Promise>> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + // ====================================== + // Emoji reactions + // ====================================== + /** + * POST /api/notes/reactions/create + * + * @param {string} id Target note ID. + * @param {string} emoji Reaction emoji string. This string is raw unicode emoji. + */ + public async createEmojiReaction(id: string, emoji: string): Promise> { + await this.client.post<{}>('/api/notes/reactions/create', { + noteId: id, + reaction: emoji + }) + return this.client + .post('/api/notes/show', { + noteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data) })) + } + + /** + * POST /api/notes/reactions/delete + */ + public async deleteEmojiReaction(id: string, _emoji: string): Promise> { + await this.client.post<{}>('/api/notes/reactions/delete', { + noteId: id + }) + return this.client + .post('/api/notes/show', { + noteId: id + }) + .then(res => ({ ...res, data: MisskeyAPI.Converter.note(res.data) })) + } + + public async getEmojiReactions(id: string): Promise>> { + return this.client + .post>('/api/notes/reactions', { + noteId: id + }) + .then(res => ({ + ...res, + data: MisskeyAPI.Converter.reactions(res.data) + })) + } + + public async getEmojiReaction(_id: string, _emoji: string): Promise> { + return new Promise((_, reject) => { + const err = new NoImplementedError('misskey does not support') + reject(err) + }) + } + + public userSocket(): WebSocketInterface { + return this.client.socket('user') + } + + public publicSocket(): WebSocketInterface { + return this.client.socket('globalTimeline') + } + + public localSocket(): WebSocketInterface { + return this.client.socket('localTimeline') + } + + public tagSocket(_tag: string): WebSocketInterface { + throw new NoImplementedError('TODO: implement') + } + + public listSocket(list_id: string): WebSocketInterface { + return this.client.socket('list', list_id) + } + + public directSocket(): WebSocketInterface { + return this.client.socket('conversation') + } } diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index e4dd140459..4883e7a8ce 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -1,727 +1,629 @@ -import axios, { AxiosResponse, AxiosRequestConfig } from "axios"; -import dayjs from "dayjs"; -import FormData from "form-data"; +import axios, { AxiosResponse, AxiosRequestConfig } from 'axios' +import dayjs from 'dayjs' +import FormData from 'form-data' -import { DEFAULT_UA } from "../default"; -import proxyAgent, { ProxyConfig } from "../proxy_config"; -import Response from "../response"; -import MisskeyEntity from "./entity"; -import MegalodonEntity from "../entity"; -import WebSocket from "./web_socket"; -import MisskeyNotificationType from "./notification"; -import NotificationType from "../notification"; +import { DEFAULT_UA } from '../default' +import proxyAgent, { ProxyConfig } from '../proxy_config' +import Response from '../response' +import MisskeyEntity from './entity' +import MegalodonEntity from '../entity' +import WebSocket from './web_socket' +import MisskeyNotificationType from './notification' +import NotificationType, { UnknownNotificationTypeError } from '../notification' namespace MisskeyAPI { - export namespace Entity { - export type App = MisskeyEntity.App; - export type Announcement = MisskeyEntity.Announcement; - export type Blocking = MisskeyEntity.Blocking; - export type Choice = MisskeyEntity.Choice; - export type CreatedNote = MisskeyEntity.CreatedNote; - export type Emoji = MisskeyEntity.Emoji; - export type Favorite = MisskeyEntity.Favorite; - export type Field = MisskeyEntity.Field; - export type File = MisskeyEntity.File; - export type Follower = MisskeyEntity.Follower; - export type Following = MisskeyEntity.Following; - export type FollowRequest = MisskeyEntity.FollowRequest; - export type Hashtag = MisskeyEntity.Hashtag; - export type List = MisskeyEntity.List; - export type Meta = MisskeyEntity.Meta; - export type Mute = MisskeyEntity.Mute; - export type Note = MisskeyEntity.Note; - export type Notification = MisskeyEntity.Notification; - export type Poll = MisskeyEntity.Poll; - export type Reaction = MisskeyEntity.Reaction; - export type Relation = MisskeyEntity.Relation; - export type User = MisskeyEntity.User; - export type UserDetail = MisskeyEntity.UserDetail; - export type UserDetailMe = MisskeyEntity.UserDetailMe; - export type GetAll = MisskeyEntity.GetAll; - export type UserKey = MisskeyEntity.UserKey; - export type Session = MisskeyEntity.Session; - export type Stats = MisskeyEntity.Stats; - export type State = MisskeyEntity.State; - export type APIEmoji = { emojis: Emoji[] }; - } + export namespace Entity { + export type Announcement = MisskeyEntity.Announcement + export type App = MisskeyEntity.App + export type Blocking = MisskeyEntity.Blocking + export type Choice = MisskeyEntity.Choice + export type CreatedNote = MisskeyEntity.CreatedNote + export type Emoji = MisskeyEntity.Emoji + export type Favorite = MisskeyEntity.Favorite + export type File = MisskeyEntity.File + export type Follower = MisskeyEntity.Follower + export type Following = MisskeyEntity.Following + export type FollowRequest = MisskeyEntity.FollowRequest + export type Hashtag = MisskeyEntity.Hashtag + export type List = MisskeyEntity.List + export type Meta = MisskeyEntity.Meta + export type Mute = MisskeyEntity.Mute + export type Note = MisskeyEntity.Note + export type Notification = MisskeyEntity.Notification + export type Poll = MisskeyEntity.Poll + export type Reaction = MisskeyEntity.Reaction + export type Relation = MisskeyEntity.Relation + export type User = MisskeyEntity.User + export type UserDetail = MisskeyEntity.UserDetail + export type UserKey = MisskeyEntity.UserKey + export type Session = MisskeyEntity.Session + export type Stats = MisskeyEntity.Stats + } - export class Converter { - private baseUrl: string; - private instanceHost: string; - private plcUrl: string; - private modelOfAcct = { - id: "1", - username: "none", - acct: "none", - display_name: "none", - locked: true, - bot: true, - discoverable: false, - group: false, - created_at: "1971-01-01T00:00:00.000Z", - note: "", - url: "plc", - avatar: "plc", - avatar_static: "plc", - header: "plc", - header_static: "plc", - followers_count: -1, - following_count: 0, - statuses_count: 0, - last_status_at: "1971-01-01T00:00:00.000Z", - noindex: true, - emojis: [], - fields: [], - moved: null, - }; + export namespace Converter { + export const announcement = (a: Entity.Announcement): MegalodonEntity.Announcement => ({ + id: a.id, + content: a.title + '\n' + a.text, + starts_at: null, + ends_at: null, + published: true, + all_day: true, + published_at: a.createdAt, + updated_at: a.updatedAt, + read: a.isRead !== undefined ? a.isRead : null, + mentions: [], + statuses: [], + tags: [], + emojis: [], + reactions: [] + }) - constructor(baseUrl: string) { - this.baseUrl = baseUrl; - this.instanceHost = baseUrl.substring(baseUrl.indexOf("//") + 2); - this.plcUrl = `${baseUrl}/static-assets/transparent.png`; - this.modelOfAcct.url = this.plcUrl; - this.modelOfAcct.avatar = this.plcUrl; - this.modelOfAcct.avatar_static = this.plcUrl; - this.modelOfAcct.header = this.plcUrl; - this.modelOfAcct.header_static = this.plcUrl; - } + export const emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => { + return { + shortcode: e.name, + static_url: e.url, + url: e.url, + visible_in_picker: true, + category: e.category + } + } - // FIXME: Properly render MFM instead of just escaping HTML characters. - escapeMFM = (text: string): string => - text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/`/g, "`") - .replace(/\r?\n/g, "
"); + export const user = (u: Entity.User): MegalodonEntity.Account => { + let acct = u.username + if (u.host) { + acct = `${u.username}@${u.host}` + } + return { + id: u.id, + username: u.username, + acct: acct, + display_name: u.name, + locked: false, + group: null, + noindex: null, + suspended: null, + limited: null, + created_at: '', + followers_count: 0, + following_count: 0, + statuses_count: 0, + note: '', + url: acct, + avatar: u.avatarUrl, + avatar_static: u.avatarColor, + header: '', + header_static: '', + emojis: mapEmojis(u.emojis), + moved: null, + fields: [], + bot: null + } + } - emoji = (e: Entity.Emoji): MegalodonEntity.Emoji => { - return { - shortcode: e.name, - static_url: e.url, - url: e.url, - visible_in_picker: true, - category: e.category, - }; - }; + export const userDetail = (u: Entity.UserDetail): MegalodonEntity.Account => { + let acct = u.username + if (u.host) { + acct = `${u.username}@${u.host}` + } + return { + id: u.id, + username: u.username, + acct: acct, + display_name: u.name, + locked: u.isLocked, + group: null, + noindex: null, + suspended: null, + limited: null, + created_at: u.createdAt, + followers_count: u.followersCount, + following_count: u.followingCount, + statuses_count: u.notesCount, + note: u.description, + url: acct, + avatar: u.avatarUrl, + avatar_static: u.avatarColor, + header: u.bannerUrl, + header_static: u.bannerColor, + emojis: mapEmojis(u.emojis), + moved: null, + fields: [], + bot: u.isBot + } + } - field = (f: Entity.Field): MegalodonEntity.Field => ({ - name: f.name, - value: this.escapeMFM(f.value), - verified_at: null, - }); + export const visibility = (v: 'public' | 'home' | 'followers' | 'specified'): 'public' | 'unlisted' | 'private' | 'direct' => { + switch (v) { + case 'public': + return v + case 'home': + return 'unlisted' + case 'followers': + return 'private' + case 'specified': + return 'direct' + } + } - user = (u: Entity.User): MegalodonEntity.Account => { - let acct = u.username; - let acctUrl = `https://${u.host || this.instanceHost}/@${u.username}`; - if (u.host) { - acct = `${u.username}@${u.host}`; - acctUrl = `https://${u.host}/@${u.username}`; - } - return { - id: u.id, - username: u.username, - acct: acct, - display_name: u.name || u.username, - locked: false, - created_at: new Date().toISOString(), - followers_count: 0, - following_count: 0, - statuses_count: 0, - note: "", - url: acctUrl, - avatar: u.avatarUrl, - avatar_static: u.avatarUrl, - header: this.plcUrl, - header_static: this.plcUrl, - emojis: u.emojis && u.emojis.length > 0 ? u.emojis.map((e) => this.emoji(e)) : [], - moved: null, - fields: [], - bot: false, - }; - }; + export const encodeVisibility = (v: 'public' | 'unlisted' | 'private' | 'direct'): 'public' | 'home' | 'followers' | 'specified' => { + switch (v) { + case 'public': + return v + case 'unlisted': + return 'home' + case 'private': + return 'followers' + case 'direct': + return 'specified' + } + } - userDetail = ( - u: Entity.UserDetail, - host: string, - ): MegalodonEntity.Account => { - let acct = u.username; - host = host.replace("https://", ""); - let acctUrl = `https://${host || u.host || this.instanceHost}/@${ - u.username - }`; - if (u.host) { - acct = `${u.username}@${u.host}`; - acctUrl = `https://${u.host}/@${u.username}`; - } - return { - id: u.id, - username: u.username, - acct: acct, - display_name: u.name || u.username, - locked: u.isLocked, - created_at: u.createdAt, - followers_count: u.followersCount, - following_count: u.followingCount, - statuses_count: u.notesCount, - note: u.description?.replace(/\n|\\n/g, "
") ?? "", - url: acctUrl, - avatar: u.avatarUrl, - avatar_static: u.avatarUrl, - header: u.bannerUrl ?? this.plcUrl, - header_static: u.bannerUrl ?? this.plcUrl, - emojis: u.emojis && u.emojis.length > 0 ? u.emojis.map((e) => this.emoji(e)) : [], - moved: null, - fields: u.fields.map((f) => this.field(f)), - bot: u.isBot, - }; - }; + export const fileType = (s: string): 'unknown' | 'image' | 'gifv' | 'video' | 'audio' => { + if (s === 'image/gif') { + return 'gifv' + } + if (s.includes('image')) { + return 'image' + } + if (s.includes('video')) { + return 'video' + } + if (s.includes('audio')) { + return 'audio' + } + return 'unknown' + } - userPreferences = ( - u: MisskeyAPI.Entity.UserDetailMe, - v: "public" | "unlisted" | "private" | "direct", - ): MegalodonEntity.Preferences => { - return { - "reading:expand:media": "default", - "reading:expand:spoilers": false, - "posting:default:language": u.lang, - "posting:default:sensitive": u.alwaysMarkNsfw, - "posting:default:visibility": v, - }; - }; + export const file = (f: Entity.File): MegalodonEntity.Attachment => { + return { + id: f.id, + type: fileType(f.type), + url: f.url, + remote_url: f.url, + preview_url: f.thumbnailUrl, + text_url: f.url, + meta: { + width: f.properties.width, + height: f.properties.height + }, + description: null, + blurhash: null + } + } - visibility = ( - v: "public" | "home" | "followers" | "specified", - ): "public" | "unlisted" | "private" | "direct" => { - switch (v) { - case "public": - return v; - case "home": - return "unlisted"; - case "followers": - return "private"; - case "specified": - return "direct"; - } - }; + export const follower = (f: Entity.Follower): MegalodonEntity.Account => { + return user(f.follower) + } - encodeVisibility = ( - v: "public" | "unlisted" | "private" | "direct", - ): "public" | "home" | "followers" | "specified" => { - switch (v) { - case "public": - return v; - case "unlisted": - return "home"; - case "private": - return "followers"; - case "direct": - return "specified"; - } - }; + export const following = (f: Entity.Following): MegalodonEntity.Account => { + return user(f.followee) + } - fileType = ( - s: string, - ): "unknown" | "image" | "gifv" | "video" | "audio" => { - if (s === "image/gif") { - return "gifv"; - } - if (s.includes("image")) { - return "image"; - } - if (s.includes("video")) { - return "video"; - } - if (s.includes("audio")) { - return "audio"; - } - return "unknown"; - }; + export const relation = (r: Entity.Relation): MegalodonEntity.Relationship => { + return { + id: r.id, + following: r.isFollowing, + followed_by: r.isFollowed, + blocking: r.isBlocking, + blocked_by: r.isBlocked, + muting: r.isMuted, + muting_notifications: false, + requested: r.hasPendingFollowRequestFromYou, + domain_blocking: false, + showing_reblogs: true, + endorsed: false, + notifying: false, + note: null + } + } - file = (f: Entity.File): MegalodonEntity.Attachment => { - return { - id: f.id, - type: this.fileType(f.type), - url: f.url, - remote_url: f.url, - preview_url: f.thumbnailUrl, - text_url: f.url, - meta: { - width: f.properties.width, - height: f.properties.height, - }, - description: f.comment, - blurhash: f.blurhash, - }; - }; + export const choice = (c: Entity.Choice): MegalodonEntity.PollOption => { + return { + title: c.text, + votes_count: c.votes + } + } - follower = (f: Entity.Follower): MegalodonEntity.Account => { - return this.user(f.follower); - }; + export const poll = (p: Entity.Poll): MegalodonEntity.Poll => { + const now = dayjs() + const expire = dayjs(p.expiresAt) + const count = p.choices.reduce((sum, choice) => sum + choice.votes, 0) + return { + id: '', + expires_at: p.expiresAt, + expired: now.isAfter(expire), + multiple: p.multiple, + votes_count: count, + options: Array.isArray(p.choices) ? p.choices.map(c => choice(c)) : [], + voted: Array.isArray(p.choices) ? p.choices.some(c => c.isVoted) : false + } + } - following = (f: Entity.Following): MegalodonEntity.Account => { - return this.user(f.followee); - }; + export const note = (n: Entity.Note): MegalodonEntity.Status => { + return { + id: n.id, + uri: n.uri ? n.uri : '', + url: n.uri ? n.uri : '', + account: user(n.user), + in_reply_to_id: n.replyId, + in_reply_to_account_id: null, + reblog: n.renote ? note(n.renote) : null, + content: n.text + ? n.text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/`/g, '`') + .replace(/\r?\n/g, '
') + : '', + plain_content: n.text ? n.text : null, + created_at: n.createdAt, + emojis: mapEmojis(n.emojis).concat(mapReactionEmojis(n.reactionEmojis)), + replies_count: n.repliesCount, + reblogs_count: n.renoteCount, + favourites_count: 0, + reblogged: false, + favourited: false, + muted: false, + sensitive: Array.isArray(n.files) ? n.files.some(f => f.isSensitive) : false, + spoiler_text: n.cw ? n.cw : '', + visibility: visibility(n.visibility), + media_attachments: Array.isArray(n.files) ? n.files.map(f => file(f)) : [], + mentions: [], + tags: [], + card: null, + poll: n.poll ? poll(n.poll) : null, + application: null, + language: null, + pinned: null, + emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.myReaction) : [], + bookmarked: false, + quote: n.renote !== undefined && n.text !== null + } + } - relation = (r: Entity.Relation): MegalodonEntity.Relationship => { - return { - id: r.id, - following: r.isFollowing, - followed_by: r.isFollowed, - blocking: r.isBlocking, - blocked_by: r.isBlocked, - muting: r.isMuted, - muting_notifications: false, - requested: r.hasPendingFollowRequestFromYou, - domain_blocking: false, - showing_reblogs: true, - endorsed: false, - notifying: false, - }; - }; + const mapEmojis = (e: Array | { [key: string]: string }): Array => { + if (Array.isArray(e)) { + return e.map(e => emoji(e)) + } else if (e) { + return mapReactionEmojis(e) + } else { + return [] + } + } - choice = (c: Entity.Choice): MegalodonEntity.PollOption => { - return { - title: c.text, - votes_count: c.votes, - }; - }; + export const mapReactions = (r: { [key: string]: number }, myReaction?: string): Array => { + return Object.keys(r).map(key => { + if (myReaction && key === myReaction) { + return { + count: r[key], + me: true, + name: key + } + } + return { + count: r[key], + me: false, + name: key + } + }) + } - poll = (p: Entity.Poll, id: string): MegalodonEntity.Poll => { - const now = dayjs(); - const expire = dayjs(p.expiresAt); - const count = p.choices.reduce((sum, choice) => sum + choice.votes, 0); - return { - id: id, - expires_at: p.expiresAt, - expired: now.isAfter(expire), - multiple: p.multiple, - votes_count: count, - options: p.choices.map((c) => this.choice(c)), - voted: p.choices.some((c) => c.isVoted), - own_votes: p.choices - .filter((c) => c.isVoted) - .map((c) => p.choices.indexOf(c)), - }; - }; + const mapReactionEmojis = (r: { [key: string]: string }): Array => { + return Object.keys(r).map(key => ({ + shortcode: key, + static_url: r[key], + url: r[key], + visible_in_picker: true, + category: '' + })) + } - note = (n: Entity.Note, host: string): MegalodonEntity.Status => { - host = host.replace("https://", ""); + export const reactions = (r: Array): Array => { + const result: Array = [] + r.map(e => { + const i = result.findIndex(res => res.name === e.type) + if (i >= 0) { + result[i].count++ + } else { + result.push({ + count: 1, + me: false, + name: e.type + }) + } + }) + return result + } - return { - id: n.id, - uri: n.uri ? n.uri : `https://${host}/notes/${n.id}`, - url: n.uri ? n.uri : `https://${host}/notes/${n.id}`, - account: this.user(n.user), - in_reply_to_id: n.replyId, - in_reply_to_account_id: n.reply?.userId ?? null, - reblog: n.renote ? this.note(n.renote, host) : null, - content: n.text ? this.escapeMFM(n.text) : "", - plain_content: n.text ? n.text : null, - created_at: n.createdAt, - // Remove reaction emojis with names containing @ from the emojis list. - emojis: n.emojis && n.emojis.length > 0 ? n.emojis - .filter((e) => e.name.indexOf("@") === -1) - .map((e) => this.emoji(e)) : [], - replies_count: n.repliesCount, - reblogs_count: n.renoteCount, - favourites_count: this.getTotalReactions(n.reactions), - reblogged: false, - favourited: !!n.myReaction, - muted: false, - sensitive: n.files ? n.files.some((f) => f.isSensitive) : false, - spoiler_text: n.cw ? n.cw : "", - visibility: this.visibility(n.visibility), - media_attachments: n.files ? n.files.map((f) => this.file(f)) : [], - mentions: [], - tags: [], - card: null, - poll: n.poll ? this.poll(n.poll, n.id) : null, - application: null, - language: null, - pinned: null, - // Use emojis list to provide URLs for emoji reactions. - reactions: n.emojis && n.emojis.length > 0 ? this.mapReactions(n.emojis, n.reactions, n.myReaction) : [], - bookmarked: false, - quote: n.renote && n.text ? this.note(n.renote, host) : null, - }; - }; + export const noteToConversation = (n: Entity.Note): MegalodonEntity.Conversation => { + const accounts: Array = [user(n.user)] + if (n.reply) { + accounts.push(user(n.reply.user)) + } + return { + id: n.id, + accounts: accounts, + last_status: note(n), + unread: false + } + } - mapReactions = ( - emojis: Array, - r: { [key: string]: number }, - myReaction?: string, - ): Array => { - // Map of emoji shortcodes to image URLs. - const emojiUrls = new Map( - emojis.map((e) => [e.name, e.url]), - ); - return Object.keys(r).map((key) => { - // Strip colons from custom emoji reaction names to match emoji shortcodes. - const shortcode = key.replaceAll(":", ""); - // If this is a custom emoji (vs. a Unicode emoji), find its image URL. - const url = emojiUrls.get(shortcode); - // Finally, remove trailing @. from local custom emoji reaction names. - const name = shortcode.replace("@.", ""); - return { - count: r[key], - me: key === myReaction, - name, - url, - // We don't actually have a static version of the asset, but clients expect one anyway. - static_url: url, - }; - }); - }; + export const list = (l: Entity.List): MegalodonEntity.List => ({ + id: l.id, + title: l.name, + replies_policy: null + }) - getTotalReactions = (r: { [key: string]: number }): number => { - return Object.values(r).length > 0 - ? Object.values(r).reduce( - (previousValue, currentValue) => previousValue + currentValue, - ) - : 0; - }; + export const encodeNotificationType = ( + e: MegalodonEntity.NotificationType + ): MisskeyEntity.NotificationType | UnknownNotificationTypeError => { + switch (e) { + case NotificationType.Follow: + return MisskeyNotificationType.Follow + case NotificationType.Mention: + return MisskeyNotificationType.Reply + case NotificationType.Favourite: + case NotificationType.EmojiReaction: + return MisskeyNotificationType.Reaction + case NotificationType.Reblog: + return MisskeyNotificationType.Renote + case NotificationType.PollVote: + return MisskeyNotificationType.PollVote + case NotificationType.FollowRequest: + return MisskeyNotificationType.ReceiveFollowRequest + default: + return new UnknownNotificationTypeError() + } + } - reactions = ( - r: Array, - ): Array => { - const result: Array = []; - for (const e of r) { - const i = result.findIndex((res) => res.name === e.type); - if (i >= 0) { - result[i].count++; - } else { - result.push({ - count: 1, - me: false, - name: e.type, - }); - } - } - return result; - }; + export const decodeNotificationType = ( + e: MisskeyEntity.NotificationType + ): MegalodonEntity.NotificationType | UnknownNotificationTypeError => { + switch (e) { + case MisskeyNotificationType.Follow: + return NotificationType.Follow + case MisskeyNotificationType.Mention: + case MisskeyNotificationType.Reply: + return NotificationType.Mention + case MisskeyNotificationType.Renote: + case MisskeyNotificationType.Quote: + return NotificationType.Reblog + case MisskeyNotificationType.Reaction: + return NotificationType.EmojiReaction + case MisskeyNotificationType.PollVote: + return NotificationType.PollVote + case MisskeyNotificationType.ReceiveFollowRequest: + return NotificationType.FollowRequest + case MisskeyNotificationType.FollowRequestAccepted: + return NotificationType.Follow + default: + return new UnknownNotificationTypeError() + } + } - noteToConversation = ( - n: Entity.Note, - host: string, - ): MegalodonEntity.Conversation => { - const accounts: Array = [this.user(n.user)]; - if (n.reply) { - accounts.push(this.user(n.reply.user)); - } - return { - id: n.id, - accounts: accounts, - last_status: this.note(n, host), - unread: false, - }; - }; + export const notification = (n: Entity.Notification): MegalodonEntity.Notification | UnknownNotificationTypeError => { + const notificationType = decodeNotificationType(n.type) + if (notificationType instanceof UnknownNotificationTypeError) { + return notificationType + } + let notification = { + id: n.id, + account: user(n.user), + created_at: n.createdAt, + type: notificationType + } + if (n.note) { + notification = Object.assign(notification, { + status: note(n.note) + }) + } + if (n.reaction) { + notification = Object.assign(notification, { + emoji: n.reaction + }) + } + return notification + } - list = (l: Entity.List): MegalodonEntity.List => ({ - id: l.id, - title: l.name, - }); + export const stats = (s: Entity.Stats): MegalodonEntity.Stats => { + return { + user_count: s.usersCount, + status_count: s.notesCount, + domain_count: s.instances + } + } - encodeNotificationType = ( - e: MegalodonEntity.NotificationType, - ): MisskeyEntity.NotificationType => { - switch (e) { - case NotificationType.Follow: - return MisskeyNotificationType.Follow; - case NotificationType.Mention: - return MisskeyNotificationType.Reply; - case NotificationType.Favourite: - case NotificationType.Reaction: - return MisskeyNotificationType.Reaction; - case NotificationType.Reblog: - return MisskeyNotificationType.Renote; - case NotificationType.Poll: - return MisskeyNotificationType.PollEnded; - case NotificationType.FollowRequest: - return MisskeyNotificationType.ReceiveFollowRequest; - default: - return e; - } - }; + export const meta = (m: Entity.Meta, s: Entity.Stats): MegalodonEntity.Instance => { + const wss = m.uri.replace(/^https:\/\//, 'wss://') + return { + uri: m.uri, + title: m.name, + description: m.description, + email: m.maintainerEmail, + version: m.version, + thumbnail: m.bannerUrl, + urls: { + streaming_api: `${wss}/streaming` + }, + stats: stats(s), + languages: m.langs, + registrations: !m.disableRegistration, + approval_required: false, + configuration: { + statuses: { + max_characters: m.maxNoteTextLength, + max_media_attachments: m.policies.clipLimit + } + } + } + } - decodeNotificationType = ( - e: MisskeyEntity.NotificationType, - ): MegalodonEntity.NotificationType => { - switch (e) { - case MisskeyNotificationType.Follow: - return NotificationType.Follow; - case MisskeyNotificationType.Mention: - case MisskeyNotificationType.Reply: - return NotificationType.Mention; - case MisskeyNotificationType.Renote: - case MisskeyNotificationType.Quote: - return NotificationType.Reblog; - case MisskeyNotificationType.Reaction: - return NotificationType.Reaction; - case MisskeyNotificationType.PollEnded: - return NotificationType.Poll; - case MisskeyNotificationType.ReceiveFollowRequest: - return NotificationType.FollowRequest; - case MisskeyNotificationType.FollowRequestAccepted: - return NotificationType.Follow; - default: - return e; - } - }; + export const hashtag = (h: Entity.Hashtag): MegalodonEntity.Tag => { + return { + name: h.tag, + url: h.tag, + history: [], + following: false + } + } + } - announcement = (a: Entity.Announcement): MegalodonEntity.Announcement => ({ - id: a.id, - content: `

${this.escapeMFM(a.title)}

${this.escapeMFM(a.text)}`, - starts_at: null, - ends_at: null, - published: true, - all_day: false, - published_at: a.createdAt, - updated_at: a.updatedAt, - read: a.isRead, - mentions: [], - statuses: [], - tags: [], - emojis: [], - reactions: [], - }); + export const DEFAULT_SCOPE = [ + 'read:account', + 'write:account', + 'read:blocks', + 'write:blocks', + 'read:drive', + 'write:drive', + 'read:favorites', + 'write:favorites', + 'read:following', + 'write:following', + 'read:mutes', + 'write:mutes', + 'write:notes', + 'read:notifications', + 'write:notifications', + 'read:reactions', + 'write:reactions', + 'write:votes' + ] - notification = ( - n: Entity.Notification, - host: string, - ): MegalodonEntity.Notification => { - let notification = { - id: n.id, - account: n.user ? this.user(n.user) : this.modelOfAcct, - created_at: n.createdAt, - type: this.decodeNotificationType(n.type), - }; - if (n.note) { - notification = Object.assign(notification, { - status: this.note(n.note, host), - }); - if (notification.type === NotificationType.Poll) { - notification = Object.assign(notification, { - account: this.note(n.note, host).account, - }); - } - if (n.reaction) { - notification = Object.assign(notification, { - reaction: this.mapReactions(n.note.emojis, { [n.reaction]: 1 })[0], - }); - } - } - return notification; - }; + /** + * Interface + */ + export interface Interface { + get(path: string, params?: any, headers?: { [key: string]: string }): Promise> + post(path: string, params?: any, headers?: { [key: string]: string }): Promise> + cancel(): void + socket(channel: 'user' | 'localTimeline' | 'hybridTimeline' | 'globalTimeline' | 'conversation' | 'list', listId?: string): WebSocket + } - stats = (s: Entity.Stats): MegalodonEntity.Stats => { - return { - user_count: s.usersCount, - status_count: s.notesCount, - domain_count: s.instances, - }; - }; + /** + * Misskey API client. + * + * Usign axios for request, you will handle promises. + */ + export class Client implements Interface { + private accessToken: string | null + private baseUrl: string + private userAgent: string + private abortController: AbortController + private proxyConfig: ProxyConfig | false = false - meta = (m: Entity.Meta, s: Entity.Stats): MegalodonEntity.Instance => { - const wss = m.uri.replace(/^https:\/\//, "wss://"); - return { - uri: m.uri, - title: m.name, - description: m.description, - email: m.maintainerEmail, - version: m.version, - thumbnail: m.bannerUrl, - urls: { - streaming_api: `${wss}/streaming`, - }, - stats: this.stats(s), - languages: m.langs, - contact_account: null, - max_toot_chars: m.maxNoteTextLength, - registrations: !m.disableRegistration, - }; - }; + /** + * @param baseUrl hostname or base URL + * @param accessToken access token from OAuth2 authorization + * @param userAgent UserAgent is specified in header on request. + * @param proxyConfig Proxy setting, or set false if don't use proxy. + */ + constructor(baseUrl: string, accessToken: string | null, userAgent: string = DEFAULT_UA, proxyConfig: ProxyConfig | false = false) { + this.accessToken = accessToken + this.baseUrl = baseUrl + this.userAgent = userAgent + this.proxyConfig = proxyConfig + this.abortController = new AbortController() + axios.defaults.signal = this.abortController.signal + } - hashtag = (h: Entity.Hashtag): MegalodonEntity.Tag => { - return { - name: h.tag, - url: h.tag, - history: null, - following: false, - }; - }; - } + /** + * GET request to misskey API. + **/ + public async get(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise> { + let options: AxiosRequestConfig = { + params: params, + headers: headers, + maxContentLength: Infinity, + maxBodyLength: Infinity + } + if (this.proxyConfig) { + options = Object.assign(options, { + httpAgent: proxyAgent(this.proxyConfig), + httpsAgent: proxyAgent(this.proxyConfig) + }) + } + return axios.get(this.baseUrl + path, options).then((resp: AxiosResponse) => { + const res: Response = { + data: resp.data, + status: resp.status, + statusText: resp.statusText, + headers: resp.headers + } + return res + }) + } - export const DEFAULT_SCOPE = [ - "read:account", - "write:account", - "read:blocks", - "write:blocks", - "read:drive", - "write:drive", - "read:favorites", - "write:favorites", - "read:following", - "write:following", - "read:mutes", - "write:mutes", - "write:notes", - "read:notifications", - "write:notifications", - "read:reactions", - "write:reactions", - "write:votes", - ]; + /** + * POST request to misskey REST API. + * @param path relative path from baseUrl + * @param params Form data + * @param headers Request header object + */ + public async post(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise> { + let options: AxiosRequestConfig = { + headers: headers, + maxContentLength: Infinity, + maxBodyLength: Infinity + } + if (this.proxyConfig) { + options = Object.assign(options, { + httpAgent: proxyAgent(this.proxyConfig), + httpsAgent: proxyAgent(this.proxyConfig) + }) + } + let bodyParams = params + if (this.accessToken) { + if (params instanceof FormData) { + bodyParams.append('i', this.accessToken) + } else { + bodyParams = Object.assign(params, { + i: this.accessToken + }) + } + } + return axios.post(this.baseUrl + path, bodyParams, options).then((resp: AxiosResponse) => { + const res: Response = { + data: resp.data, + status: resp.status, + statusText: resp.statusText, + headers: resp.headers + } + return res + }) + } - /** - * Interface - */ - export interface Interface { - post( - path: string, - params?: any, - headers?: { [key: string]: string }, - ): Promise>; - cancel(): void; - socket( - channel: - | "user" - | "localTimeline" - | "hybridTimeline" - | "globalTimeline" - | "conversation" - | "list", - listId?: string, - ): WebSocket; - } + /** + * Cancel all requests in this instance. + * @returns void + */ + public cancel(): void { + return this.abortController.abort() + } - /** - * Misskey API client. - * - * Usign axios for request, you will handle promises. - */ - export class Client implements Interface { - private accessToken: string | null; - private baseUrl: string; - private userAgent: string; - private abortController: AbortController; - private proxyConfig: ProxyConfig | false = false; - private converter: Converter; - - /** - * @param baseUrl hostname or base URL - * @param accessToken access token from OAuth2 authorization - * @param userAgent UserAgent is specified in header on request. - * @param proxyConfig Proxy setting, or set false if don't use proxy. - * @param converter Converter instance. - */ - constructor( - baseUrl: string, - accessToken: string | null, - userAgent: string = DEFAULT_UA, - proxyConfig: ProxyConfig | false = false, - converter: Converter, - ) { - this.accessToken = accessToken; - this.baseUrl = baseUrl; - this.userAgent = userAgent; - this.proxyConfig = proxyConfig; - this.abortController = new AbortController(); - this.converter = converter; - axios.defaults.signal = this.abortController.signal; - } - - /** - * POST request to mastodon REST API. - * @param path relative path from baseUrl - * @param params Form data - * @param headers Request header object - */ - public async post( - path: string, - params: any = {}, - headers: { [key: string]: string } = {}, - ): Promise> { - let options: AxiosRequestConfig = { - headers: headers, - maxContentLength: Infinity, - maxBodyLength: Infinity, - }; - if (this.proxyConfig) { - options = Object.assign(options, { - httpAgent: proxyAgent(this.proxyConfig), - httpsAgent: proxyAgent(this.proxyConfig), - }); - } - let bodyParams = params; - if (this.accessToken) { - if (params instanceof FormData) { - bodyParams.append("i", this.accessToken); - } else { - bodyParams = Object.assign(params, { - i: this.accessToken, - }); - } - } - - return axios - .post(this.baseUrl + path, bodyParams, options) - .then((resp: AxiosResponse) => { - const res: Response = { - data: resp.data, - status: resp.status, - statusText: resp.statusText, - headers: resp.headers, - }; - return res; - }); - } - - /** - * Cancel all requests in this instance. - * @returns void - */ - public cancel(): void { - return this.abortController.abort(); - } - - /** - * Get connection and receive websocket connection for Misskey API. - * - * @param channel Channel name is user, localTimeline, hybridTimeline, globalTimeline, conversation or list. - * @param listId This parameter is required only list channel. - */ - public socket( - channel: - | "user" - | "localTimeline" - | "hybridTimeline" - | "globalTimeline" - | "conversation" - | "list", - listId?: string, - ): WebSocket { - if (!this.accessToken) { - throw new Error("accessToken is required"); - } - const url = `${this.baseUrl}/streaming`; - const streaming = new WebSocket( - url, - channel, - this.accessToken, - listId, - this.userAgent, - this.proxyConfig, - this.converter, - ); - process.nextTick(() => { - streaming.start(); - }); - return streaming; - } - } + /** + * Get connection and receive websocket connection for Misskey API. + * + * @param channel Channel name is user, localTimeline, hybridTimeline, globalTimeline, conversation or list. + * @param listId This parameter is required only list channel. + */ + public socket( + channel: 'user' | 'localTimeline' | 'hybridTimeline' | 'globalTimeline' | 'conversation' | 'list', + listId?: string + ): WebSocket { + if (!this.accessToken) { + throw new Error('accessToken is required') + } + const url = this.baseUrl + '/streaming' + const streaming = new WebSocket(url, channel, this.accessToken, listId, this.userAgent, this.proxyConfig) + process.nextTick(() => { + streaming.start() + }) + return streaming + } + } } -export default MisskeyAPI; +export default MisskeyAPI diff --git a/packages/megalodon/src/misskey/entities/GetAll.ts b/packages/megalodon/src/misskey/entities/GetAll.ts deleted file mode 100644 index 94ace2f184..0000000000 --- a/packages/megalodon/src/misskey/entities/GetAll.ts +++ /dev/null @@ -1,6 +0,0 @@ -namespace MisskeyEntity { - export type GetAll = { - tutorial: number; - defaultNoteVisibility: "public" | "home" | "followers" | "specified"; - }; -} diff --git a/packages/megalodon/src/misskey/entities/announcement.ts b/packages/megalodon/src/misskey/entities/announcement.ts index 7594ba7efc..ec1739a75a 100644 --- a/packages/megalodon/src/misskey/entities/announcement.ts +++ b/packages/megalodon/src/misskey/entities/announcement.ts @@ -1,10 +1,11 @@ namespace MisskeyEntity { - export type Announcement = { - id: string; - createdAt: string; - updatedAt: string; - text: string; - title: string; - isRead?: boolean; - }; + export type Announcement = { + id: string + createdAt: string + updatedAt: string | null + text: string + title: string + imageurl: string | null + isRead?: boolean + } } diff --git a/packages/megalodon/src/misskey/entities/app.ts b/packages/megalodon/src/misskey/entities/app.ts index 5924060d81..40a704b944 100644 --- a/packages/megalodon/src/misskey/entities/app.ts +++ b/packages/megalodon/src/misskey/entities/app.ts @@ -1,9 +1,9 @@ namespace MisskeyEntity { - export type App = { - id: string; - name: string; - callbackUrl: string; - permission: Array; - secret: string; - }; + export type App = { + id: string + name: string + callbackUrl: string + permission: Array + secret: string + } } diff --git a/packages/megalodon/src/misskey/entities/blocking.ts b/packages/megalodon/src/misskey/entities/blocking.ts index 3e56790a7b..9900a777bf 100644 --- a/packages/megalodon/src/misskey/entities/blocking.ts +++ b/packages/megalodon/src/misskey/entities/blocking.ts @@ -1,10 +1,10 @@ /// namespace MisskeyEntity { - export type Blocking = { - id: string; - createdAt: string; - blockeeId: string; - blockee: UserDetail; - }; + export type Blocking = { + id: string + createdAt: string + blockeeId: string + blockee: UserDetail + } } diff --git a/packages/megalodon/src/misskey/entities/createdNote.ts b/packages/megalodon/src/misskey/entities/createdNote.ts index 235f7063fb..88ba600401 100644 --- a/packages/megalodon/src/misskey/entities/createdNote.ts +++ b/packages/megalodon/src/misskey/entities/createdNote.ts @@ -1,7 +1,7 @@ /// namespace MisskeyEntity { - export type CreatedNote = { - createdNote: Note; - }; + export type CreatedNote = { + createdNote: Note + } } diff --git a/packages/megalodon/src/misskey/entities/emoji.ts b/packages/megalodon/src/misskey/entities/emoji.ts index d320760e91..2bd4c8c730 100644 --- a/packages/megalodon/src/misskey/entities/emoji.ts +++ b/packages/megalodon/src/misskey/entities/emoji.ts @@ -1,9 +1,8 @@ namespace MisskeyEntity { - export type Emoji = { - name: string; - host: string | null; - url: string; - aliases: Array; - category: string; - }; + export type Emoji = { + name: string + url: string + aliases: Array + category: string + } } diff --git a/packages/megalodon/src/misskey/entities/favorite.ts b/packages/megalodon/src/misskey/entities/favorite.ts index ba948f2e73..8ed7a54bfe 100644 --- a/packages/megalodon/src/misskey/entities/favorite.ts +++ b/packages/megalodon/src/misskey/entities/favorite.ts @@ -1,10 +1,10 @@ /// namespace MisskeyEntity { - export type Favorite = { - id: string; - createdAt: string; - noteId: string; - note: Note; - }; + export type Favorite = { + id: string + createdAt: string + noteId: string + note: Note + } } diff --git a/packages/megalodon/src/misskey/entities/field.ts b/packages/megalodon/src/misskey/entities/field.ts deleted file mode 100644 index 8bbb2d7c42..0000000000 --- a/packages/megalodon/src/misskey/entities/field.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace MisskeyEntity { - export type Field = { - name: string; - value: string; - verified?: string; - }; -} diff --git a/packages/megalodon/src/misskey/entities/file.ts b/packages/megalodon/src/misskey/entities/file.ts index e823dde1be..6e4e09eee0 100644 --- a/packages/megalodon/src/misskey/entities/file.ts +++ b/packages/megalodon/src/misskey/entities/file.ts @@ -1,20 +1,18 @@ namespace MisskeyEntity { - export type File = { - id: string; - createdAt: string; - name: string; - type: string; - md5: string; - size: number; - isSensitive: boolean; - properties: { - width: number; - height: number; - avgColor: string; - }; - url: string; - thumbnailUrl: string; - comment: string; - blurhash: string; - }; + export type File = { + id: string + createdAt: string + name: string + type: string + md5: string + size: number + isSensitive: boolean + properties: { + width: number + height: number + avgColor: string + } + url: string + thumbnailUrl: string + } } diff --git a/packages/megalodon/src/misskey/entities/followRequest.ts b/packages/megalodon/src/misskey/entities/followRequest.ts index 60bd0e0abc..bd2777b2de 100644 --- a/packages/megalodon/src/misskey/entities/followRequest.ts +++ b/packages/megalodon/src/misskey/entities/followRequest.ts @@ -1,9 +1,9 @@ /// namespace MisskeyEntity { - export type FollowRequest = { - id: string; - follower: User; - followee: User; - }; + export type FollowRequest = { + id: string + follower: User + followee: User + } } diff --git a/packages/megalodon/src/misskey/entities/follower.ts b/packages/megalodon/src/misskey/entities/follower.ts index 34ae825519..70ef632e1d 100644 --- a/packages/megalodon/src/misskey/entities/follower.ts +++ b/packages/megalodon/src/misskey/entities/follower.ts @@ -1,11 +1,11 @@ /// namespace MisskeyEntity { - export type Follower = { - id: string; - createdAt: string; - followeeId: string; - followerId: string; - follower: UserDetail; - }; + export type Follower = { + id: string + createdAt: string + followeeId: string + followerId: string + follower: UserDetail + } } diff --git a/packages/megalodon/src/misskey/entities/following.ts b/packages/megalodon/src/misskey/entities/following.ts index 6cbc8f1c39..927a913545 100644 --- a/packages/megalodon/src/misskey/entities/following.ts +++ b/packages/megalodon/src/misskey/entities/following.ts @@ -1,11 +1,11 @@ /// namespace MisskeyEntity { - export type Following = { - id: string; - createdAt: string; - followeeId: string; - followerId: string; - followee: UserDetail; - }; + export type Following = { + id: string + createdAt: string + followeeId: string + followerId: string + followee: UserDetail + } } diff --git a/packages/megalodon/src/misskey/entities/hashtag.ts b/packages/megalodon/src/misskey/entities/hashtag.ts index 3ec4d6675b..6a3fe43ad5 100644 --- a/packages/megalodon/src/misskey/entities/hashtag.ts +++ b/packages/megalodon/src/misskey/entities/hashtag.ts @@ -1,7 +1,7 @@ namespace MisskeyEntity { - export type Hashtag = { - tag: string; - chart: Array; - usersCount: number; - }; + export type Hashtag = { + tag: string + chart: Array + usersCount: number + } } diff --git a/packages/megalodon/src/misskey/entities/list.ts b/packages/megalodon/src/misskey/entities/list.ts index 60706592a4..8167d29810 100644 --- a/packages/megalodon/src/misskey/entities/list.ts +++ b/packages/megalodon/src/misskey/entities/list.ts @@ -1,8 +1,8 @@ namespace MisskeyEntity { - export type List = { - id: string; - createdAt: string; - name: string; - userIds: Array; - }; + export type List = { + id: string + createdAt: string + name: string + userIds: Array + } } diff --git a/packages/megalodon/src/misskey/entities/meta.ts b/packages/megalodon/src/misskey/entities/meta.ts index 97827fe8fd..6d168db235 100644 --- a/packages/megalodon/src/misskey/entities/meta.ts +++ b/packages/megalodon/src/misskey/entities/meta.ts @@ -1,18 +1,47 @@ /// namespace MisskeyEntity { - export type Meta = { - maintainerName: string; - maintainerEmail: string; - name: string; - version: string; - uri: string; - description: string; - langs: Array; - disableRegistration: boolean; - disableLocalTimeline: boolean; - bannerUrl: string; - maxNoteTextLength: 3000; - emojis: Array; - }; + export type Meta = { + maintainerName: string + maintainerEmail: string + name: string + version: string + uri: string + description: string + langs: Array + disableRegistration: boolean + disableLocalTimeline: boolean + bannerUrl: string + maxNoteTextLength: number + emojis: Array + policies: { + gtlAvailable: boolean + ltlAvailable: boolean + canPublicNote: boolean + canInvite: boolean + canManageCustomEmojis: boolean + canHideAds: boolean + driveCapacityMb: number + pinLimit: number + antennaLimit: number + wordMuteLimit: number + webhookLimit: number + clipLimit: number + noteEachClipsLimit: number + userListLimit: number + userEachUserListsLimit: number + rateLimitFactor: number + } + features: { + registration: boolean + emailRequiredForSignup: boolean + elasticsearch: boolean + hcaptcha: boolean + recaptcha: boolean + turnstile: boolean + objectStorage: boolean + serviceWorker: boolean + miauth: boolean + } + } } diff --git a/packages/megalodon/src/misskey/entities/mute.ts b/packages/megalodon/src/misskey/entities/mute.ts index 7975b3d315..3cd7ae4090 100644 --- a/packages/megalodon/src/misskey/entities/mute.ts +++ b/packages/megalodon/src/misskey/entities/mute.ts @@ -1,10 +1,10 @@ /// namespace MisskeyEntity { - export type Mute = { - id: string; - createdAt: string; - muteeId: string; - mutee: UserDetail; - }; + export type Mute = { + id: string + createdAt: string + muteeId: string + mutee: UserDetail + } } diff --git a/packages/megalodon/src/misskey/entities/note.ts b/packages/megalodon/src/misskey/entities/note.ts index 64a0a50785..cc8aa32062 100644 --- a/packages/megalodon/src/misskey/entities/note.ts +++ b/packages/megalodon/src/misskey/entities/note.ts @@ -4,29 +4,31 @@ /// namespace MisskeyEntity { - export type Note = { - id: string; - createdAt: string; - userId: string; - user: User; - text: string | null; - cw: string | null; - visibility: "public" | "home" | "followers" | "specified"; - renoteCount: number; - repliesCount: number; - reactions: { [key: string]: number }; - emojis: Array; - fileIds: Array; - files: Array; - replyId: string | null; - renoteId: string | null; - uri?: string; - reply?: Note; - renote?: Note; - viaMobile?: boolean; - tags?: Array; - poll?: Poll; - mentions?: Array; - myReaction?: string; - }; + export type Note = { + id: string + createdAt: string + userId: string + user: User + text: string | null + cw: string | null + visibility: 'public' | 'home' | 'followers' | 'specified' + renoteCount: number + repliesCount: number + reactions: { [key: string]: number } + // This field includes only remote emojis + reactionEmojis: { [key: string]: string } + emojis: Array | { [key: string]: string } + fileIds: Array + files: Array + replyId: string | null + renoteId: string | null + uri?: string + reply?: Note + renote?: Note + viaMobile?: boolean + tags?: Array + poll?: Poll + mentions?: Array + myReaction?: string + } } diff --git a/packages/megalodon/src/misskey/entities/notification.ts b/packages/megalodon/src/misskey/entities/notification.ts index 7ecb911537..c331a1ec85 100644 --- a/packages/megalodon/src/misskey/entities/notification.ts +++ b/packages/megalodon/src/misskey/entities/notification.ts @@ -2,16 +2,16 @@ /// namespace MisskeyEntity { - export type Notification = { - id: string; - createdAt: string; - // https://github.com/syuilo/misskey/blob/056942391aee135eb6c77aaa63f6ed5741d701a6/src/models/entities/notification.ts#L50-L62 - type: NotificationType; - userId: string; - user: User; - note?: Note; - reaction?: string; - }; + export type Notification = { + id: string + createdAt: string + // https://github.com/syuilo/misskey/blob/056942391aee135eb6c77aaa63f6ed5741d701a6/src/models/entities/notification.ts#L50-L62 + type: NotificationType + userId: string + user: User + note?: Note + reaction?: string + } - export type NotificationType = string; + export type NotificationType = string } diff --git a/packages/megalodon/src/misskey/entities/poll.ts b/packages/megalodon/src/misskey/entities/poll.ts index 9f6bfa40d2..a3f1d971a3 100644 --- a/packages/megalodon/src/misskey/entities/poll.ts +++ b/packages/megalodon/src/misskey/entities/poll.ts @@ -1,13 +1,13 @@ namespace MisskeyEntity { - export type Choice = { - text: string; - votes: number; - isVoted: boolean; - }; + export type Choice = { + text: string + votes: number + isVoted: boolean + } - export type Poll = { - multiple: boolean; - expiresAt: string; - choices: Array; - }; + export type Poll = { + multiple: boolean + expiresAt: string + choices: Array + } } diff --git a/packages/megalodon/src/misskey/entities/reaction.ts b/packages/megalodon/src/misskey/entities/reaction.ts index b35a25bfb5..270ca6eab1 100644 --- a/packages/megalodon/src/misskey/entities/reaction.ts +++ b/packages/megalodon/src/misskey/entities/reaction.ts @@ -1,11 +1,10 @@ /// namespace MisskeyEntity { - export type Reaction = { - id: string; - createdAt: string; - user: User; - url?: string; - type: string; - }; + export type Reaction = { + id: string + createdAt: string + user: User + type: string + } } diff --git a/packages/megalodon/src/misskey/entities/relation.ts b/packages/megalodon/src/misskey/entities/relation.ts index 6db4a1b167..07653b4865 100644 --- a/packages/megalodon/src/misskey/entities/relation.ts +++ b/packages/megalodon/src/misskey/entities/relation.ts @@ -1,12 +1,12 @@ namespace MisskeyEntity { - export type Relation = { - id: string; - isFollowing: boolean; - hasPendingFollowRequestFromYou: boolean; - hasPendingFollowRequestToYou: boolean; - isFollowed: boolean; - isBlocking: boolean; - isBlocked: boolean; - isMuted: boolean; - }; + export type Relation = { + id: string + isFollowing: boolean + hasPendingFollowRequestFromYou: boolean + hasPendingFollowRequestToYou: boolean + isFollowed: boolean + isBlocking: boolean + isBlocked: boolean + isMuted: boolean + } } diff --git a/packages/megalodon/src/misskey/entities/session.ts b/packages/megalodon/src/misskey/entities/session.ts index 572333ff0b..47fe9cf826 100644 --- a/packages/megalodon/src/misskey/entities/session.ts +++ b/packages/megalodon/src/misskey/entities/session.ts @@ -1,6 +1,6 @@ namespace MisskeyEntity { - export type Session = { - token: string; - url: string; - }; + export type Session = { + token: string + url: string + } } diff --git a/packages/megalodon/src/misskey/entities/state.ts b/packages/megalodon/src/misskey/entities/state.ts deleted file mode 100644 index 62d60ce282..0000000000 --- a/packages/megalodon/src/misskey/entities/state.ts +++ /dev/null @@ -1,7 +0,0 @@ -namespace MisskeyEntity { - export type State = { - isFavorited: boolean; - isMutedThread: boolean; - isWatching: boolean; - }; -} diff --git a/packages/megalodon/src/misskey/entities/stats.ts b/packages/megalodon/src/misskey/entities/stats.ts index 9832a0ad8a..7f080efda9 100644 --- a/packages/megalodon/src/misskey/entities/stats.ts +++ b/packages/megalodon/src/misskey/entities/stats.ts @@ -1,9 +1,9 @@ namespace MisskeyEntity { - export type Stats = { - notesCount: number; - originalNotesCount: number; - usersCount: number; - originalUsersCount: number; - instances: number; - }; + export type Stats = { + notesCount: number + originalNotesCount: number + usersCount: number + originalUsersCount: number + instances: number + } } diff --git a/packages/megalodon/src/misskey/entities/user.ts b/packages/megalodon/src/misskey/entities/user.ts index 96610f6e6d..78745c20d3 100644 --- a/packages/megalodon/src/misskey/entities/user.ts +++ b/packages/megalodon/src/misskey/entities/user.ts @@ -1,13 +1,13 @@ /// namespace MisskeyEntity { - export type User = { - id: string; - name: string; - username: string; - host: string | null; - avatarUrl: string; - avatarColor: string; - emojis: Array; - }; + export type User = { + id: string + name: string + username: string + host: string | null + avatarUrl: string + avatarColor: string + emojis: Array | { [key: string]: string } + } } diff --git a/packages/megalodon/src/misskey/entities/userDetail.ts b/packages/megalodon/src/misskey/entities/userDetail.ts index 0f5bd5f644..607d9a511e 100644 --- a/packages/megalodon/src/misskey/entities/userDetail.ts +++ b/packages/megalodon/src/misskey/entities/userDetail.ts @@ -1,34 +1,32 @@ /// -/// /// namespace MisskeyEntity { - export type UserDetail = { - id: string; - name: string; - username: string; - host: string | null; - avatarUrl: string; - avatarColor: string; - isAdmin: boolean; - isModerator: boolean; - isBot: boolean; - isCat: boolean; - emojis: Array; - createdAt: string; - bannerUrl: string; - bannerColor: string; - isLocked: boolean; - isSilenced: boolean; - isSuspended: boolean; - description: string; - followersCount: number; - followingCount: number; - notesCount: number; - avatarId: string; - bannerId: string; - pinnedNoteIds?: Array; - pinnedNotes?: Array; - fields: Array; - }; + export type UserDetail = { + id: string + name: string + username: string + host: string | null + avatarUrl: string + avatarColor: string + isAdmin: boolean + isModerator: boolean + isBot: boolean + isCat: boolean + emojis: Array | { [key: string]: string } + createdAt: string + bannerUrl: string + bannerColor: string + isLocked: boolean + isSilenced: boolean + isSuspended: boolean + description: string + followersCount: number + followingCount: number + notesCount: number + avatarId: string + bannerId: string + pinnedNoteIds?: Array + pinnedNotes?: Array + } } diff --git a/packages/megalodon/src/misskey/entities/userDetailMe.ts b/packages/megalodon/src/misskey/entities/userDetailMe.ts deleted file mode 100644 index 272e65ffa4..0000000000 --- a/packages/megalodon/src/misskey/entities/userDetailMe.ts +++ /dev/null @@ -1,36 +0,0 @@ -/// -/// -/// - -namespace MisskeyEntity { - export type UserDetailMe = { - id: string; - name: string; - username: string; - host: string | null; - avatarUrl: string; - avatarColor: string; - isAdmin: boolean; - isModerator: boolean; - isBot: boolean; - isCat: boolean; - emojis: Array; - createdAt: string; - bannerUrl: string; - bannerColor: string; - isLocked: boolean; - isSilenced: boolean; - isSuspended: boolean; - description: string; - followersCount: number; - followingCount: number; - notesCount: number; - avatarId: string; - bannerId: string; - pinnedNoteIds?: Array; - pinnedNotes?: Array; - fields: Array; - alwaysMarkNsfw: boolean; - lang: string | null; - }; -} diff --git a/packages/megalodon/src/misskey/entities/userkey.ts b/packages/megalodon/src/misskey/entities/userkey.ts index 921af65536..5b66e95b82 100644 --- a/packages/megalodon/src/misskey/entities/userkey.ts +++ b/packages/megalodon/src/misskey/entities/userkey.ts @@ -1,8 +1,8 @@ /// namespace MisskeyEntity { - export type UserKey = { - accessToken: string; - user: User; - }; + export type UserKey = { + accessToken: string + user: User + } } diff --git a/packages/megalodon/src/misskey/entity.ts b/packages/megalodon/src/misskey/entity.ts index 72a80f9d96..8498517be9 100644 --- a/packages/megalodon/src/misskey/entity.ts +++ b/packages/megalodon/src/misskey/entity.ts @@ -1,10 +1,9 @@ -/// /// +/// /// /// /// /// -/// /// /// /// @@ -20,9 +19,8 @@ /// /// /// -/// /// /// /// -export default MisskeyEntity; +export default MisskeyEntity diff --git a/packages/megalodon/src/misskey/notification.ts b/packages/megalodon/src/misskey/notification.ts index eb7c2d23d8..2909762c15 100644 --- a/packages/megalodon/src/misskey/notification.ts +++ b/packages/megalodon/src/misskey/notification.ts @@ -1,18 +1,16 @@ -import MisskeyEntity from "./entity"; +import MisskeyEntity from './entity' namespace MisskeyNotificationType { - export const Follow: MisskeyEntity.NotificationType = "follow"; - export const Mention: MisskeyEntity.NotificationType = "mention"; - export const Reply: MisskeyEntity.NotificationType = "reply"; - export const Renote: MisskeyEntity.NotificationType = "renote"; - export const Quote: MisskeyEntity.NotificationType = "quote"; - export const Reaction: MisskeyEntity.NotificationType = "favourite"; - export const PollEnded: MisskeyEntity.NotificationType = "pollEnded"; - export const ReceiveFollowRequest: MisskeyEntity.NotificationType = - "receiveFollowRequest"; - export const FollowRequestAccepted: MisskeyEntity.NotificationType = - "followRequestAccepted"; - export const GroupInvited: MisskeyEntity.NotificationType = "groupInvited"; + export const Follow: MisskeyEntity.NotificationType = 'follow' + export const Mention: MisskeyEntity.NotificationType = 'mention' + export const Reply: MisskeyEntity.NotificationType = 'reply' + export const Renote: MisskeyEntity.NotificationType = 'renote' + export const Quote: MisskeyEntity.NotificationType = 'quote' + export const Reaction: MisskeyEntity.NotificationType = 'reaction' + export const PollVote: MisskeyEntity.NotificationType = 'pollVote' + export const ReceiveFollowRequest: MisskeyEntity.NotificationType = 'receiveFollowRequest' + export const FollowRequestAccepted: MisskeyEntity.NotificationType = 'followRequestAccepted' + export const GroupInvited: MisskeyEntity.NotificationType = 'groupInvited' } -export default MisskeyNotificationType; +export default MisskeyNotificationType diff --git a/packages/megalodon/src/misskey/web_socket.ts b/packages/megalodon/src/misskey/web_socket.ts index 0cbfc2bfeb..181fb1c903 100644 --- a/packages/megalodon/src/misskey/web_socket.ts +++ b/packages/megalodon/src/misskey/web_socket.ts @@ -1,365 +1,328 @@ -import WS from "ws"; -import dayjs, { Dayjs } from "dayjs"; -import { v4 as uuid } from "uuid"; -import { EventEmitter } from "events"; -import { WebSocketInterface } from "../megalodon"; -import proxyAgent, { ProxyConfig } from "../proxy_config"; -import MisskeyAPI from "./api_client"; +import WS from 'ws' +import dayjs, { Dayjs } from 'dayjs' +import { v4 as uuid } from 'uuid' +import { EventEmitter } from 'events' +import { WebSocketInterface } from '../megalodon' +import proxyAgent, { ProxyConfig } from '../proxy_config' +import MisskeyAPI from './api_client' +import { UnknownNotificationTypeError } from '../notification' /** * WebSocket * Misskey is not support http streaming. It supports websocket instead of streaming. * So this class connect to Misskey server with WebSocket. */ -export default class WebSocket - extends EventEmitter - implements WebSocketInterface -{ - public url: string; - public channel: - | "user" - | "localTimeline" - | "hybridTimeline" - | "globalTimeline" - | "conversation" - | "list"; - public parser: any; - public headers: { [key: string]: string }; - public proxyConfig: ProxyConfig | false = false; - public listId: string | null = null; - private _converter: MisskeyAPI.Converter; - private _accessToken: string; - private _reconnectInterval: number; - private _reconnectMaxAttempts: number; - private _reconnectCurrentAttempts: number; - private _connectionClosed: boolean; - private _client: WS | null = null; - private _channelID: string; - private _pongReceivedTimestamp: Dayjs; - private _heartbeatInterval = 60000; - private _pongWaiting = false; +export default class WebSocket extends EventEmitter implements WebSocketInterface { + public url: string + public channel: 'user' | 'localTimeline' | 'hybridTimeline' | 'globalTimeline' | 'conversation' | 'list' + public parser: Parser + public headers: { [key: string]: string } + public proxyConfig: ProxyConfig | false = false + public listId: string | null = null + private _accessToken: string + private _reconnectInterval: number + private _reconnectMaxAttempts: number + private _reconnectCurrentAttempts: number + private _connectionClosed: boolean + private _client: WS | null = null + private _channelID: string + private _pongReceivedTimestamp: Dayjs + private _heartbeatInterval: number = 60000 + private _pongWaiting: boolean = false - /** - * @param url Full url of websocket: e.g. wss://misskey.io/streaming - * @param channel Channel name is user, localTimeline, hybridTimeline, globalTimeline, conversation or list. - * @param accessToken The access token. - * @param listId This parameter is required when you specify list as channel. - */ - constructor( - url: string, - channel: - | "user" - | "localTimeline" - | "hybridTimeline" - | "globalTimeline" - | "conversation" - | "list", - accessToken: string, - listId: string | undefined, - userAgent: string, - proxyConfig: ProxyConfig | false = false, - converter: MisskeyAPI.Converter, - ) { - super(); - this.url = url; - this.parser = new Parser(); - this.channel = channel; - this.headers = { - "User-Agent": userAgent, - }; - if (listId === undefined) { - this.listId = null; - } else { - this.listId = listId; - } - this.proxyConfig = proxyConfig; - this._accessToken = accessToken; - this._reconnectInterval = 10000; - this._reconnectMaxAttempts = Infinity; - this._reconnectCurrentAttempts = 0; - this._connectionClosed = false; - this._channelID = uuid(); - this._pongReceivedTimestamp = dayjs(); - this._converter = converter; - } + /** + * @param url Full url of websocket: e.g. wss://misskey.io/streaming + * @param channel Channel name is user, localTimeline, hybridTimeline, globalTimeline, conversation or list. + * @param accessToken The access token. + * @param listId This parameter is required when you specify list as channel. + */ + constructor( + url: string, + channel: 'user' | 'localTimeline' | 'hybridTimeline' | 'globalTimeline' | 'conversation' | 'list', + accessToken: string, + listId: string | undefined, + userAgent: string, + proxyConfig: ProxyConfig | false = false + ) { + super() + this.url = url + this.parser = new Parser() + this.channel = channel + this.headers = { + 'User-Agent': userAgent + } + if (listId === undefined) { + this.listId = null + } else { + this.listId = listId + } + this.proxyConfig = proxyConfig + this._accessToken = accessToken + this._reconnectInterval = 10000 + this._reconnectMaxAttempts = Infinity + this._reconnectCurrentAttempts = 0 + this._connectionClosed = false + this._channelID = uuid() + this._pongReceivedTimestamp = dayjs() + } - /** - * Start websocket connection. - */ - public start() { - this._connectionClosed = false; - this._resetRetryParams(); - this._startWebSocketConnection(); - } + /** + * Start websocket connection. + */ + public start() { + this._connectionClosed = false + this._resetRetryParams() + this._startWebSocketConnection() + } - private baseUrlToHost(baseUrl: string): string { - return baseUrl.replace("https://", ""); - } + /** + * Reset connection and start new websocket connection. + */ + private _startWebSocketConnection() { + this._resetConnection() + this._setupParser() + this._client = this._connect() + this._bindSocket(this._client) + } - /** - * Reset connection and start new websocket connection. - */ - private _startWebSocketConnection() { - this._resetConnection(); - this._setupParser(); - this._client = this._connect(); - this._bindSocket(this._client); - } + /** + * Stop current connection. + */ + public stop() { + this._connectionClosed = true + this._resetConnection() + this._resetRetryParams() + } - /** - * 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 + } - /** - * 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() + } + } - if (this.parser) { - this.parser.removeAllListeners(); - } - } + /** + * Resets the parameters used in reconnect. + */ + private _resetRetryParams() { + this._reconnectCurrentAttempts = 0 + } - /** - * Resets the parameters used in reconnect. - */ - private _resetRetryParams() { - this._reconnectCurrentAttempts = 0; - } + /** + * Connect to the endpoint. + */ + private _connect(): WS { + let options: WS.ClientOptions = { + headers: this.headers + } + if (this.proxyConfig) { + options = Object.assign(options, { + agent: proxyAgent(this.proxyConfig) + }) + } + const cli: WS = new WS(`${this.url}?i=${this._accessToken}`, options) + return cli + } - /** - * Connect to the endpoint. - */ - private _connect(): WS { - let options: WS.ClientOptions = { - headers: this.headers, - }; - if (this.proxyConfig) { - options = Object.assign(options, { - agent: proxyAgent(this.proxyConfig), - }); - } - const cli: WS = new WS(`${this.url}?i=${this._accessToken}`, options); - return cli; - } + /** + * Connect specified channels in websocket. + */ + private _channel() { + if (!this._client) { + return + } + switch (this.channel) { + case 'conversation': + this._client.send( + JSON.stringify({ + type: 'connect', + body: { + channel: 'main', + id: this._channelID + } + }) + ) + break + case 'user': + this._client.send( + JSON.stringify({ + type: 'connect', + body: { + channel: 'main', + id: this._channelID + } + }) + ) + this._client.send( + JSON.stringify({ + type: 'connect', + body: { + channel: 'homeTimeline', + id: this._channelID + } + }) + ) + break + case 'list': + this._client.send( + JSON.stringify({ + type: 'connect', + body: { + channel: 'userList', + id: this._channelID, + params: { + listId: this.listId + } + } + }) + ) + break + default: + this._client.send( + JSON.stringify({ + type: 'connect', + body: { + channel: this.channel, + id: this._channelID + } + }) + ) + break + } + } - /** - * Connect specified channels in websocket. - */ - private _channel() { - if (!this._client) { - return; - } - switch (this.channel) { - case "conversation": - this._client.send( - JSON.stringify({ - type: "connect", - body: { - channel: "main", - id: this._channelID, - }, - }), - ); - break; - case "user": - this._client.send( - JSON.stringify({ - type: "connect", - body: { - channel: "main", - id: this._channelID, - }, - }), - ); - this._client.send( - JSON.stringify({ - type: "connect", - body: { - channel: "homeTimeline", - id: this._channelID, - }, - }), - ); - break; - case "list": - this._client.send( - JSON.stringify({ - type: "connect", - body: { - channel: "userList", - id: this._channelID, - params: { - listId: this.listId, - }, - }, - }), - ); - break; - default: - this._client.send( - JSON.stringify({ - type: "connect", - body: { - channel: this.channel, - id: this._channelID, - }, - }), - ); - break; - } - } + /** + * Reconnects to the same endpoint. + */ - /** - * 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 + } - 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._bindSocket(this._client) + } + }, this._reconnectInterval) + } - 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._bindSocket(this._client); - } - }, this._reconnectInterval); - } + /** + * Clear binding event for websocket 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') + } + } - /** - * Clear binding event for websocket 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) => { + if (code === 1000) { + this.emit('close', {}) + } else { + console.log(`Closed connection with ${code}`) + 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', {}) + this._channel() + // Call first ping event. + setTimeout(() => { + client.ping('') + }, 10000) + }) + client.on('message', (data: WS.Data, isBinary: boolean) => { + this.parser.parse(data, isBinary, this._channelID) + }) + client.on('error', (err: Error) => { + this.emit('error', err) + }) + } - /** - * Bind event for web socket client. - * @param client A WebSocket instance. - */ - private _bindSocket(client: WS) { - client.on("close", (code: number, _reason: Buffer) => { - if (code === 1000) { - this.emit("close", {}); - } else { - console.log(`Closed connection with ${code}`); - 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", {}); - this._channel(); - // Call first ping event. - setTimeout(() => { - client.ping(""); - }, 10000); - }); - client.on("message", (data: WS.Data, isBinary: boolean) => { - this.parser.parse(data, isBinary, this._channelID); - }); - client.on("error", (err: Error) => { - this.emit("error", err); - }); - } + /** + * Set up parser when receive message. + */ + private _setupParser() { + this.parser.on('update', (note: MisskeyAPI.Entity.Note) => { + this.emit('update', MisskeyAPI.Converter.note(note)) + }) + this.parser.on('notification', (notification: MisskeyAPI.Entity.Notification) => { + const n = MisskeyAPI.Converter.notification(notification) + if (n instanceof UnknownNotificationTypeError) { + console.warn(`Unknown notification event has received: ${notification}`) + } else { + this.emit('notification', n) + } + }) + this.parser.on('conversation', (note: MisskeyAPI.Entity.Note) => { + this.emit('conversation', MisskeyAPI.Converter.noteToConversation(note)) + }) + this.parser.on('error', (err: Error) => { + this.emit('parser-error', err) + }) + } - /** - * Set up parser when receive message. - */ - private _setupParser() { - this.parser.on("update", (note: MisskeyAPI.Entity.Note) => { - this.emit( - "update", - this._converter.note(note, this.baseUrlToHost(this.url)), - ); - }); - this.parser.on( - "notification", - (notification: MisskeyAPI.Entity.Notification) => { - this.emit( - "notification", - this._converter.notification( - notification, - this.baseUrlToHost(this.url), - ), - ); - }, - ); - this.parser.on("conversation", (note: MisskeyAPI.Entity.Note) => { - this.emit( - "conversation", - this._converter.noteToConversation(note, this.baseUrlToHost(this.url)), - ); - }); - this.parser.on("error", (err: Error) => { - this.emit("parser-error", err); - }); - } - - /** - * 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); - } - } - } + /** + * 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) + } + } + } } /** @@ -367,92 +330,84 @@ export default class WebSocket * This class provides parser for websocket message. */ export class Parser extends EventEmitter { - /** - * @param message Message body of websocket. - * @param channelID Parse only messages which has same channelID. - */ - public parse(data: WS.Data, isBinary: boolean, channelID: string) { - const message = isBinary ? data : data.toString(); - if (typeof message !== "string") { - this.emit("heartbeat", {}); - return; - } + /** + * @param message Message body of websocket. + * @param channelID Parse only messages which has same channelID. + */ + public parse(data: WS.Data, isBinary: boolean, channelID: string) { + const message = isBinary ? data : data.toString() + if (typeof message !== 'string') { + this.emit('heartbeat', {}) + return + } - if (message === "") { - this.emit("heartbeat", {}); - return; - } + if (message === '') { + this.emit('heartbeat', {}) + return + } - let obj: { - type: string; - body: { - id: string; - type: string; - body: any; - }; - }; - let body: { - id: string; - type: string; - body: any; - }; + let obj: { + type: string + body: { + id: string + type: string + body: any + } + } + let body: { + id: string + type: string + body: any + } - try { - obj = JSON.parse(message); - if (obj.type !== "channel") { - return; - } - if (!obj.body) { - return; - } - body = obj.body; - if (body.id !== channelID) { - return; - } - } catch (err) { - this.emit( - "error", - new Error( - `Error parsing websocket reply: ${message}, error message: ${err}`, - ), - ); - return; - } + try { + obj = JSON.parse(message) + if (obj.type !== 'channel') { + return + } + if (!obj.body) { + return + } + body = obj.body + if (body.id !== channelID) { + return + } + } catch (err) { + this.emit('error', new Error(`Error parsing websocket reply: ${message}, error message: ${err}`)) + return + } - switch (body.type) { - case "note": - this.emit("update", body.body as MisskeyAPI.Entity.Note); - break; - case "notification": - this.emit("notification", body.body as MisskeyAPI.Entity.Notification); - break; - case "mention": { - const note = body.body as MisskeyAPI.Entity.Note; - if (note.visibility === "specified") { - this.emit("conversation", note); - } - break; - } - // When renote and followed event, the same notification will be received. - case "renote": - case "followed": - case "follow": - case "unfollow": - case "receiveFollowRequest": - case "meUpdated": - case "readAllNotifications": - case "readAllUnreadSpecifiedNotes": - case "readAllAntennas": - case "readAllUnreadMentions": - case "unreadNotification": - // Ignore these events - break; - default: - this.emit( - "error", - new Error(`Unknown event has received: ${JSON.stringify(body)}`), - ); - break; - } - } + switch (body.type) { + case 'note': + this.emit('update', body.body as MisskeyAPI.Entity.Note) + break + case 'notification': + this.emit('notification', body.body as MisskeyAPI.Entity.Notification) + break + case 'mention': { + const note = body.body as MisskeyAPI.Entity.Note + if (note.visibility === 'specified') { + this.emit('conversation', note) + } + break + } + // When renote and followed event, the same notification will be received. + case 'renote': + case 'followed': + case 'follow': + case 'unfollow': + case 'receiveFollowRequest': + case 'meUpdated': + case 'readAllNotifications': + case 'readAllUnreadSpecifiedNotes': + case 'readAllAntennas': + case 'readAllUnreadMentions': + case 'unreadNotification': + // Ignore these events + break + default: + this.emit('error', new Error(`Unknown event has received: ${JSON.stringify(body)}`)) + break + } + } } diff --git a/packages/megalodon/src/notification.ts b/packages/megalodon/src/notification.ts index 84cd23e40d..7c08c5d47f 100644 --- a/packages/megalodon/src/notification.ts +++ b/packages/megalodon/src/notification.ts @@ -1,14 +1,26 @@ -import Entity from "./entity"; +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 Reaction: Entity.NotificationType = "reaction"; - export const FollowRequest: Entity.NotificationType = "follow_request"; - export const Status: Entity.NotificationType = "status"; - export const Poll: Entity.NotificationType = "poll"; + 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 default NotificationType; +export class UnknownNotificationTypeError extends Error { + constructor() { + super() + Object.setPrototypeOf(this, UnknownNotificationTypeError.prototype) + } +} + +export default NotificationType diff --git a/packages/megalodon/src/oauth.ts b/packages/megalodon/src/oauth.ts index f0df721f0a..3c3ceb9deb 100644 --- a/packages/megalodon/src/oauth.ts +++ b/packages/megalodon/src/oauth.ts @@ -3,121 +3,107 @@ * Response data when oauth request. **/ namespace OAuth { - export type AppDataFromServer = { - id: string; - name: string; - website: string | null; - redirect_uri: string; - client_id: string; - client_secret: string; - }; + export type AppDataFromServer = { + id: string + name: string + website: string | null + redirect_uri: string + client_id: string + client_secret: string + } - export type TokenDataFromServer = { - access_token: string; - token_type: string; - scope: string; - created_at: number; - expires_in: number | null; - refresh_token: string | null; - }; + export type TokenDataFromServer = { + access_token: string + token_type: string + scope: string + created_at: number + expires_in: number | null + refresh_token: string | null + } - export class AppData { - public url: string | null; - public session_token: string | null; - constructor( - public id: string, - public name: string, - public website: string | null, - public redirect_uri: string, - public client_id: string, - public client_secret: string, - ) { - this.url = null; - this.session_token = null; - } + export class AppData { + public url: string | null + public session_token: string | null + constructor( + public id: string, + public name: string, + public website: string | null, + public redirect_uri: string, + public client_id: string, + public client_secret: string + ) { + this.url = null + this.session_token = null + } - /** - * Serialize raw application data from server - * @param raw from server - */ - static from(raw: AppDataFromServer) { - return new this( - raw.id, - raw.name, - raw.website, - raw.redirect_uri, - raw.client_id, - raw.client_secret, - ); - } + /** + * Serialize raw application data from server + * @param raw from server + */ + static from(raw: AppDataFromServer) { + return new this(raw.id, raw.name, raw.website, raw.redirect_uri, raw.client_id, raw.client_secret) + } - get redirectUri() { - return this.redirect_uri; - } - get clientId() { - return this.client_id; - } - get clientSecret() { - return this.client_secret; - } - } + get redirectUri() { + return this.redirect_uri + } + get clientId() { + return this.client_id + } + get clientSecret() { + return this.client_secret + } + } - export class TokenData { - public _scope: string; - constructor( - public access_token: string, - public token_type: string, - scope: string, - public created_at: number, - public expires_in: number | null = null, - public refresh_token: string | null = null, - ) { - this._scope = scope; - } + export class TokenData { + public _scope: string + constructor( + public access_token: string, + public token_type: string, + scope: string, + public created_at: number, + public expires_in: number | null = null, + public refresh_token: string | null = null + ) { + this._scope = scope + } - /** - * Serialize raw token data from server - * @param raw from server - */ - static from(raw: TokenDataFromServer) { - return new this( - raw.access_token, - raw.token_type, - raw.scope, - raw.created_at, - raw.expires_in, - raw.refresh_token, - ); - } + /** + * Serialize raw token data from server + * @param raw from server + */ + static from(raw: TokenDataFromServer) { + return new this(raw.access_token, raw.token_type, raw.scope, raw.created_at, raw.expires_in, raw.refresh_token) + } - /** - * OAuth Aceess Token - */ - get accessToken() { - return this.access_token; - } - get tokenType() { - return this.token_type; - } - get scope() { - return this._scope; - } - /** - * Application ID - */ - get createdAt() { - return this.created_at; - } - get expiresIn() { - return this.expires_in; - } - /** - * OAuth Refresh Token - */ - get refreshToken() { - return this.refresh_token; - } - } + /** + * OAuth Aceess Token + */ + get accessToken() { + return this.access_token + } + get tokenType() { + return this.token_type + } + get scope() { + return this._scope + } + /** + * Application ID + */ + get createdAt() { + return this.created_at + } + get expiresIn() { + return this.expires_in + } + /** + * OAuth Refresh Token + */ + get refreshToken() { + return this.refresh_token + } + } } -export default OAuth; +export default OAuth diff --git a/packages/megalodon/src/parser.ts b/packages/megalodon/src/parser.ts index 2ddf2ac2e6..67abff797d 100644 --- a/packages/megalodon/src/parser.ts +++ b/packages/megalodon/src/parser.ts @@ -1,94 +1,86 @@ -import { EventEmitter } from "events"; -import Entity from "./entity"; +import { EventEmitter } from 'events' +import Entity from './entity' /** * Parser * Parse response data in streaming. **/ export class Parser extends EventEmitter { - private message: string; + private message: string - constructor() { - super(); - this.message = ""; - } + constructor() { + super() + this.message = '' + } - public parse(chunk: string) { - // skip heartbeats - if (chunk === ":thump\n") { - this.emit("heartbeat", {}); - return; - } + public parse(chunk: string) { + // skip heartbeats + if (chunk === ':thump\n') { + this.emit('heartbeat', {}) + return + } - this.message += chunk; - chunk = this.message; + this.message += chunk + chunk = this.message - const size: number = chunk.length; - let start = 0; - let offset = 0; - let curr: string | undefined; - let next: string | undefined; + const size: number = chunk.length + let start: number = 0 + let offset: number = 0 + let curr: string | undefined + let next: string | undefined - while (offset < size) { - curr = chunk[offset]; - next = chunk[offset + 1]; + while (offset < size) { + curr = chunk[offset] + next = chunk[offset + 1] - if (curr === "\n" && next === "\n") { - const piece: string = chunk.slice(start, offset); + if (curr === '\n' && next === '\n') { + const piece: string = chunk.slice(start, offset) - offset += 2; - start = offset; + offset += 2 + start = offset - if (!piece.length) continue; // empty object + if (!piece.length) continue // empty object - const root: Array = piece.split("\n"); + const root: Array = piece.split('\n') - // should never happen, as long as mastodon doesn't change API messages - if (root.length !== 2) continue; + // should never happen, as long as mastodon doesn't change API messages + if (root.length !== 2) continue - // remove event and data markers - const event: string = root[0].substr(7); - const data: string = root[1].substr(6); + // remove event and data markers + const event: string = root[0].substr(7) + const data: string = root[1].substr(6) - let jsonObj = {}; - try { - jsonObj = JSON.parse(data); - } catch (err) { - // delete event does not have json object - if (event !== "delete") { - this.emit( - "error", - new Error( - `Error parsing API reply: '${piece}', error message: '${err}'`, - ), - ); - continue; - } - } - switch (event) { - case "update": - this.emit("update", jsonObj as Entity.Status); - break; - case "notification": - this.emit("notification", jsonObj as Entity.Notification); - break; - case "conversation": - this.emit("conversation", jsonObj as Entity.Conversation); - break; - case "delete": - // When delete, data is an ID of the deleted status - this.emit("delete", data); - break; - default: - this.emit( - "error", - new Error(`Unknown event has received: ${event}`), - ); - continue; - } - } - offset++; - } - this.message = chunk.slice(start, size); - } + let jsonObj = {} + try { + jsonObj = JSON.parse(data) + } catch (err) { + // delete event does not have json object + if (event !== 'delete') { + this.emit('error', new Error(`Error parsing API reply: '${piece}', error message: '${err}'`)) + continue + } + } + switch (event) { + case 'update': + this.emit('update', jsonObj as Entity.Status) + break + case 'notification': + this.emit('notification', jsonObj as Entity.Notification) + break + case 'conversation': + this.emit('conversation', jsonObj as Entity.Conversation) + break + case 'delete': + // When delete, data is an ID of the deleted status + this.emit('delete', data) + break + default: + this.emit('error', new Error(`Unknown event has received: ${event}`)) + continue + } + } + offset++ + } + this.message = chunk.slice(start, size) + } } diff --git a/packages/megalodon/src/pleroma.ts b/packages/megalodon/src/pleroma.ts new file mode 100644 index 0000000000..265c7d3c0b --- /dev/null +++ b/packages/megalodon/src/pleroma.ts @@ -0,0 +1,3217 @@ +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 new file mode 100644 index 0000000000..99d964353e --- /dev/null +++ b/packages/megalodon/src/pleroma/api_client.ts @@ -0,0 +1,823 @@ +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, + 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 new file mode 100644 index 0000000000..29d42643fc --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/account.ts @@ -0,0 +1,31 @@ +/// +/// +/// +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 new file mode 100644 index 0000000000..f70ad168eb --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/activity.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..247ad90c5b --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/announcement.ts @@ -0,0 +1,39 @@ +/// + +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 new file mode 100644 index 0000000000..055592d6ce --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/application.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..8784979cbb --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/async_attachment.ts @@ -0,0 +1,14 @@ +/// +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 new file mode 100644 index 0000000000..18d4371daf --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/attachment.ts @@ -0,0 +1,49 @@ +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 new file mode 100644 index 0000000000..9aca99a8c8 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/card.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000000..f297bd2c17 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/context.ts @@ -0,0 +1,8 @@ +/// + +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 new file mode 100644 index 0000000000..624e6da389 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/conversation.ts @@ -0,0 +1,11 @@ +/// +/// + +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 new file mode 100644 index 0000000000..43ea22d770 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/emoji.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..a42e27f9d0 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/featured_tag.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..01803078a9 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/field.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..08a18089c2 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/filter.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000000..9aaaeb8def --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/history.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..463fdc6817 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/identity_proof.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000000..0b57e805e9 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/instance.ts @@ -0,0 +1,46 @@ +/// +/// +/// + +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 new file mode 100644 index 0000000000..a3d4362d9e --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/list.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..720d4a9055 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/marker.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000000..0d68b4ec21 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/mention.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..edfa456deb --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/notification.ts @@ -0,0 +1,16 @@ +/// +/// + +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 new file mode 100644 index 0000000000..82e0182adc --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/poll.ts @@ -0,0 +1,13 @@ +/// + +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 new file mode 100644 index 0000000000..69717ca0f3 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/poll_option.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..99f8d6bca1 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/preferences.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000000..b3e14e68a3 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/push_subscription.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000000..662600f252 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/reaction.ts @@ -0,0 +1,10 @@ +/// + +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 new file mode 100644 index 0000000000..039f8ec74b --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/relationship.ts @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000000..5b9c650a16 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/report.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..cd42e3b090 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/results.ts @@ -0,0 +1,11 @@ +/// +/// +/// + +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 new file mode 100644 index 0000000000..547d35fd8f --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/scheduled_status.ts @@ -0,0 +1,10 @@ +/// +/// +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 new file mode 100644 index 0000000000..f2fa74ab70 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/source.ts @@ -0,0 +1,10 @@ +/// +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 new file mode 100644 index 0000000000..ab3e778454 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/stats.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..1949ec954c --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/status.ts @@ -0,0 +1,64 @@ +/// +/// +/// +/// +/// +/// +/// +/// + +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 + 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 new file mode 100644 index 0000000000..eda13a0b9b --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/status_params.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000000..57d2bea781 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/status_source.ts @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000000..e323ec72c3 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/tag.ts @@ -0,0 +1,10 @@ +/// + +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 new file mode 100644 index 0000000000..0ac565b517 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/token.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..7ad6faf2b0 --- /dev/null +++ b/packages/megalodon/src/pleroma/entities/urls.ts @@ -0,0 +1,5 @@ +namespace PleromaEntity { + export type URLs = { + streaming_api: string + } +} diff --git a/packages/megalodon/src/pleroma/entity.ts b/packages/megalodon/src/pleroma/entity.ts new file mode 100644 index 0000000000..bd486f62bd --- /dev/null +++ b/packages/megalodon/src/pleroma/entity.ts @@ -0,0 +1,39 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// > +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// + +export default PleromaEntity diff --git a/packages/megalodon/src/pleroma/notification.ts b/packages/megalodon/src/pleroma/notification.ts new file mode 100644 index 0000000000..2dad51a6e3 --- /dev/null +++ b/packages/megalodon/src/pleroma/notification.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000000..f96ea5dc56 --- /dev/null +++ b/packages/megalodon/src/pleroma/web_socket.ts @@ -0,0 +1,349 @@ +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/src/proxy_config.ts b/packages/megalodon/src/proxy_config.ts index fadbcf084e..c9ae01b736 100644 --- a/packages/megalodon/src/proxy_config.ts +++ b/packages/megalodon/src/proxy_config.ts @@ -1,92 +1,50 @@ -import { HttpsProxyAgent, HttpsProxyAgentOptions } from "https-proxy-agent"; -import { SocksProxyAgent, SocksProxyAgentOptions } from "socks-proxy-agent"; +import { HttpsProxyAgent } from 'https-proxy-agent' +import { SocksProxyAgent } from 'socks-proxy-agent' export type ProxyConfig = { - host: string; - port: number; - auth?: { - username: string; - password: string; - }; - protocol: - | "http" - | "https" - | "socks4" - | "socks4a" - | "socks5" - | "socks5h" - | "socks"; -}; + host: string + port: number + auth?: { + username: string + password: string + } + protocol: 'http' | 'https' | 'socks4' | 'socks4a' | 'socks5' | 'socks5h' | 'socks' +} class ProxyProtocolError extends Error {} -const proxyAgent = ( - proxyConfig: ProxyConfig, -): HttpsProxyAgent | SocksProxyAgent => { - switch (proxyConfig.protocol) { - case "http": { - let options: HttpsProxyAgentOptions = { - host: proxyConfig.host, - port: proxyConfig.port, - secureProxy: false, - }; - if (proxyConfig.auth) { - options = Object.assign(options, { - auth: `${proxyConfig.auth.username}:${proxyConfig.auth.password}`, - }); - } - const httpsAgent = new HttpsProxyAgent(options); - return httpsAgent; - } - case "https": { - let options: HttpsProxyAgentOptions = { - host: proxyConfig.host, - port: proxyConfig.port, - secureProxy: true, - }; - if (proxyConfig.auth) { - options = Object.assign(options, { - auth: `${proxyConfig.auth.username}:${proxyConfig.auth.password}`, - }); - } - const httpsAgent = new HttpsProxyAgent(options); - return httpsAgent; - } - case "socks4": - case "socks4a": { - let options: SocksProxyAgentOptions = { - type: 4, - hostname: proxyConfig.host, - port: proxyConfig.port, - }; - if (proxyConfig.auth) { - options = Object.assign(options, { - userId: proxyConfig.auth.username, - password: proxyConfig.auth.password, - }); - } - const socksAgent = new SocksProxyAgent(options); - return socksAgent; - } - case "socks5": - case "socks5h": - case "socks": { - let options: SocksProxyAgentOptions = { - type: 5, - hostname: proxyConfig.host, - port: proxyConfig.port, - }; - if (proxyConfig.auth) { - options = Object.assign(options, { - userId: proxyConfig.auth.username, - password: proxyConfig.auth.password, - }); - } - const socksAgent = new SocksProxyAgent(options); - return socksAgent; - } - default: - throw new ProxyProtocolError("protocol is not accepted"); - } -}; -export default proxyAgent; +const proxyAgent = (proxyConfig: ProxyConfig): HttpsProxyAgent<'http'> | HttpsProxyAgent<'https'> | SocksProxyAgent => { + switch (proxyConfig.protocol) { + case 'http': { + let url = new URL(`http://${proxyConfig.host}:${proxyConfig.port}`) + if (proxyConfig.auth) { + url = new URL(`http://${proxyConfig.auth.username}:${proxyConfig.auth.password}@${proxyConfig.host}:${proxyConfig.port}`) + } + const httpsAgent = new HttpsProxyAgent<'http'>(url) + return httpsAgent + } + case 'https': { + let url = new URL(`https://${proxyConfig.host}:${proxyConfig.port}`) + if (proxyConfig.auth) { + url = new URL(`https://${proxyConfig.auth.username}:${proxyConfig.auth.password}@${proxyConfig.host}:${proxyConfig.port}`) + } + const httpsAgent = new HttpsProxyAgent<'https'>(url) + return httpsAgent + } + case 'socks4': + case 'socks4a': + case 'socks5': + case 'socks5h': + case 'socks': { + let url = `socks://${proxyConfig.host}:${proxyConfig.port}` + if (proxyConfig.auth) { + url = `socks://${proxyConfig.auth.username}:${proxyConfig.auth.password}@${proxyConfig.host}:${proxyConfig.port}` + } + const socksAgent = new SocksProxyAgent(url) + return socksAgent + } + default: + throw new ProxyProtocolError('protocol is not accepted') + } +} +export default proxyAgent diff --git a/packages/megalodon/src/response.ts b/packages/megalodon/src/response.ts index 13fd8ab574..e275962034 100644 --- a/packages/megalodon/src/response.ts +++ b/packages/megalodon/src/response.ts @@ -1,8 +1,8 @@ type Response = { - data: T; - status: number; - statusText: string; - headers: any; -}; + data: T + status: number + statusText: string + headers: any +} -export default Response; +export default Response diff --git a/packages/megalodon/test/integration/cancel.spec.ts b/packages/megalodon/test/integration/cancel.spec.ts new file mode 100644 index 0000000000..efc9d49770 --- /dev/null +++ b/packages/megalodon/test/integration/cancel.spec.ts @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000000..17a0722780 --- /dev/null +++ b/packages/megalodon/test/integration/cancelWorker.ts @@ -0,0 +1,5 @@ +import MastodonAPI from '@/mastodon/api_client' + +export function cancel(client: MastodonAPI.Client) { + return client.cancel() +} diff --git a/packages/megalodon/test/integration/detector.spec.ts b/packages/megalodon/test/integration/detector.spec.ts new file mode 100644 index 0000000000..86c32622e9 --- /dev/null +++ b/packages/megalodon/test/integration/detector.spec.ts @@ -0,0 +1,67 @@ +import { detector } from '../../src/index' + +describe('detector', () => { + describe('mastodon', () => { + const url = 'https://mastodon.social' + it('should be mastodon', async () => { + const mastodon = await detector(url) + expect(mastodon).toEqual('mastodon') + }) + }) + + describe('pleroma', () => { + const url = 'https://pleroma.io' + it('should be pleroma', async () => { + const pleroma = await detector(url) + expect(pleroma).toEqual('pleroma') + }) + }) + + describe('misskey', () => { + const url = 'https://misskey.io' + it('should be misskey', async () => { + const misskey = await detector(url) + expect(misskey).toEqual('misskey') + }) + }) + + describe('fedibird', () => { + const url = 'https://fedibird.com' + it('should be mastodon', async () => { + const fedibird = await detector(url) + expect(fedibird).toEqual('mastodon') + }, 20000) + }) + + describe('friendica', () => { + const url = 'https://squeet.me' + it('should be friendica', async () => { + const friendica = await detector(url) + expect(friendica).toEqual('friendica') + }) + }) + + describe('akkoma', () => { + const url = 'https://pleroma.noellabo.jp' + it('should be akkoma', async () => { + const akkoma = await detector(url) + expect(akkoma).toEqual('pleroma') + }) + }) + + describe('wildebeest', () => { + const url = 'https://wildebeest.mirror-kt.dev' + it('should be mastodon', async () => { + const wildebeest = await detector(url) + expect(wildebeest).toEqual('mastodon') + }) + }) + + describe('unknown', () => { + const url = 'https://google.com' + it('should be null', async () => { + const unknown = detector(url) + await expect(unknown).rejects.toThrow() + }) + }) +}) diff --git a/packages/megalodon/test/integration/mastodon.spec.ts b/packages/megalodon/test/integration/mastodon.spec.ts new file mode 100644 index 0000000000..172d11a863 --- /dev/null +++ b/packages/megalodon/test/integration/mastodon.spec.ts @@ -0,0 +1,218 @@ +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 new file mode 100644 index 0000000000..950105152c --- /dev/null +++ b/packages/megalodon/test/integration/mastodon/api_client.spec.ts @@ -0,0 +1,176 @@ +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', + 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/megalodon.spec.ts b/packages/megalodon/test/integration/megalodon.spec.ts deleted file mode 100644 index 8964535509..0000000000 --- a/packages/megalodon/test/integration/megalodon.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { detector } from '../../src/index' - -describe('detector', () => { - describe('mastodon', () => { - const url = 'https://fedibird.com' - it('should be mastodon', async () => { - const mastodon = await detector(url) - expect(mastodon).toEqual('mastodon') - }) - }) - - describe('pleroma', () => { - const url = 'https://pleroma.soykaf.com' - it('should be pleroma', async () => { - const pleroma = await detector(url) - expect(pleroma).toEqual('pleroma') - }) - }) - - describe('misskey', () => { - const url = 'https://misskey.io' - it('should be misskey', async () => { - const misskey = await detector(url) - expect(misskey).toEqual('misskey') - }) - }) -}) diff --git a/packages/megalodon/test/integration/misskey.spec.ts b/packages/megalodon/test/integration/misskey.spec.ts index 0ec1288428..ed3b9a40f2 100644 --- a/packages/megalodon/test/integration/misskey.spec.ts +++ b/packages/megalodon/test/integration/misskey.spec.ts @@ -2,7 +2,7 @@ import MisskeyEntity from '@/misskey/entity' import MisskeyNotificationType from '@/misskey/notification' import Misskey from '@/misskey' import MegalodonNotificationType from '@/notification' -import axios, { AxiosResponse } from 'axios' +import axios, { AxiosHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios' jest.mock('axios') @@ -27,6 +27,7 @@ const note: MisskeyEntity.Note = { renoteCount: 0, repliesCount: 0, reactions: {}, + reactionEmojis: {}, emojis: [], fileIds: [], files: [], @@ -93,7 +94,7 @@ const pollVote: MisskeyEntity.Notification = { createdAt: '2021-02-01T01:49:29', userId: user.id, user: user, - type: MisskeyNotificationType.PollEnded, + type: MisskeyNotificationType.PollVote, note: note } @@ -163,12 +164,12 @@ describe('getNotifications', () => { }, { event: reaction, - expected: MegalodonNotificationType.Reaction, + expected: MegalodonNotificationType.EmojiReaction, title: 'reaction' }, { event: pollVote, - expected: MegalodonNotificationType.Poll, + expected: MegalodonNotificationType.PollVote, title: 'pollVote' }, { @@ -180,25 +181,38 @@ describe('getNotifications', () => { event: followRequestAccepted, expected: MegalodonNotificationType.Follow, title: 'followRequestAccepted' - }, - { - event: groupInvited, - expected: MisskeyNotificationType.GroupInvited, - title: 'groupInvited' } ] 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: config } ;(axios.post as any).mockResolvedValue(mockResponse) const res = await client.getNotifications() expect(res.data[0].type).toEqual(c.expected) }) }) + it('groupInvited event should be ignored', async () => { + const config: InternalAxiosRequestConfig = { + headers: new AxiosHeaders() + } + const mockResponse: AxiosResponse> = { + data: [groupInvited], + status: 200, + statusText: '200OK', + headers: {}, + config: config + } + ;(axios.post as any).mockResolvedValue(mockResponse) + const res = await client.getNotifications() + expect(res.data).toEqual([]) + }) }) diff --git a/packages/megalodon/test/integration/pleroma.spec.ts b/packages/megalodon/test/integration/pleroma.spec.ts new file mode 100644 index 0000000000..1e1f449e17 --- /dev/null +++ b/packages/megalodon/test/integration/pleroma.spec.ts @@ -0,0 +1,222 @@ +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 new file mode 100644 index 0000000000..311f60d128 --- /dev/null +++ b/packages/megalodon/test/unit/mastodon.spec.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..1e3c6b5237 --- /dev/null +++ b/packages/megalodon/test/unit/mastodon/api_client.spec.ts @@ -0,0 +1,80 @@ +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/misskey/api_client.spec.ts b/packages/megalodon/test/unit/misskey/api_client.spec.ts index 7cf33b983d..38039385cb 100644 --- a/packages/megalodon/test/unit/misskey/api_client.spec.ts +++ b/packages/megalodon/test/unit/misskey/api_client.spec.ts @@ -14,8 +14,6 @@ const user: MisskeyEntity.User = { emojis: [] } -const converter: MisskeyAPI.Converter = new MisskeyAPI.Converter("https://example.com") - describe('api_client', () => { describe('notification', () => { describe('encode', () => { @@ -34,7 +32,7 @@ describe('api_client', () => { dist: MisskeyNotificationType.Reaction }, { - src: MegalodonNotificationType.Reaction, + src: MegalodonNotificationType.EmojiReaction, dist: MisskeyNotificationType.Reaction }, { @@ -42,8 +40,8 @@ describe('api_client', () => { dist: MisskeyNotificationType.Renote }, { - src: MegalodonNotificationType.Poll, - dist: MisskeyNotificationType.PollEnded + src: MegalodonNotificationType.PollVote, + dist: MisskeyNotificationType.PollVote }, { src: MegalodonNotificationType.FollowRequest, @@ -51,7 +49,7 @@ describe('api_client', () => { } ] cases.forEach(c => { - expect(converter.encodeNotificationType(c.src)).toEqual(c.dist) + expect(MisskeyAPI.Converter.encodeNotificationType(c.src)).toEqual(c.dist) }) }) }) @@ -80,11 +78,11 @@ describe('api_client', () => { }, { src: MisskeyNotificationType.Reaction, - dist: MegalodonNotificationType.Reaction + dist: MegalodonNotificationType.EmojiReaction }, { - src: MisskeyNotificationType.PollEnded, - dist: MegalodonNotificationType.Poll + src: MisskeyNotificationType.PollVote, + dist: MegalodonNotificationType.PollVote }, { src: MisskeyNotificationType.ReceiveFollowRequest, @@ -96,7 +94,7 @@ describe('api_client', () => { } ] cases.forEach(c => { - expect(converter.decodeNotificationType(c.src)).toEqual(c.dist) + expect(MisskeyAPI.Converter.decodeNotificationType(c.src)).toEqual(c.dist) }) }) }) @@ -162,7 +160,7 @@ describe('api_client', () => { } ] - const reactions = converter.reactions(misskeyReactions) + const reactions = MisskeyAPI.Converter.reactions(misskeyReactions) expect(reactions).toEqual([ { count: 3, @@ -194,13 +192,14 @@ describe('api_client', () => { renoteCount: 0, repliesCount: 0, reactions: {}, + reactionEmojis: {}, emojis: [], fileIds: [], files: [], replyId: null, renoteId: null } - const megalodonStatus = converter.note(note, user.host || 'misskey.io') + const megalodonStatus = MisskeyAPI.Converter.note(note) expect(megalodonStatus.plain_content).toEqual(plainContent) expect(megalodonStatus.content).toEqual(content) }) @@ -218,16 +217,161 @@ describe('api_client', () => { renoteCount: 0, repliesCount: 0, reactions: {}, + reactionEmojis: {}, emojis: [], fileIds: [], files: [], replyId: null, renoteId: null } - const megalodonStatus = converter.note(note, user.host || 'misskey.io') + const megalodonStatus = MisskeyAPI.Converter.note(note) expect(megalodonStatus.plain_content).toEqual(plainContent) expect(megalodonStatus.content).toEqual(content) }) }) + describe('emoji reaction', () => { + it('reactionEmojis should be parsed', () => { + const plainContent = 'hoge\nfuga\nfuga' + const note: MisskeyEntity.Note = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: '1', + user: user, + text: plainContent, + cw: null, + visibility: 'public', + renoteCount: 0, + repliesCount: 0, + reactions: { + ':example1@.:': 1, + ':example2@example.com:': 2 + }, + reactionEmojis: { + 'example2@example.com': 'https://example.com/emoji.png' + }, + emojis: [], + fileIds: [], + files: [], + replyId: null, + renoteId: null + } + const megalodonStatus = MisskeyAPI.Converter.note(note) + expect(megalodonStatus.emojis).toEqual([ + { + shortcode: 'example2@example.com', + static_url: 'https://example.com/emoji.png', + url: 'https://example.com/emoji.png', + visible_in_picker: true, + category: '' + } + ]) + expect(megalodonStatus.emoji_reactions).toEqual([ + { + count: 1, + me: false, + name: ':example1@.:' + }, + { + count: 2, + me: false, + name: ':example2@example.com:' + } + ]) + }) + }) + describe('emoji', () => { + it('emojis in array format should be parsed', () => { + const plainContent = 'hoge\nfuga\nfuga' + const note: MisskeyEntity.Note = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: '1', + user: user, + text: plainContent, + cw: null, + visibility: 'public', + renoteCount: 0, + repliesCount: 0, + reactions: {}, + reactionEmojis: {}, + emojis: [ + { + aliases: [], + name: ':example1:', + url: 'https://example.com/emoji1.png', + category: '', + }, + { + aliases: [], + name: ':example2:', + url: 'https://example.com/emoji2.png', + category: '', + }, + ], + fileIds: [], + files: [], + replyId: null, + renoteId: null + } + const megalodonStatus = MisskeyAPI.Converter.note(note) + expect(megalodonStatus.emojis).toEqual([ + { + shortcode: ':example1:', + static_url: 'https://example.com/emoji1.png', + url: 'https://example.com/emoji1.png', + visible_in_picker: true, + category: '' + }, + { + shortcode: ':example2:', + static_url: 'https://example.com/emoji2.png', + url: 'https://example.com/emoji2.png', + visible_in_picker: true, + category: '' + } + ]) + }) + it('emojis in object format should be parsed', () => { + const plainContent = 'hoge\nfuga\nfuga' + const note: MisskeyEntity.Note = { + id: '1', + createdAt: '2021-02-01T01:49:29', + userId: '1', + user: user, + text: plainContent, + cw: null, + visibility: 'public', + renoteCount: 0, + repliesCount: 0, + reactions: {}, + reactionEmojis: {}, + emojis: { + ':example1:': 'https://example.com/emoji1.png', + ':example2:': 'https://example.com/emoji2.png', + }, + fileIds: [], + files: [], + replyId: null, + renoteId: null + } + const megalodonStatus = MisskeyAPI.Converter.note(note) + expect(megalodonStatus.emojis).toEqual([ + { + shortcode: ':example1:', + static_url: 'https://example.com/emoji1.png', + url: 'https://example.com/emoji1.png', + visible_in_picker: true, + category: '' + }, + { + shortcode: ':example2:', + static_url: 'https://example.com/emoji2.png', + url: 'https://example.com/emoji2.png', + visible_in_picker: true, + category: '' + } + ]) + }) + }) }) }) diff --git a/packages/megalodon/test/unit/parser.spec.ts b/packages/megalodon/test/unit/parser.spec.ts index 5174a647c6..94c1d98029 100644 --- a/packages/megalodon/test/unit/parser.spec.ts +++ b/packages/megalodon/test/unit/parser.spec.ts @@ -7,6 +7,10 @@ const account: Entity.Account = { 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, @@ -54,9 +58,9 @@ const status: Entity.Status = { } as Entity.Application, language: null, pinned: null, - reactions: [], + emoji_reactions: [], bookmarked: false, - quote: null + quote: false } const notification: Entity.Notification = { diff --git a/packages/megalodon/test/unit/pleroma/api_client.spec.ts b/packages/megalodon/test/unit/pleroma/api_client.spec.ts new file mode 100644 index 0000000000..98c9ec8e4c --- /dev/null +++ b/packages/megalodon/test/unit/pleroma/api_client.spec.ts @@ -0,0 +1,226 @@ +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 new file mode 100644 index 0000000000..bb9f997a57 --- /dev/null +++ b/packages/megalodon/test/unit/webo_socket.spec.ts @@ -0,0 +1,184 @@ +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', + 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) + }) + }) + }) + }) +}) diff --git a/packages/megalodon/tsconfig.json b/packages/megalodon/tsconfig.json index 5a9bfbde9a..7f6943225e 100644 --- a/packages/megalodon/tsconfig.json +++ b/packages/megalodon/tsconfig.json @@ -3,7 +3,7 @@ /* Basic Options */ "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ - "lib": ["es2021", "dom"], /* Specify library files to be included in the compilation. */ + "lib": ["es6", "dom"], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ @@ -30,7 +30,7 @@ "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ /* Additional Checks */ - "noUnusedLocals": false, /* Report errors on unused locals. */ + "noUnusedLocals": true, /* Report errors on unused locals. */ "noUnusedParameters": true, /* Report errors on unused parameters. */ "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42faf9301c..c7bbc9531b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,26 +1008,23 @@ importers: packages/megalodon: dependencies: '@types/oauth': - specifier: ^0.9.0 + specifier: ^0.9.2 version: 0.9.2 '@types/ws': - specifier: ^8.5.4 + specifier: ^8.5.5 version: 8.5.5 - async-lock: - specifier: 1.4.0 - version: 1.4.0 axios: - specifier: 1.2.2 - version: 1.2.2 + specifier: 1.5.0 + version: 1.5.0 dayjs: - specifier: ^1.11.7 - version: 1.11.7 + specifier: ^1.11.9 + version: 1.11.9 form-data: specifier: ^4.0.0 version: 4.0.0 https-proxy-agent: - specifier: ^5.0.1 - version: 5.0.1 + specifier: ^7.0.2 + version: 7.0.2 oauth: specifier: ^0.10.0 version: 0.10.0 @@ -1038,90 +1035,66 @@ importers: specifier: ^2.0.0 version: 2.0.0 socks-proxy-agent: - specifier: ^7.0.0 - version: 7.0.0 + specifier: ^8.0.2 + version: 8.0.2 typescript: - specifier: 4.9.4 - version: 4.9.4 + specifier: 5.1.6 + version: 5.1.6 uuid: - specifier: ^9.0.0 + specifier: ^9.0.1 version: 9.0.1 ws: - specifier: 8.12.0 - version: 8.12.0 + specifier: 8.14.2 + version: 8.14.2(bufferutil@4.0.7)(utf-8-validate@6.0.3) devDependencies: - '@types/async-lock': - specifier: 1.4.0 - version: 1.4.0 '@types/core-js': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.5.6 + version: 2.5.6 '@types/form-data': specifier: ^2.5.0 version: 2.5.0 '@types/jest': - specifier: ^29.4.0 + specifier: ^29.5.5 version: 29.5.5 - '@types/node': - specifier: 18.11.18 - version: 18.11.18 '@types/object-assign-deep': - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.4.1 + version: 0.4.1 '@types/parse-link-header': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.0.1 + version: 2.0.1 '@types/uuid': - specifier: ^9.0.0 + specifier: ^9.0.4 version: 9.0.4 '@typescript-eslint/eslint-plugin': - specifier: ^5.49.0 - version: 5.49.0(@typescript-eslint/parser@5.49.0)(eslint@8.49.0)(typescript@4.9.4) + specifier: ^6.7.2 + version: 6.7.2(@typescript-eslint/parser@6.7.2)(eslint@8.49.0)(typescript@5.1.6) '@typescript-eslint/parser': - specifier: ^5.49.0 - version: 5.49.0(eslint@8.49.0)(typescript@4.9.4) + specifier: ^6.7.2 + version: 6.7.2(eslint@8.49.0)(typescript@5.1.6) eslint: - specifier: ^8.32.0 + specifier: ^8.49.0 version: 8.49.0 eslint-config-prettier: - specifier: ^8.6.0 - version: 8.6.0(eslint@8.49.0) - eslint-config-standard: - specifier: ^16.0.3 - version: 16.0.3(eslint-plugin-import@2.28.1)(eslint-plugin-node@11.0.0)(eslint-plugin-promise@6.1.1)(eslint@8.49.0) - eslint-plugin-import: - specifier: ^2.27.5 - version: 2.28.1(@typescript-eslint/parser@5.49.0)(eslint@8.49.0) - eslint-plugin-node: - specifier: ^11.0.0 - version: 11.0.0(eslint@8.49.0) - eslint-plugin-prettier: - specifier: ^4.2.1 - version: 4.2.1(eslint-config-prettier@8.6.0)(eslint@8.49.0)(prettier@2.8.8) - eslint-plugin-promise: - specifier: ^6.1.1 - version: 6.1.1(eslint@8.49.0) - eslint-plugin-standard: - specifier: ^5.0.0 - version: 5.0.0(eslint@8.49.0) + specifier: ^9.0.0 + version: 9.0.0(eslint@8.49.0) jest: - specifier: ^29.4.0 - version: 29.7.0(@types/node@18.11.18) + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.6.3) jest-worker: - specifier: ^29.4.0 + specifier: ^29.7.0 version: 29.7.0 lodash: specifier: 4.17.21 version: 4.17.21 prettier: - specifier: ^2.8.3 - version: 2.8.8 + specifier: ^3.0.3 + version: 3.0.3 ts-jest: - specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.11)(jest@29.7.0)(typescript@4.9.4) + specifier: ^29.1.1 + version: 29.1.1(@babel/core@7.22.11)(jest@29.7.0)(typescript@5.1.6) typedoc: - specifier: ^0.23.24 - version: 0.23.24(typescript@4.9.4) + specifier: ^0.25.1 + version: 0.25.1(typescript@5.1.6) packages/misskey-js: dependencies: @@ -2035,10 +2008,6 @@ packages: resolution: {integrity: sha512-4E/F9IIEi8WR94324mbDUMo074YTheJmd7eZF5vITTeYchqAi6sYXRLHUVsmkdmY4QjfKTcB2jB7dVP3NaBElQ==} engines: {node: '>=6.9.0'} - /@babel/helper-validator-identifier@7.22.5: - resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} - engines: {node: '>=6.9.0'} - /@babel/helper-validator-option@7.22.5: resolution: {integrity: sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==} engines: {node: '>=6.9.0'} @@ -2078,7 +2047,7 @@ packages: engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.22.5 + '@babel/types': 7.22.17 /@babel/parser@7.22.16: resolution: {integrity: sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==} @@ -2087,13 +2056,6 @@ packages: dependencies: '@babel/types': 7.22.17 - /@babel/parser@7.22.7: - resolution: {integrity: sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.22.5 - /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.22.5(@babel/core@7.22.11): resolution: {integrity: sha512-NP1M5Rf+u2Gw9qfSO4ihjcTGW5zXTi36ITLd4/EoAcEhIZ0yjMqmftDNl3QC19CX7olhrjpyU454g/2W7X0jvQ==} engines: {node: '>=6.9.0'} @@ -3151,14 +3113,6 @@ packages: '@babel/helper-validator-identifier': 7.22.15 to-fast-properties: 2.0.0 - /@babel/types@7.22.5: - resolution: {integrity: sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.22.5 - '@babel/helper-validator-identifier': 7.22.5 - to-fast-properties: 2.0.0 - /@base2/pretty-print-object@1.0.1: resolution: {integrity: sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==} dev: true @@ -7663,10 +7617,6 @@ packages: resolution: {integrity: sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==} dev: true - /@types/async-lock@1.4.0: - resolution: {integrity: sha512-2+rYSaWrpdbQG3SA0LmMT6YxWLrI81AqpMlSkw3QtFc2HGDufkweQSn30Eiev7x9LL0oyFrBqk1PXOnB9IEgKg==} - dev: true - /@types/babel__core@7.20.0: resolution: {integrity: sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==} dependencies: @@ -7760,8 +7710,8 @@ packages: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} dev: true - /@types/core-js@2.5.0: - resolution: {integrity: sha512-qjkHL3wF0JMHMqgm/kmL8Pf8rIiqvueEiZ0g6NVTcBX1WN46GWDr+V5z+gsHUeL0n8TfAmXnYmF7ajsxmBp4PQ==} + /@types/core-js@2.5.6: + resolution: {integrity: sha512-zLzoC7avO4EYUUYCSzDaahSP1QJEpZQcPxqs91mPeFdh2NS4hQBcnRoEc9RuXfJ8cdN/KXUWukMmZGcKaWeOvw==} dev: true /@types/cross-spawn@6.0.2: @@ -8007,10 +7957,6 @@ packages: resolution: {integrity: sha512-Mnq3O9Xz52exs3mlxMcQuA7/9VFe/dXcrgAyfjLkABIqxXKOgBRjyazTxUbjsxDa4BP7hhPliyjVTP9RDP14xg==} dev: true - /@types/node@18.11.18: - resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==} - dev: true - /@types/node@18.17.15: resolution: {integrity: sha512-2yrWpBk32tvV/JAd3HNHWuZn/VDN1P+72hWirHnvsvTGSqbANi+kSeuQR9yAHnbvaBvHDsoTdXV0Fe+iRtHLKA==} dev: true @@ -8046,8 +7992,8 @@ packages: dependencies: '@types/node': 20.6.3 - /@types/object-assign-deep@0.4.0: - resolution: {integrity: sha512-3D0F3rHRNDc8cQSXNzwF1jBrJi28Mdrhc10ZLlqbJWDPYRWTTWB9Tc8JoKrgBvLKioXoPoHT6Uzf3s2F7akCUg==} + /@types/object-assign-deep@0.4.1: + resolution: {integrity: sha512-uWJatOM1JKDdF6Fwa16124b76BtxvTz5Lv+ORGuI7dwqU4iqExXpeHrHOi1c8BU4FgSJ6PdH0skR9Zmz8+MUqQ==} dev: true /@types/offscreencanvas@2019.3.0: @@ -8060,8 +8006,8 @@ packages: requiresBuild: true dev: false - /@types/parse-link-header@2.0.0: - resolution: {integrity: sha512-KbqcQLdRaawDOfXnwqr6nvhe1MV+Uv/Ww+ViSx7Ujgw9X5qCgObLP52B1ZSJqZD8FK1y/4o+bJQTUrZOynegcg==} + /@types/parse-link-header@2.0.1: + resolution: {integrity: sha512-BrKNSrRTqn3UkMXvdVtr/znJch0PMBpEvEP8oBkxDx7eEGntuFLI+WpA5HGsNHK4SlqyhaMa+Ks0ViwyixQB5w==} dev: true /@types/pg@8.10.2: @@ -8279,29 +8225,31 @@ packages: dev: true optional: true - /@typescript-eslint/eslint-plugin@5.49.0(@typescript-eslint/parser@5.49.0)(eslint@8.49.0)(typescript@4.9.4): - resolution: {integrity: sha512-IhxabIpcf++TBaBa1h7jtOWyon80SXPRLDq0dVz5SLFC/eW6tofkw/O7Ar3lkx5z5U6wzbKDrl2larprp5kk5Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/eslint-plugin@6.7.2(@typescript-eslint/parser@6.7.2)(eslint@8.49.0)(typescript@5.1.6): + resolution: {integrity: sha512-ooaHxlmSgZTM6CHYAFRlifqh1OAr3PAQEwi7lhYhaegbnXrnh7CDcHmc3+ihhbQC7H0i4JF0psI5ehzkF6Yl6Q==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - '@typescript-eslint/parser': ^5.0.0 - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/parser': 5.49.0(eslint@8.49.0)(typescript@4.9.4) - '@typescript-eslint/scope-manager': 5.49.0 - '@typescript-eslint/type-utils': 5.49.0(eslint@8.49.0)(typescript@4.9.4) - '@typescript-eslint/utils': 5.49.0(eslint@8.49.0)(typescript@4.9.4) + '@eslint-community/regexpp': 4.6.2 + '@typescript-eslint/parser': 6.7.2(eslint@8.49.0)(typescript@5.1.6) + '@typescript-eslint/scope-manager': 6.7.2 + '@typescript-eslint/type-utils': 6.7.2(eslint@8.49.0)(typescript@5.1.6) + '@typescript-eslint/utils': 6.7.2(eslint@8.49.0)(typescript@5.1.6) + '@typescript-eslint/visitor-keys': 6.7.2 debug: 4.3.4(supports-color@8.1.1) eslint: 8.49.0 + graphemer: 1.4.0 ignore: 5.2.4 - natural-compare-lite: 1.4.0 - regexpp: 3.2.0 + natural-compare: 1.4.0 semver: 7.5.4 - tsutils: 3.21.0(typescript@4.9.4) - typescript: 4.9.4 + ts-api-utils: 1.0.1(typescript@5.1.6) + typescript: 5.1.6 transitivePeerDependencies: - supports-color dev: true @@ -8335,22 +8283,23 @@ packages: - supports-color dev: true - /@typescript-eslint/parser@5.49.0(eslint@8.49.0)(typescript@4.9.4): - resolution: {integrity: sha512-veDlZN9mUhGqU31Qiv2qEp+XrJj5fgZpJ8PW30sHU+j/8/e5ruAhLaVDAeznS7A7i4ucb/s8IozpDtt9NqCkZg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/parser@6.7.2(eslint@8.49.0)(typescript@5.1.6): + resolution: {integrity: sha512-KA3E4ox0ws+SPyxQf9iSI25R6b4Ne78ORhNHeVKrPQnoYsb9UhieoiRoJgrzgEeKGOXhcY1i8YtOeCHHTDa6Fw==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 5.49.0 - '@typescript-eslint/types': 5.49.0 - '@typescript-eslint/typescript-estree': 5.49.0(typescript@4.9.4) + '@typescript-eslint/scope-manager': 6.7.2 + '@typescript-eslint/types': 6.7.2 + '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.1.6) + '@typescript-eslint/visitor-keys': 6.7.2 debug: 4.3.4(supports-color@8.1.1) eslint: 8.49.0 - typescript: 4.9.4 + typescript: 5.1.6 transitivePeerDependencies: - supports-color dev: true @@ -8376,14 +8325,6 @@ packages: - supports-color dev: true - /@typescript-eslint/scope-manager@5.49.0: - resolution: {integrity: sha512-clpROBOiMIzpbWNxCe1xDK14uPZh35u4QaZO1GddilEzoCLAEz4szb51rBpdgurs5k2YzPtJeTEN3qVbG+LRUQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - '@typescript-eslint/types': 5.49.0 - '@typescript-eslint/visitor-keys': 5.49.0 - dev: true - /@typescript-eslint/scope-manager@6.7.2: resolution: {integrity: sha512-bgi6plgyZjEqapr7u2mhxGR6E8WCzKNUFWNh6fkpVe9+yzRZeYtDTbsIBzKbcxI+r1qVWt6VIoMSNZ4r2A+6Yw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8392,22 +8333,22 @@ packages: '@typescript-eslint/visitor-keys': 6.7.2 dev: true - /@typescript-eslint/type-utils@5.49.0(eslint@8.49.0)(typescript@4.9.4): - resolution: {integrity: sha512-eUgLTYq0tR0FGU5g1YHm4rt5H/+V2IPVkP0cBmbhRyEmyGe4XvJ2YJ6sYTmONfjmdMqyMLad7SB8GvblbeESZA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/type-utils@6.7.2(eslint@8.49.0)(typescript@5.1.6): + resolution: {integrity: sha512-36F4fOYIROYRl0qj95dYKx6kybddLtsbmPIYNK0OBeXv2j9L5nZ17j9jmfy+bIDHKQgn2EZX+cofsqi8NPATBQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: '*' + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.49.0(typescript@4.9.4) - '@typescript-eslint/utils': 5.49.0(eslint@8.49.0)(typescript@4.9.4) + '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.1.6) + '@typescript-eslint/utils': 6.7.2(eslint@8.49.0)(typescript@5.1.6) debug: 4.3.4(supports-color@8.1.1) eslint: 8.49.0 - tsutils: 3.21.0(typescript@4.9.4) - typescript: 4.9.4 + ts-api-utils: 1.0.1(typescript@5.1.6) + typescript: 5.1.6 transitivePeerDependencies: - supports-color dev: true @@ -8432,33 +8373,28 @@ packages: - supports-color dev: true - /@typescript-eslint/types@5.49.0: - resolution: {integrity: sha512-7If46kusG+sSnEpu0yOz2xFv5nRz158nzEXnJFCGVEHWnuzolXKwrH5Bsf9zsNlOQkyZuk0BZKKoJQI+1JPBBg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - /@typescript-eslint/types@6.7.2: resolution: {integrity: sha512-flJYwMYgnUNDAN9/GAI3l8+wTmvTYdv64fcH8aoJK76Y+1FCZ08RtI5zDerM/FYT5DMkAc+19E4aLmd5KqdFyg==} engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@5.49.0(typescript@4.9.4): - resolution: {integrity: sha512-PBdx+V7deZT/3GjNYPVQv1Nc0U46dAHbIuOG8AZ3on3vuEKiPDwFE/lG1snN2eUB9IhF7EyF7K1hmTcLztNIsA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/typescript-estree@6.7.2(typescript@5.1.6): + resolution: {integrity: sha512-kiJKVMLkoSciGyFU0TOY0fRxnp9qq1AzVOHNeN1+B9erKFCJ4Z8WdjAkKQPP+b1pWStGFqezMLltxO+308dJTQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/types': 5.49.0 - '@typescript-eslint/visitor-keys': 5.49.0 + '@typescript-eslint/types': 6.7.2 + '@typescript-eslint/visitor-keys': 6.7.2 debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - tsutils: 3.21.0(typescript@4.9.4) - typescript: 4.9.4 + ts-api-utils: 1.0.1(typescript@5.1.6) + typescript: 5.1.6 transitivePeerDependencies: - supports-color dev: true @@ -8484,20 +8420,19 @@ packages: - supports-color dev: true - /@typescript-eslint/utils@5.49.0(eslint@8.49.0)(typescript@4.9.4): - resolution: {integrity: sha512-cPJue/4Si25FViIb74sHCLtM4nTSBXtLx1d3/QT6mirQ/c65bV8arBEebBJJizfq8W2YyMoPI/WWPFWitmNqnQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/utils@6.7.2(eslint@8.49.0)(typescript@5.1.6): + resolution: {integrity: sha512-ZCcBJug/TS6fXRTsoTkgnsvyWSiXwMNiPzBUani7hDidBdj1779qwM1FIAmpH4lvlOZNF3EScsxxuGifjpLSWQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^7.0.0 || ^8.0.0 dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.49.0) '@types/json-schema': 7.0.12 '@types/semver': 7.5.2 - '@typescript-eslint/scope-manager': 5.49.0 - '@typescript-eslint/types': 5.49.0 - '@typescript-eslint/typescript-estree': 5.49.0(typescript@4.9.4) + '@typescript-eslint/scope-manager': 6.7.2 + '@typescript-eslint/types': 6.7.2 + '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.1.6) eslint: 8.49.0 - eslint-scope: 5.1.1 - eslint-utils: 3.0.0(eslint@8.49.0) semver: 7.5.4 transitivePeerDependencies: - supports-color @@ -8523,14 +8458,6 @@ packages: - typescript dev: true - /@typescript-eslint/visitor-keys@5.49.0: - resolution: {integrity: sha512-v9jBMjpNWyn8B6k/Mjt6VbUS4J1GvUlR4x3Y+ibnP1z7y7V4n0WRz+50DY6+Myj0UaXVSuUlHohO+eZ8IJEnkg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - '@typescript-eslint/types': 5.49.0 - eslint-visitor-keys: 3.4.3 - dev: true - /@typescript-eslint/visitor-keys@6.7.2: resolution: {integrity: sha512-uVw9VIMFBUTz8rIeaUT3fFe8xIUx8r4ywAdlQv1ifH+6acn/XF8Y6rwJ7XNmkNMDrTW+7+vxFFPIF40nJCVsMQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -8735,7 +8662,7 @@ packages: /@vue/reactivity-transform@3.3.4: resolution: {integrity: sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==} dependencies: - '@babel/parser': 7.22.7 + '@babel/parser': 7.22.16 '@vue/compiler-core': 3.3.4 '@vue/shared': 3.3.4 estree-walker: 2.0.2 @@ -9004,6 +8931,10 @@ packages: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} + /ansi-sequence-parser@1.1.1: + resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==} + dev: true + /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -9306,10 +9237,6 @@ packages: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} dev: true - /async-lock@1.4.0: - resolution: {integrity: sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==} - dev: false - /async-mutex@0.4.0: resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==} dependencies: @@ -9382,8 +9309,8 @@ packages: - debug dev: true - /axios@1.2.2: - resolution: {integrity: sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==} + /axios@1.5.0: + resolution: {integrity: sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==} dependencies: follow-redirects: 1.15.2(debug@4.3.4) form-data: 4.0.0 @@ -9515,7 +9442,7 @@ packages: resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} engines: {node: '>= 10.0.0'} dependencies: - '@babel/types': 7.22.5 + '@babel/types': 7.22.17 /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -10400,8 +10327,8 @@ packages: /constantinople@4.0.1: resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} dependencies: - '@babel/parser': 7.22.7 - '@babel/types': 7.22.5 + '@babel/parser': 7.22.16 + '@babel/types': 7.22.17 /content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} @@ -10464,25 +10391,6 @@ packages: readable-stream: 3.6.0 dev: false - /create-jest@29.7.0(@types/node@18.11.18): - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@18.11.18) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - dev: true - /create-jest@29.7.0(@types/node@20.6.3): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -10778,6 +10686,11 @@ packages: /dayjs@1.11.7: resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==} + dev: true + + /dayjs@1.11.9: + resolution: {integrity: sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==} + dev: false /de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -11479,8 +11392,8 @@ packages: source-map: 0.6.1 dev: true - /eslint-config-prettier@8.6.0(eslint@8.49.0): - resolution: {integrity: sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA==} + /eslint-config-prettier@9.0.0(eslint@8.49.0): + resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==} hasBin: true peerDependencies: eslint: '>=7.0.0' @@ -11488,20 +11401,6 @@ packages: eslint: 8.49.0 dev: true - /eslint-config-standard@16.0.3(eslint-plugin-import@2.28.1)(eslint-plugin-node@11.0.0)(eslint-plugin-promise@6.1.1)(eslint@8.49.0): - resolution: {integrity: sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==} - peerDependencies: - eslint: ^7.12.1 - eslint-plugin-import: ^2.22.1 - eslint-plugin-node: ^11.1.0 - eslint-plugin-promise: ^4.2.1 || ^5.0.0 - dependencies: - eslint: 8.49.0 - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@5.49.0)(eslint@8.49.0) - eslint-plugin-node: 11.0.0(eslint@8.49.0) - eslint-plugin-promise: 6.1.1(eslint@8.49.0) - dev: true - /eslint-formatter-pretty@4.1.0: resolution: {integrity: sha512-IsUTtGxF1hrH6lMWiSl1WbGaiP01eT6kzywdY1U+zLc0MP+nwEnUiS9UI8IaOTUhTeQJLlCEWIbXINBH4YJbBQ==} engines: {node: '>=10'} @@ -11526,35 +11425,6 @@ packages: - supports-color dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@5.49.0)(eslint-import-resolver-node@0.3.7)(eslint@8.49.0): - resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - dependencies: - '@typescript-eslint/parser': 5.49.0(eslint@8.49.0)(typescript@4.9.4) - debug: 3.2.7(supports-color@5.5.0) - eslint: 8.49.0 - eslint-import-resolver-node: 0.3.7 - transitivePeerDependencies: - - supports-color - dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.2)(eslint-import-resolver-node@0.3.7)(eslint@8.49.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} @@ -11584,52 +11454,6 @@ packages: - supports-color dev: true - /eslint-plugin-es@3.0.1(eslint@8.49.0): - resolution: {integrity: sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==} - engines: {node: '>=8.10.0'} - peerDependencies: - eslint: '>=4.19.1' - dependencies: - eslint: 8.49.0 - eslint-utils: 2.1.0 - regexpp: 3.2.0 - dev: true - - /eslint-plugin-import@2.28.1(@typescript-eslint/parser@5.49.0)(eslint@8.49.0): - resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - dependencies: - '@typescript-eslint/parser': 5.49.0(eslint@8.49.0)(typescript@4.9.4) - array-includes: 3.1.6 - array.prototype.findlastindex: 1.2.2 - array.prototype.flat: 1.3.1 - array.prototype.flatmap: 1.3.1 - debug: 3.2.7(supports-color@5.5.0) - doctrine: 2.1.0 - eslint: 8.49.0 - eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.49.0)(eslint-import-resolver-node@0.3.7)(eslint@8.49.0) - has: 1.0.3 - is-core-module: 2.13.0 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.6 - object.groupby: 1.0.0 - object.values: 1.1.6 - semver: 6.3.1 - tsconfig-paths: 3.14.2 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - dev: true - /eslint-plugin-import@2.28.1(@typescript-eslint/parser@6.7.2)(eslint@8.49.0): resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} engines: {node: '>=4'} @@ -11665,56 +11489,6 @@ packages: - supports-color dev: true - /eslint-plugin-node@11.0.0(eslint@8.49.0): - resolution: {integrity: sha512-chUs/NVID+sknFiJzxoN9lM7uKSOEta8GC8365hw1nDfwIPIjjpRSwwPvQanWv8dt/pDe9EV4anmVSwdiSndNg==} - engines: {node: '>=8.10.0'} - peerDependencies: - eslint: '>=5.16.0' - dependencies: - eslint: 8.49.0 - eslint-plugin-es: 3.0.1(eslint@8.49.0) - eslint-utils: 2.1.0 - ignore: 5.2.4 - minimatch: 3.1.2 - resolve: 1.22.3 - semver: 6.3.1 - dev: true - - /eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.6.0)(eslint@8.49.0)(prettier@2.8.8): - resolution: {integrity: sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==} - engines: {node: '>=12.0.0'} - peerDependencies: - eslint: '>=7.28.0' - eslint-config-prettier: '*' - prettier: '>=2.0.0' - peerDependenciesMeta: - eslint-config-prettier: - optional: true - dependencies: - eslint: 8.49.0 - eslint-config-prettier: 8.6.0(eslint@8.49.0) - prettier: 2.8.8 - prettier-linter-helpers: 1.0.0 - dev: true - - /eslint-plugin-promise@6.1.1(eslint@8.49.0): - resolution: {integrity: sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - dependencies: - eslint: 8.49.0 - dev: true - - /eslint-plugin-standard@5.0.0(eslint@8.49.0): - resolution: {integrity: sha512-eSIXPc9wBM4BrniMzJRBm2uoVuXz2EPa+NXPk2+itrVt+r5SbKFERx/IgrK/HmfjddyKVz2f+j+7gBRvu19xLg==} - deprecated: 'standard 16.0.0 and eslint-config-standard 16.0.0 no longer require the eslint-plugin-standard package. You can remove it from your dependencies with ''npm rm eslint-plugin-standard''. More info here: https://github.com/standard/standard/issues/1316' - peerDependencies: - eslint: '>=5.0.0' - dependencies: - eslint: 8.49.0 - dev: true - /eslint-plugin-vue@9.17.0(eslint@8.49.0): resolution: {integrity: sha512-r7Bp79pxQk9I5XDP0k2dpUC7Ots3OSWgvGZNu3BxmKK6Zg7NgVtcOB6OCna5Kb9oQwJPl5hq183WD0SY5tZtIQ==} engines: {node: ^14.17.0 || >=16.0.0} @@ -11737,14 +11511,6 @@ packages: resolution: {integrity: sha512-+TQ+x4JdTnDoFEXXb3fDvfGOwnyNV7duH8fXWTPD1ieaBmB8omj7Gw/pMBBu4uI2uJCCU8APDaQJzWuXnTsH4A==} dev: true - /eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - dev: true - /eslint-scope@7.2.0: resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11761,33 +11527,6 @@ packages: estraverse: 5.3.0 dev: true - /eslint-utils@2.1.0: - resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} - engines: {node: '>=6'} - dependencies: - eslint-visitor-keys: 1.3.0 - dev: true - - /eslint-utils@3.0.0(eslint@8.49.0): - resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} - engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} - peerDependencies: - eslint: '>=5' - dependencies: - eslint: 8.49.0 - eslint-visitor-keys: 2.1.0 - dev: true - - /eslint-visitor-keys@1.3.0: - resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} - engines: {node: '>=4'} - dev: true - - /eslint-visitor-keys@2.1.0: - resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} - engines: {node: '>=10'} - dev: true - /eslint-visitor-keys@3.4.1: resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11882,11 +11621,6 @@ packages: estraverse: 5.3.0 dev: true - /estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - dev: true - /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -12146,10 +11880,6 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - /fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - dev: true - /fast-fifo@1.3.0: resolution: {integrity: sha512-IgfweLvEpwyA4WgiQe9Nx6VV2QkML2NkvZnk1oKnIzXgXdWxuhF7zw4DvLTPZJn6PIUneiAXPF24QmoEqHTjyw==} @@ -13252,6 +12982,16 @@ packages: - supports-color dev: false + /https-proxy-agent@7.0.2: + resolution: {integrity: sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.0 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: false + /human-signals@1.1.1: resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} engines: {node: '>=8.12.0'} @@ -13525,16 +13265,10 @@ packages: ci-info: 3.7.1 dev: true - /is-core-module@2.11.0: - resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==} - dependencies: - has: 1.0.3 - /is-core-module@2.13.0: resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==} dependencies: has: 1.0.3 - dev: true /is-date-object@1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} @@ -13928,34 +13662,6 @@ packages: - supports-color dev: true - /jest-cli@29.7.0(@types/node@18.11.18): - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@18.11.18) - exit: 0.1.2 - import-local: 3.1.0 - jest-config: 29.7.0(@types/node@18.11.18) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.6.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - dev: true - /jest-cli@29.7.0(@types/node@20.6.3): resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -13984,46 +13690,6 @@ packages: - ts-node dev: true - /jest-config@29.7.0(@types/node@18.11.18): - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - dependencies: - '@babel/core': 7.22.11 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 18.11.18 - babel-jest: 29.7.0(@babel/core@7.22.11) - chalk: 4.1.2 - ci-info: 3.7.1 - deepmerge: 4.2.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.5 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - dev: true - /jest-config@29.7.0(@types/node@20.6.3): resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -14406,27 +14072,6 @@ packages: supports-color: 8.1.1 dev: true - /jest@29.7.0(@types/node@18.11.18): - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 29.7.0 - '@jest/types': 29.6.3 - import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@18.11.18) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - dev: true - /jest@29.7.0(@types/node@20.6.3): resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -15280,6 +14925,13 @@ packages: dependencies: brace-expansion: 2.0.1 + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -15548,10 +15200,6 @@ packages: /napi-build-utils@1.0.2: resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} - /natural-compare-lite@1.4.0: - resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} - dev: true - /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -16920,13 +16568,6 @@ packages: engines: {node: '>= 0.8.0'} dev: true - /prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} - engines: {node: '>=6.0.0'} - dependencies: - fast-diff: 1.3.0 - dev: true - /prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -17079,7 +16720,6 @@ packages: /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false /ps-tree@1.2.0: resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} @@ -17128,7 +16768,7 @@ packages: jstransformer: 1.0.0 pug-error: 2.0.0 pug-walk: 2.0.0 - resolve: 1.22.1 + resolve: 1.22.3 /pug-lexer@5.0.1: resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} @@ -17214,7 +16854,7 @@ packages: mime: 2.6.0 mime-types: 2.1.35 progress: 2.0.3 - proxy-from-env: 1.0.0 + proxy-from-env: 1.1.0 rimraf: 2.7.1 ws: 6.2.2 transitivePeerDependencies: @@ -17738,11 +17378,6 @@ packages: functions-have-names: 1.2.3 dev: true - /regexpp@3.2.0: - resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} - engines: {node: '>=8'} - dev: true - /regexpu-core@5.3.2: resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==} engines: {node: '>=4'} @@ -17868,14 +17503,6 @@ packages: path-parse: 1.0.7 dev: true - /resolve@1.22.1: - resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} - hasBin: true - dependencies: - is-core-module: 2.11.0 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - /resolve@1.22.3: resolution: {integrity: sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==} hasBin: true @@ -17883,7 +17510,6 @@ packages: is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: true /responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} @@ -18211,9 +17837,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - /shiki@0.12.1: - resolution: {integrity: sha512-aieaV1m349rZINEBkjxh2QbBvFFQOlgqYTNtCal82hHj4dDZ76oMlQIX+C7ryerBTDiga3e5NfH6smjdJ02BbQ==} + /shiki@0.14.4: + resolution: {integrity: sha512-IXCRip2IQzKwxArNNq1S+On4KPML3Yyn8Zzs/xRgcgOWIr8ntIK3IKzjFPfjy/7kt9ZMjc+FItfqHRBg8b6tNQ==} dependencies: + ansi-sequence-parser: 1.1.1 jsonc-parser: 3.2.0 vscode-oniguruma: 1.7.0 vscode-textmate: 8.0.0 @@ -18467,6 +18094,17 @@ packages: - supports-color dev: false + /socks-proxy-agent@8.0.2: + resolution: {integrity: sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.0 + debug: 4.3.4(supports-color@8.1.1) + socks: 2.7.1 + transitivePeerDependencies: + - supports-color + dev: false + /socks@2.7.1: resolution: {integrity: sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==} engines: {node: '>= 10.13.0', npm: '>= 3.0.0'} @@ -19232,6 +18870,15 @@ packages: escape-string-regexp: 5.0.0 dev: false + /ts-api-utils@1.0.1(typescript@5.1.6): + resolution: {integrity: sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==} + engines: {node: '>=16.13.0'} + peerDependencies: + typescript: '>=4.2.0' + dependencies: + typescript: 5.1.6 + dev: true + /ts-api-utils@1.0.1(typescript@5.2.2): resolution: {integrity: sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==} engines: {node: '>=16.13.0'} @@ -19246,8 +18893,8 @@ packages: engines: {node: '>=6.10'} dev: true - /ts-jest@29.0.5(@babel/core@7.22.11)(jest@29.7.0)(typescript@4.9.4): - resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} + /ts-jest@29.1.1(@babel/core@7.22.11)(jest@29.7.0)(typescript@5.1.6): + resolution: {integrity: sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true peerDependencies: @@ -19256,7 +18903,7 @@ packages: babel-jest: ^29.0.0 esbuild: '*' jest: ^29.0.0 - typescript: '>=4.3' + typescript: '>=4.3 <6' peerDependenciesMeta: '@babel/core': optional: true @@ -19270,13 +18917,13 @@ packages: '@babel/core': 7.22.11 bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@18.11.18) + jest: 29.7.0(@types/node@20.6.3) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.4 - typescript: 4.9.4 + typescript: 5.1.6 yargs-parser: 21.1.1 dev: true @@ -19344,16 +18991,6 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - /tsutils@3.21.0(typescript@4.9.4): - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - dependencies: - tslib: 1.14.1 - typescript: 4.9.4 - dev: true - /tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} dependencies: @@ -19460,18 +19097,18 @@ packages: /typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - /typedoc@0.23.24(typescript@4.9.4): - resolution: {integrity: sha512-bfmy8lNQh+WrPYcJbtjQ6JEEsVl/ce1ZIXyXhyW+a1vFrjO39t6J8sL/d6FfAGrJTc7McCXgk9AanYBSNvLdIA==} - engines: {node: '>= 14.14'} + /typedoc@0.25.1(typescript@5.1.6): + resolution: {integrity: sha512-c2ye3YUtGIadxN2O6YwPEXgrZcvhlZ6HlhWZ8jQRNzwLPn2ylhdGqdR8HbyDRyALP8J6lmSANILCkkIdNPFxqA==} + engines: {node: '>= 16'} hasBin: true peerDependencies: - typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x dependencies: lunr: 2.3.9 marked: 4.3.0 - minimatch: 5.1.2 - shiki: 0.12.1 - typescript: 4.9.4 + minimatch: 9.0.3 + shiki: 0.14.4 + typescript: 5.1.6 dev: true /typeorm@0.3.17(ioredis@5.3.2)(pg@8.11.3): @@ -19553,17 +19190,17 @@ packages: - supports-color dev: false - /typescript@4.9.4: - resolution: {integrity: sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==} - engines: {node: '>=4.2.0'} - hasBin: true - /typescript@5.0.4: resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} engines: {node: '>=12.20'} hasBin: true dev: true + /typescript@5.1.6: + resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} + engines: {node: '>=14.17'} + hasBin: true + /typescript@5.2.2: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} @@ -20293,8 +19930,8 @@ packages: resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} engines: {node: '>= 10.0.0'} dependencies: - '@babel/parser': 7.22.7 - '@babel/types': 7.22.5 + '@babel/parser': 7.22.16 + '@babel/types': 7.22.17 assert-never: 1.2.1 babel-walk: 3.0.0-canary-5 @@ -20359,19 +19996,6 @@ packages: async-limiter: 1.0.1 dev: true - /ws@8.12.0: - resolution: {integrity: sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: false - /ws@8.14.2(bufferutil@4.0.7)(utf-8-validate@6.0.3): resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} engines: {node: '>=10.0.0'} From 6c23c63b5353ad47412afb2a53de7e12a91b70e6 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 01:47:45 +0200 Subject: [PATCH 10/31] add: files back into megalodon --- packages/megalodon/src/misskey/entities/GetAll.ts | 6 ++++++ packages/megalodon/src/misskey/entities/field.ts | 7 +++++++ 2 files changed, 13 insertions(+) create mode 100644 packages/megalodon/src/misskey/entities/GetAll.ts create mode 100644 packages/megalodon/src/misskey/entities/field.ts diff --git a/packages/megalodon/src/misskey/entities/GetAll.ts b/packages/megalodon/src/misskey/entities/GetAll.ts new file mode 100644 index 0000000000..94ace2f184 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/GetAll.ts @@ -0,0 +1,6 @@ +namespace MisskeyEntity { + export type GetAll = { + tutorial: number; + defaultNoteVisibility: "public" | "home" | "followers" | "specified"; + }; +} diff --git a/packages/megalodon/src/misskey/entities/field.ts b/packages/megalodon/src/misskey/entities/field.ts new file mode 100644 index 0000000000..8bbb2d7c42 --- /dev/null +++ b/packages/megalodon/src/misskey/entities/field.ts @@ -0,0 +1,7 @@ +namespace MisskeyEntity { + export type Field = { + name: string; + value: string; + verified?: string; + }; +} From 17efcf6b51cfd80594ee7827cec75842dbeca3b3 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 02:48:16 +0200 Subject: [PATCH 11/31] upd: Masto API accounts endpoint finished --- .../api/mastodon/MastodonApiServerService.ts | 214 +++++++++++++++++- .../server/api/mastodon/endpoints/account.ts | 139 ++++++++++++ 2 files changed, 351 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 320fdf52d5..fd802b9a73 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -3,9 +3,9 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import megalodon, { MegalodonInterface } from "megalodon"; +import megalodon, { Entity, MegalodonInterface } from "megalodon"; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; -import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag } from './converters.js'; +import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList } from './converters.js'; import { IsNull } from 'typeorm'; import type { Config } from '@/config.js'; import { getInstance } from './endpoints/meta.js'; @@ -339,6 +339,216 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); + + fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/lists", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountLists(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data.map((list) => convertList(list))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/follow", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.addFollow()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/unfollow", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.rmFollow()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/block", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.addBlock()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/unblock", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.rmBlock()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/mute", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.addMute()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/unmute", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.rmMute()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/followed_tags", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFollowedTags(); + reply.send(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/bookmarks", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getBookmarks()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/favourites", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getFavourites()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/mutes", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getMutes()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/blocks", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getBlocks()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/follow_requests", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit ); + reply.send(data.data.map((account) => convertAccount(account as Entity.Account))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/follow_requests/:id/authorize", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.acceptFollow()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/follow_requests/:id/reject", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new apiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.rejectFollow()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); //#endregion done(); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 205543f186..a9580aeedb 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -144,4 +144,143 @@ export class apiAccountMastodon { return e.response.data; } } + + public async addFollow() { + try { + const data = await this.client.followAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + let acct = convertRelationship(data.data); + acct.following = true; + return acct; + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async rmFollow() { + try { + const data = await this.client.unfollowAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + let acct = convertRelationship(data.data); + acct.following = false; + return acct; + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async addBlock() { + try { + const data = await this.client.blockAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + return convertRelationship(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async rmBlock() { + try { + const data = await this.client.unblockAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + return convertRelationship(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async addMute() { + try { + const data = await this.client.muteAccount( + convertId((this.request.params as any).id, IdType.SharkeyId), + this.request.body as any + ); + return convertRelationship(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async rmMute() { + try { + const data = await this.client.unmuteAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + return convertRelationship(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async getBookmarks() { + try { + const data = await this.client.getBookmarks( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); + return data.data.map((status) => convertStatus(status)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async getFavourites() { + try { + const data = await this.client.getFavourites( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); + return data.data.map((status) => convertStatus(status)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async getMutes() { + try { + const data = await this.client.getMutes( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); + return data.data.map((account) => convertAccount(account)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async getBlocks() { + try { + const data = await this.client.getBlocks( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); + return data.data.map((account) => convertAccount(account)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async acceptFollow() { + try { + const data = await this.client.acceptFollowRequest( convertId((this.request.params as any).id, IdType.SharkeyId) ); + return convertRelationship(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } + + public async rejectFollow() { + try { + const data = await this.client.rejectFollowRequest( convertId((this.request.params as any).id, IdType.SharkeyId) ); + return convertRelationship(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + return e.response.data; + } + } } \ No newline at end of file From f151ab2258e766864606fab6218d9cedf596eadf Mon Sep 17 00:00:00 2001 From: Amelia Yukii <123300075+Insert5StarName@users.noreply.github.com> Date: Sun, 24 Sep 2023 04:14:12 +0200 Subject: [PATCH 12/31] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a816a78407..99ccd9a989 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Not on Sharky? No problem! Not only can Sharky instances talk to each other, but you can make friends with people on other networks like Mastodon and Pixelfed! - **Reactions**\ You can add emoji reactions to any post! No longer are you bound by a like button, show everyone exactly how you feel with the tap of a button. +- **Post Editing**\ +In Sharkey you can edit your post, this is not possible in normal Misskey +- **Mastodon API**\ +Sharkey implements the mastodon api unlike normal Misskey +- **UI/UX Improvements**\ +Sharkey makes some Ui/UX improvements to make it easier to navigate - **Drive**\ With Sharky's built in drive, you get cloud storage right in your social media, where you can upload any files, make folders, and find media from posts you've made! - **Rich Web UI**\ From 72ff1ffc9d10a7a40d356ff75407e262e339a86f Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 04:17:09 +0200 Subject: [PATCH 13/31] add: converter export from megalodon --- packages/megalodon/src/converter.ts | 3 +++ packages/megalodon/src/index.ts | 4 +++- packages/megalodon/src/misskey/api_client.ts | 21 ++++++++++++++------ 3 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 packages/megalodon/src/converter.ts diff --git a/packages/megalodon/src/converter.ts b/packages/megalodon/src/converter.ts new file mode 100644 index 0000000000..f768fc9304 --- /dev/null +++ b/packages/megalodon/src/converter.ts @@ -0,0 +1,3 @@ +import MisskeyAPI from "./misskey/api_client"; + +export default MisskeyAPI.Converter; \ No newline at end of file diff --git a/packages/megalodon/src/index.ts b/packages/megalodon/src/index.ts index ae7c45e40a..0d2b70c295 100644 --- a/packages/megalodon/src/index.ts +++ b/packages/megalodon/src/index.ts @@ -10,6 +10,7 @@ import Misskey from './misskey' import Entity from './entity' import NotificationType from './notification' import FilterContext from './filter_context' +import Converter from './converter' export { Response, @@ -25,7 +26,8 @@ export { Mastodon, Pleroma, Misskey, - Entity + Entity, + Converter } export default generator diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index 4883e7a8ce..05971e9b7c 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -68,11 +68,15 @@ namespace MisskeyAPI { } } - export const user = (u: Entity.User): MegalodonEntity.Account => { + export const user = (u: Entity.User, host: string | null = null): MegalodonEntity.Account => { + host ? host = host.replace("https://", "") : null; let acct = u.username if (u.host) { acct = `${u.username}@${u.host}` } + if (host) { + acct = `${u.username}@${host}` + } return { id: u.id, username: u.username, @@ -100,11 +104,15 @@ namespace MisskeyAPI { } } - export const userDetail = (u: Entity.UserDetail): MegalodonEntity.Account => { + export const userDetail = (u: Entity.UserDetail, host: string | null = null): MegalodonEntity.Account => { + host ? host = host.replace("https://", "") : null; let acct = u.username if (u.host) { acct = `${u.username}@${u.host}` } + if (host) { + acct = `${u.username}@${host}` + } return { id: u.id, username: u.username, @@ -239,12 +247,13 @@ namespace MisskeyAPI { } } - export const note = (n: Entity.Note): MegalodonEntity.Status => { + export const note = (n: Entity.Note, host: string | null = null): MegalodonEntity.Status => { + host ? host = host.replace("https://", "") : null; return { id: n.id, - uri: n.uri ? n.uri : '', - url: n.uri ? n.uri : '', - account: user(n.user), + uri: n.uri ? n.uri : host ? `https://${host}/notes/${n.id}` : '', + url: n.uri ? n.uri : host ? `https://${host}/notes/${n.id}` : '', + account: user(n.user, host ? host : null), in_reply_to_id: n.replyId, in_reply_to_account_id: null, reblog: n.renote ? note(n.renote) : null, From bb2d4b0e09c4d2004c28f6aab16d7f6207ec604b Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 04:25:01 +0200 Subject: [PATCH 14/31] add: search endpoints to masto api --- .../api/mastodon/MastodonApiServerService.ts | 59 ++++++++ .../server/api/mastodon/endpoints/search.ts | 132 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 packages/backend/src/server/api/mastodon/endpoints/search.ts diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index fd802b9a73..34c06d094a 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -13,6 +13,7 @@ import { MetaService } from '@/core/MetaService.js'; import multer from 'fastify-multer'; import { apiAuthMastodon } from './endpoints/auth.js'; import { apiAccountMastodon } from './endpoints/account.js'; +import { apiSearchMastodon } from './endpoints/search.js'; const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); @@ -549,6 +550,64 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); + //#endregion + + //#region Search + fastify.get("/v1/search", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const search = new apiSearchMastodon(_request, client, BASE_URL); + reply.send(await search.SearchV1()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v2/search", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const search = new apiSearchMastodon(_request, client, BASE_URL); + reply.send(await search.SearchV2()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v1/trends/statuses", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const search = new apiSearchMastodon(_request, client, BASE_URL); + reply.send(await search.getStatusTrends()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get("/v2/suggestions", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const search = new apiSearchMastodon(_request, client, BASE_URL); + reply.send(await search.getSuggestions()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); //#endregion done(); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts new file mode 100644 index 0000000000..d55831640a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -0,0 +1,132 @@ +import type { MegalodonInterface } from "megalodon"; +import { Converter } from "megalodon"; +import type { FastifyRequest } from 'fastify'; +import { convertTimelinesArgsId, limitToInt } from "./timeline.js"; +import { convertAccount, convertStatus } from '../converters.js'; + +async function getHighlight( + BASE_URL: string, + domain: string, + accessTokens: string | undefined, +) { + const accessTokenArr = accessTokens?.split(" ") ?? [null]; + const accessToken = accessTokenArr[accessTokenArr.length - 1]; + try { + + const apicall = await fetch(`${BASE_URL}/api/notes/featured`, + { + method: 'POST', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({i: accessToken}) + }); + const api = await apicall.json(); + const data: MisskeyEntity.Note[] = api; + return data.map((note) => Converter.note(note, domain)); + } catch (e: any) { + console.log(e); + console.log(e.response.data); + return []; + } +} + +async function getFeaturedUser( BASE_URL: string, host: string, accessTokens: string | undefined, limit: number ) { + const accessTokenArr = accessTokens?.split(" ") ?? [null]; + const accessToken = accessTokenArr[accessTokenArr.length - 1]; + try { + const apicall = await fetch(`${BASE_URL}/api/users`, + { + method: 'POST', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({i: accessToken, limit, origin: "local", sort: "+follower", state: "alive"}) + }); + const api = await apicall.json(); + const data: MisskeyEntity.UserDetail[] = api; + return data.map((u) => { + return { + source: "past_interactions", + account: Converter.userDetail(u, host), + }; + }); + } catch (e: any) { + console.log(e); + console.log(e.response.data); + return []; + } +} +export class apiSearchMastodon { + private request: FastifyRequest; + private client: MegalodonInterface; + private BASE_URL: string; + + constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) { + this.request = request; + this.client = client; + this.BASE_URL = BASE_URL; + } + + public async SearchV1() { + try { + const query: any = convertTimelinesArgsId(limitToInt(this.request.query as any)); + const type = query.type || ""; + const data = await this.client.search(query.q, { type: type, ...query }); + return data.data; + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async SearchV2() { + try { + const query: any = convertTimelinesArgsId(limitToInt(this.request.query as any)); + const type = query.type; + const acct = !type || type === "accounts" ? await this.client.search(query.q, { type: "accounts", ...query }) : null; + const stat = !type || type === "statuses" ? await this.client.search(query.q, { type: "statuses", ...query }) : null; + const tags = !type || type === "hashtags" ? await this.client.search(query.q, { type: "hashtags", ...query }) : null; + const data = { + accounts: acct?.data.accounts.map((account) => convertAccount(account)) ?? [], + statuses: stat?.data.statuses.map((status) => convertStatus(status)) ?? [], + hashtags: tags?.data.hashtags ?? [] + }; + return data; + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async getStatusTrends() { + try { + const data = await getHighlight( + this.BASE_URL, + this.request.hostname, + this.request.headers.authorization, + ); + return data.map((status) => convertStatus(status)); + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async getSuggestions() { + try { + const data = await getFeaturedUser( + this.BASE_URL, + this.request.hostname, + this.request.headers.authorization, + (this.request.query as any).limit || 20, + ); + return data.map((suggestion) => { suggestion.account = convertAccount(suggestion.account); return suggestion; }); + } catch (e: any) { + console.error(e); + return e.response.data; + } + } +} \ No newline at end of file From 168c041373d69173bcdd05fcdb534e91e82879e1 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 05:02:36 +0200 Subject: [PATCH 15/31] add: notification endpoints to masto api --- .../api/mastodon/MastodonApiServerService.ts | 59 +++++++++++++++ .../api/mastodon/endpoints/notifications.ts | 71 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 packages/backend/src/server/api/mastodon/endpoints/notifications.ts diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 34c06d094a..467ce2b9e0 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -14,6 +14,7 @@ import multer from 'fastify-multer'; import { apiAuthMastodon } from './endpoints/auth.js'; import { apiAccountMastodon } from './endpoints/account.js'; import { apiSearchMastodon } from './endpoints/search.js'; +import { apiNotifyMastodon } from './endpoints/notifications.js'; const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); @@ -608,6 +609,64 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); + //#endregion + + //#region Notifications + fastify.get("/v1/notifications", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const notify = new apiNotifyMastodon(_request, client); + reply.send(await notify.getNotifications()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.get<{ Params: { id: string } }>("/v1/notification/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const notify = new apiNotifyMastodon(_request, client); + reply.send(await notify.getNotification()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/notification/:id/dismiss", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const notify = new apiNotifyMastodon(_request, client); + reply.send(await notify.rmNotification()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post("/v1/notifications/clear", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const notify = new apiNotifyMastodon(_request, client); + reply.send(await notify.rmNotifications()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); //#endregion done(); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts new file mode 100644 index 0000000000..4e8c314a54 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -0,0 +1,71 @@ +import type { MegalodonInterface } from "megalodon"; +import type { FastifyRequest } from 'fastify'; +import { convertTimelinesArgsId } from "./timeline.js"; +import { IdConvertType as IdType, convertId, convertNotification } from '../converters.js'; + +function toLimitToInt(q: any) { + if (q.limit) if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10); + return q; +} + +export class apiNotifyMastodon { + private request: FastifyRequest; + private client: MegalodonInterface; + + constructor(request: FastifyRequest, client: MegalodonInterface) { + this.request = request; + this.client = client; + } + + public async getNotifications() { + try { + const data = await this.client.getNotifications( convertTimelinesArgsId(toLimitToInt(this.request.query)) ); + const notifs = data.data; + const processed = notifs.map((n) => { + n = convertNotification(n); + if (n.type !== "follow" && n.type !== "follow_request") { + if (n.type === "reaction") n.type = "favourite"; + return n; + } else { + return n; + } + }); + return processed; + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async getNotification() { + try { + const data = await this.client.getNotification( convertId((this.request.params as any).id, IdType.SharkeyId) ); + const notif = convertNotification(data.data); + if (notif.type !== "follow" && notif.type !== "follow_request" && notif.type === "reaction") notif.type = "favourite"; + return notif; + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async rmNotification() { + try { + const data = await this.client.dismissNotification( convertId((this.request.params as any).id, IdType.SharkeyId) ); + return data.data; + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async rmNotifications() { + try { + const data = await this.client.dismissNotifications(); + return data.data; + } catch (e: any) { + console.error(e); + return e.response.data; + } + } +} \ No newline at end of file From 4e375f303f9e1f15b83be0061d8d9618658c1c09 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 05:11:48 +0200 Subject: [PATCH 16/31] fix: remove host from vite --- packages/frontend/vite.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index aac514728f..da976b7917 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -46,7 +46,6 @@ export function getConfig(): UserConfig { base: '/vite/', server: { - host: '0.0.0.0', port: 5173, }, From 34e6717dabe485537d3d1d4f5440266de7474dac Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 05:35:50 +0200 Subject: [PATCH 17/31] add: filter endpoint to masto api --- .../api/mastodon/MastodonApiServerService.ts | 59 +++++++++++++++++ .../server/api/mastodon/endpoints/filter.ts | 66 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 packages/backend/src/server/api/mastodon/endpoints/filter.ts diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 467ce2b9e0..9212a1fcf5 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -15,6 +15,7 @@ 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'; const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); @@ -667,6 +668,64 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); + //#endregion + + //#region Filters + fastify.get<{ Params: { id: string } }>("/v1/filters/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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.getFilters()) : reply.send(await filter.getFilter()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post("/v1/filters", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.post<{ Params: { id: string } }>("/v1/filters/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const filter = new apiFilterMastodon(_request, client); + reply.send(await filter.updateFilter()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + + fastify.delete<{ Params: { id: string } }>("/v1/filters/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const filter = new apiFilterMastodon(_request, client); + reply.send(await filter.rmFilter()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); //#endregion done(); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts new file mode 100644 index 0000000000..221090f289 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -0,0 +1,66 @@ +import type { MegalodonInterface } from "megalodon"; +import type { FastifyRequest } from 'fastify'; +import { convertTimelinesArgsId } from "./timeline.js"; +import { IdConvertType as IdType, convertId, convertFilter } from '../converters.js'; + +export class apiFilterMastodon { + private request: FastifyRequest; + private client: MegalodonInterface; + + constructor(request: FastifyRequest, client: MegalodonInterface) { + this.request = request; + this.client = client; + } + + public async getFilters() { + try { + const data = await this.client.getFilters(); + return data.data.map((filter) => convertFilter(filter)); + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async getFilter() { + try { + const data = await this.client.getFilter( convertId((this.request.params as any).id, IdType.SharkeyId) ); + return convertFilter(data.data); + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async createFilter() { + try { + const body: any = this.request.body; + const data = await this.client.createFilter(body.pharse, body.context, body); + return convertFilter(data.data); + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async updateFilter() { + try { + const body: any = this.request.body; + const data = await this.client.updateFilter(convertId((this.request.params as any).id, IdType.SharkeyId), body.pharse, body.context); + return convertFilter(data.data); + } catch (e: any) { + console.error(e); + return e.response.data; + } + } + + public async rmFilter() { + try { + const data = await this.client.deleteFilter( convertId((this.request.params as any).id, IdType.SharkeyId) ); + return data.data; + } catch (e: any) { + console.error(e); + return e.response.data; + } + } +} \ No newline at end of file From 1501ab261d75a85c763a06a69fcf9a1b628cdcef Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 12:05:59 +0200 Subject: [PATCH 18/31] upd: prettify layout --- .../api/mastodon/MastodonApiServerService.ts | 114 +++++++++--------- .../src/server/api/mastodon/converters.ts | 6 +- .../src/server/api/mastodon/endpoints.ts | 13 ++ .../server/api/mastodon/endpoints/account.ts | 25 ++-- .../src/server/api/mastodon/endpoints/auth.ts | 70 +++++------ .../server/api/mastodon/endpoints/filter.ts | 3 +- .../src/server/api/mastodon/endpoints/meta.ts | 14 +-- .../api/mastodon/endpoints/notifications.ts | 12 +- .../server/api/mastodon/endpoints/search.ts | 6 +- .../server/api/mastodon/endpoints/timeline.ts | 45 +++++-- 10 files changed, 168 insertions(+), 140 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/endpoints.ts diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 9212a1fcf5..0c74bfb50d 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import megalodon, { Entity, MegalodonInterface } from "megalodon"; +import megalodon, { Entity, MegalodonInterface } from 'megalodon'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList } from './converters.js'; import { IsNull } from 'typeorm'; @@ -11,16 +11,12 @@ import type { Config } from '@/config.js'; import { getInstance } from './endpoints/meta.js'; import { MetaService } from '@/core/MetaService.js'; import multer from 'fastify-multer'; -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 { apiAuthMastodon, apiAccountMastodon, apiFilterMastodon, apiNotifyMastodon, apiSearchMastodon } from './endpoints.js'; const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { - const accessTokenArr = authorization?.split(" ") ?? [null]; + 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; @@ -49,7 +45,7 @@ export class MastodonApiServerService { fastify.register(multer.contentParser); - fastify.get("/v1/custom_emojis", async (_request, reply) => { + fastify.get('/v1/custom_emojis', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -62,7 +58,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/instance", async (_request, reply) => { + fastify.get('/v1/instance', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt @@ -76,7 +72,7 @@ export class MastodonApiServerService { isDeleted: false, isSuspended: false, }, - order: { id: "ASC" }, + order: { id: 'ASC' }, }); const contact = admin == null ? null : convertAccount((await client.getAccount(admin.id)).data); reply.send(await getInstance(data.data, contact, this.config, await this.metaService.fetch())); @@ -86,7 +82,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/announcements", async (_request, reply) => { + fastify.get('/v1/announcements', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -99,7 +95,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Body: { id: string } }>("/v1/announcements/:id/dismiss", async (_request, reply) => { + fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -115,14 +111,14 @@ export class MastodonApiServerService { }, ); - fastify.post("/v1/media", { preHandler: upload.single('file') }, async (_request, reply) => { + fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; 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" }); + reply.code(401).send({ error: 'No image' }); return; } const data = await client.uploadMedia(multipartData); @@ -133,14 +129,14 @@ export class MastodonApiServerService { } }); - fastify.post("/v2/media", { preHandler: upload.single('file') }, async (_request, reply) => { + fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; 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" }); + reply.code(401).send({ error: 'No image' }); return; } const data = await client.uploadMedia(multipartData, _request.body!); @@ -151,7 +147,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/filters", async (_request, reply) => { + fastify.get('/v1/filters', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt @@ -165,7 +161,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/trends", async (_request, reply) => { + fastify.get('/v1/trends', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt @@ -179,9 +175,9 @@ export class MastodonApiServerService { } }); - fastify.post("/v1/apps", async (_request, reply) => { + fastify.post('/v1/apps', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const client = getClient(BASE_URL, ""); // we are using this here, because in private mode some info isnt + 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); @@ -192,7 +188,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/preferences", async (_request, reply) => { + fastify.get('/v1/preferences', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt @@ -207,7 +203,7 @@ export class MastodonApiServerService { }); //#region Accounts - fastify.get("/v1/accounts/verify_credentials", async (_request, reply) => { + fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt @@ -221,7 +217,7 @@ export class MastodonApiServerService { } }); - fastify.patch("/v1/accounts/update_credentials", async (_request, reply) => { + fastify.patch('/v1/accounts/update_credentials', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt @@ -235,7 +231,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/accounts/lookup", async (_request, reply) => { + fastify.get('/v1/accounts/lookup', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt @@ -249,15 +245,15 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/accounts/relationships", async (_request, reply) => { + fastify.get('/v1/accounts/relationships', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; 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 let users; try { - let ids = _request.query ? (_request.query as any)["id[]"] : null; - if (typeof ids === "string") { + let ids = _request.query ? (_request.query as any)['id[]'] : null; + if (typeof ids === 'string') { ids = [ids]; } users = ids; @@ -272,7 +268,7 @@ export class MastodonApiServerService { } }); - fastify.get<{ Params: { id: string } }>("/v1/accounts/:id", async (_request, reply) => { + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -287,7 +283,7 @@ export class MastodonApiServerService { } }); - fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/statuses", async (_request, reply) => { + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -301,7 +297,7 @@ export class MastodonApiServerService { } }); - fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/featured_tags", async (_request, reply) => { + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -315,7 +311,7 @@ export class MastodonApiServerService { } }); - fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/followers", async (_request, reply) => { + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -329,7 +325,7 @@ export class MastodonApiServerService { } }); - fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/following", async (_request, reply) => { + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -343,7 +339,7 @@ export class MastodonApiServerService { } }); - fastify.get<{ Params: { id: string } }>("/v1/accounts/:id/lists", async (_request, reply) => { + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -357,7 +353,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/follow", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -371,7 +367,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/unfollow", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -385,7 +381,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/block", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -399,7 +395,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/unblock", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -413,7 +409,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/mute", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -427,7 +423,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/accounts/:id/unmute", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -441,7 +437,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/followed_tags", async (_request, reply) => { + fastify.get('/v1/followed_tags', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -455,7 +451,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/bookmarks", async (_request, reply) => { + fastify.get('/v1/bookmarks', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -469,7 +465,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/favourites", async (_request, reply) => { + fastify.get('/v1/favourites', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -483,7 +479,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/mutes", async (_request, reply) => { + fastify.get('/v1/mutes', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -497,7 +493,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/blocks", async (_request, reply) => { + fastify.get('/v1/blocks', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -511,7 +507,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/follow_requests", async (_request, reply) => { + fastify.get('/v1/follow_requests', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -525,7 +521,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/follow_requests/:id/authorize", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -539,7 +535,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/follow_requests/:id/reject", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -555,7 +551,7 @@ export class MastodonApiServerService { //#endregion //#region Search - fastify.get("/v1/search", async (_request, reply) => { + fastify.get('/v1/search', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -569,7 +565,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v2/search", async (_request, reply) => { + fastify.get('/v2/search', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -583,7 +579,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v1/trends/statuses", async (_request, reply) => { + fastify.get('/v1/trends/statuses', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -597,7 +593,7 @@ export class MastodonApiServerService { } }); - fastify.get("/v2/suggestions", async (_request, reply) => { + fastify.get('/v2/suggestions', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -613,7 +609,7 @@ export class MastodonApiServerService { //#endregion //#region Notifications - fastify.get("/v1/notifications", async (_request, reply) => { + fastify.get('/v1/notifications', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -627,7 +623,7 @@ export class MastodonApiServerService { } }); - fastify.get<{ Params: { id: string } }>("/v1/notification/:id", async (_request, reply) => { + fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -641,7 +637,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/notification/:id/dismiss", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -655,7 +651,7 @@ export class MastodonApiServerService { } }); - fastify.post("/v1/notifications/clear", async (_request, reply) => { + fastify.post('/v1/notifications/clear', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -671,7 +667,7 @@ export class MastodonApiServerService { //#endregion //#region Filters - fastify.get<{ Params: { id: string } }>("/v1/filters/:id", async (_request, reply) => { + fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -685,7 +681,7 @@ export class MastodonApiServerService { } }); - fastify.post("/v1/filters", async (_request, reply) => { + fastify.post('/v1/filters', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -699,7 +695,7 @@ export class MastodonApiServerService { } }); - fastify.post<{ Params: { id: string } }>("/v1/filters/:id", async (_request, reply) => { + fastify.post<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); @@ -713,7 +709,7 @@ export class MastodonApiServerService { } }); - fastify.delete<{ Params: { id: string } }>("/v1/filters/:id", async (_request, reply) => { + fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index e6244455e0..5d77a3e3f5 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -1,6 +1,6 @@ -import { Entity } from "megalodon"; +import { Entity } from 'megalodon'; -const CHAR_COLLECTION: string = "0123456789abcdefghijklmnopqrstuvwxyz"; +const CHAR_COLLECTION: string = '0123456789abcdefghijklmnopqrstuvwxyz'; export enum IdConvertType { MastodonId, @@ -26,7 +26,7 @@ export function convertId(in_id: string, id_convert_type: IdConvertType): string outStr = charFromNum(remainder) + outStr; input /= BigInt(36); } - let ReversedoutStr = outStr.split("").reduce((acc, char) => char + acc, ""); + let ReversedoutStr = outStr.split('').reduce((acc, char) => char + acc, ''); return ReversedoutStr; default: diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts new file mode 100644 index 0000000000..d5528ade51 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints.ts @@ -0,0 +1,13 @@ +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'; + +export { + apiAccountMastodon, + apiAuthMastodon, + apiSearchMastodon, + apiNotifyMastodon, + apiFilterMastodon +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index a9580aeedb..21e1fa9fc3 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -1,11 +1,10 @@ -import { FindOptionsWhere, IsNull } from "typeorm"; -import type { MegalodonInterface } from "megalodon"; +import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -import { argsToBools, convertTimelinesArgsId, limitToInt } from "./timeline.js"; -import { convertId, IdConvertType as IdType, convertAccount, convertFeaturedTag, convertList, convertRelationship, convertStatus } from '../converters.js'; +import { argsToBools, convertTimelinesArgsId, limitToInt } from './timeline.js'; +import { convertId, IdConvertType as IdType, convertAccount, convertRelationship, convertStatus } from '../converters.js'; const relationshipModel = { - id: "", + id: '', following: false, followed_by: false, delivery_following: false, @@ -18,7 +17,7 @@ const relationshipModel = { showing_reblogs: false, endorsed: false, notifying: false, - note: "", + note: '', }; export class apiAccountMastodon { @@ -39,16 +38,16 @@ export class apiAccountMastodon { acct.id = convertId(acct.id, IdType.MastodonId); acct.display_name = acct.display_name || acct.username; acct.url = `${this.BASE_URL}/@${acct.url}`; - acct.note = acct.note || ""; + acct.note = acct.note || ''; acct.avatar_static = acct.avatar; - acct.header = acct.header || "/static-assets/transparent.png"; - acct.header_static = acct.header || "/static-assets/transparent.png"; + acct.header = acct.header || '/static-assets/transparent.png'; + acct.header_static = acct.header || '/static-assets/transparent.png'; acct.source = { note: acct.note, fields: acct.fields, - privacy: "", + privacy: '', sensitive: false, - language: "", + language: '', }; console.log(acct); return acct; @@ -72,7 +71,7 @@ export class apiAccountMastodon { public async lookup() { try { - const data = await this.client.search((this.request.query as any).acct, { type: "accounts" }); + const data = await this.client.search((this.request.query as any).acct, { type: 'accounts' }); return convertAccount(data.data.accounts[0]); } catch (e: any) { console.error(e); @@ -83,7 +82,7 @@ export class apiAccountMastodon { public async getRelationships(users: [string]) { try { - relationshipModel.id = users?.toString() || "1"; + relationshipModel.id = users?.toString() || '1'; if (!users) { return [relationshipModel]; diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts index 48b5ec55c9..27664ae1fa 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -1,49 +1,49 @@ -import type { MegalodonInterface } from "megalodon"; +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", + '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", + '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 async function apiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { const body: any = request.body || request.query; try { let scope = body.scopes; - if (typeof scope === "string") scope = scope.split(" "); + if (typeof scope === 'string') scope = scope.split(' '); const pushScope = new Set(); for (const s of scope) { if (s.match(/^read/)) for (const r of readScope) pushScope.add(r); @@ -62,7 +62,7 @@ export async function apiAuthMastodon(request: FastifyRequest, client: Megalodon name: appData.name, website: body.website, redirect_uri: red, - client_id: Buffer.from(appData.url || "").toString("base64"), + client_id: Buffer.from(appData.url || '').toString('base64'), 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 221090f289..175f2d519e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -1,6 +1,5 @@ -import type { MegalodonInterface } from "megalodon"; +import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -import { convertTimelinesArgsId } from "./timeline.js"; import { IdConvertType as IdType, convertId, convertFilter } from '../converters.js'; export class apiFilterMastodon { diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts index a37742a068..28ea6c08fd 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -1,7 +1,7 @@ -import { Entity } from "megalodon"; -import { MAX_NOTE_TEXT_LENGTH, FILE_TYPE_BROWSERSAFE } from "@/const.js"; +import { Entity } from 'megalodon'; +import { MAX_NOTE_TEXT_LENGTH, FILE_TYPE_BROWSERSAFE } from '@/const.js'; import type { Config } from '@/config.js'; -import type { MiMeta } from "@/models/Meta.js"; +import type { MiMeta } from '@/models/Meta.js'; export async function getInstance( response: Entity.Instance, @@ -11,13 +11,13 @@ export async function getInstance( ) { return { uri: config.url, - title: meta.name || "Sharkey", + title: meta.name || 'Sharkey', short_description: - meta.description?.substring(0, 50) || "See real server website", + meta.description?.substring(0, 50) || 'See real server website', description: meta.description || "This is a vanilla Sharkey Instance. It doesn't seem to have a description.", - email: response.email || "", + email: response.email || '', version: `3.0.0 (compatible; Sharkey ${config.version})`, urls: response.urls, stats: { @@ -25,7 +25,7 @@ export async function getInstance( status_count: response.stats.status_count, domain_count: response.stats.domain_count, }, - thumbnail: meta.backgroundImageUrl || "/static-assets/transparent.png", + thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png', languages: meta.langs, registrations: !meta.disableRegistration || response.registrations, approval_required: !response.registrations, diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 4e8c314a54..667379e8bc 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -1,10 +1,10 @@ -import type { MegalodonInterface } from "megalodon"; +import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -import { convertTimelinesArgsId } from "./timeline.js"; +import { convertTimelinesArgsId } from './timeline.js'; import { IdConvertType as IdType, convertId, convertNotification } from '../converters.js'; function toLimitToInt(q: any) { - if (q.limit) if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10); + if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10); return q; } @@ -23,8 +23,8 @@ export class apiNotifyMastodon { const notifs = data.data; const processed = notifs.map((n) => { n = convertNotification(n); - if (n.type !== "follow" && n.type !== "follow_request") { - if (n.type === "reaction") n.type = "favourite"; + if (n.type !== 'follow' && n.type !== 'follow_request') { + if (n.type === 'reaction') n.type = 'favourite'; return n; } else { return n; @@ -41,7 +41,7 @@ export class apiNotifyMastodon { try { const data = await this.client.getNotification( convertId((this.request.params as any).id, IdType.SharkeyId) ); const notif = convertNotification(data.data); - if (notif.type !== "follow" && notif.type !== "follow_request" && notif.type === "reaction") notif.type = "favourite"; + if (notif.type !== 'follow' && notif.type !== 'follow_request' && notif.type === 'reaction') notif.type = 'favourite'; return notif; } catch (e: any) { console.error(e); diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index d55831640a..6b36582b91 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -1,7 +1,7 @@ -import type { MegalodonInterface } from "megalodon"; -import { Converter } from "megalodon"; +import type { MegalodonInterface } from 'megalodon'; +import { Converter } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -import { convertTimelinesArgsId, limitToInt } from "./timeline.js"; +import { convertTimelinesArgsId, limitToInt } from './timeline.js'; import { convertAccount, convertStatus } from '../converters.js'; async function getHighlight( diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 4d4cb2c419..1d2812c37c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -1,19 +1,19 @@ import { convertId, IdConvertType as IdType, convertAccount, convertConversation, convertList, convertStatus } from '../converters.js'; -import { ParsedUrlQuery } from "querystring"; +import { ParsedUrlQuery } from 'querystring'; export function limitToInt(q: ParsedUrlQuery) { let object: any = q; if (q.limit) - if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10); + if (typeof q.limit === 'string') object.limit = parseInt(q.limit, 10); if (q.offset) - if (typeof q.offset === "string") object.offset = parseInt(q.offset, 10); + if (typeof q.offset === 'string') object.offset = parseInt(q.offset, 10); return object; } export function argsToBools(q: ParsedUrlQuery) { // Values taken from https://docs.joinmastodon.org/client/intro/#boolean const toBoolean = (value: string) => - !["0", "f", "F", "false", "FALSE", "off", "OFF"].includes(value); + !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value); // Keys taken from: // - https://docs.joinmastodon.org/methods/accounts/#statuses @@ -21,27 +21,48 @@ export function argsToBools(q: ParsedUrlQuery) { // - https://docs.joinmastodon.org/methods/timelines/#tag let object: any = q; if (q.only_media) - if (typeof q.only_media === "string") + if (typeof q.only_media === 'string') object.only_media = toBoolean(q.only_media); if (q.exclude_replies) - if (typeof q.exclude_replies === "string") + if (typeof q.exclude_replies === 'string') object.exclude_replies = toBoolean(q.exclude_replies); if (q.exclude_reblogs) - if (typeof q.exclude_reblogs === "string") + if (typeof q.exclude_reblogs === 'string') object.exclude_reblogs = toBoolean(q.exclude_reblogs); if (q.pinned) - if (typeof q.pinned === "string") object.pinned = toBoolean(q.pinned); + if (typeof q.pinned === 'string') object.pinned = toBoolean(q.pinned); if (q.local) - if (typeof q.local === "string") object.local = toBoolean(q.local); + if (typeof q.local === 'string') object.local = toBoolean(q.local); return q; } export function convertTimelinesArgsId(q: ParsedUrlQuery) { - if (typeof q.min_id === "string") + if (typeof q.min_id === 'string') q.min_id = convertId(q.min_id, IdType.SharkeyId); - if (typeof q.max_id === "string") + if (typeof q.max_id === 'string') q.max_id = convertId(q.max_id, IdType.SharkeyId); - if (typeof q.since_id === "string") + if (typeof q.since_id === 'string') q.since_id = convertId(q.since_id, IdType.SharkeyId); return q; +} + +function escapeHTML(str: string) { + if (!str) { + return ''; + } + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, '"') + .replace(/'/g, '''); +} + +function nl2br(str: string) { + if (!str) { + return ''; + } + str = str.replace(/\r\n/g, '
'); + str = str.replace(/(\n|\r)/g, '
'); + return str; } \ No newline at end of file From c85857f242ccf47beb698e9bd7c7482324e867b1 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 14:44:55 +0200 Subject: [PATCH 19/31] upd: update misskey support in megalodon --- packages/megalodon/src/megalodon.ts | 2 +- packages/megalodon/src/misskey.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/megalodon/src/megalodon.ts b/packages/megalodon/src/megalodon.ts index b2f62e4adf..19cd5c5551 100644 --- a/packages/megalodon/src/megalodon.ts +++ b/packages/megalodon/src/megalodon.ts @@ -1041,7 +1041,7 @@ export interface MegalodonInterface { * * @return Array of lists. */ - getLists(): Promise>> + getLists(id: string): Promise>> /** * Show a single list. * diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index 521cc041f1..3a154b68ea 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -1714,9 +1714,9 @@ export default class Misskey implements MegalodonInterface { /** * POST /api/users/lists/list */ - public async getLists(): Promise>> { + public async getLists(id: string): Promise>> { return this.client - .post>('/api/users/lists/list') + .post>('/api/users/lists/list', { userId: id }) .then(res => ({ ...res, data: res.data.map(l => MisskeyAPI.Converter.list(l)) })) } From 846f2d38779f42831c045fcaf822108026c32d9e Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 15:15:54 +0200 Subject: [PATCH 20/31] upd: megalodon changes --- packages/megalodon/src/misskey/api_client.ts | 2 +- packages/megalodon/src/misskey/entities/note.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index 05971e9b7c..70e12cc152 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -252,7 +252,7 @@ namespace MisskeyAPI { return { id: n.id, uri: n.uri ? n.uri : host ? `https://${host}/notes/${n.id}` : '', - url: n.uri ? n.uri : host ? `https://${host}/notes/${n.id}` : '', + url: n.url ? n.url : host ? `https://${host}/notes/${n.id}` : '', account: user(n.user, host ? host : null), in_reply_to_id: n.replyId, in_reply_to_account_id: null, diff --git a/packages/megalodon/src/misskey/entities/note.ts b/packages/megalodon/src/misskey/entities/note.ts index cc8aa32062..08c5f10aea 100644 --- a/packages/megalodon/src/misskey/entities/note.ts +++ b/packages/megalodon/src/misskey/entities/note.ts @@ -23,6 +23,7 @@ namespace MisskeyEntity { replyId: string | null renoteId: string | null uri?: string + url?: string reply?: Note renote?: Note viaMobile?: boolean From f1fc1ba4c7b6527597c456fd3ed8a01f8d92f4a5 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 15:33:13 +0200 Subject: [PATCH 21/31] add: timeline endpoints tio masto api --- .../api/mastodon/MastodonApiServerService.ts | 27 +- .../src/server/api/mastodon/endpoints.ts | 4 +- .../server/api/mastodon/endpoints/timeline.ts | 249 ++++++++++++++++++ 3 files changed, 278 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 0c74bfb50d..b3c4f77d1c 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -11,7 +11,7 @@ import type { Config } from '@/config.js'; import { getInstance } from './endpoints/meta.js'; import { MetaService } from '@/core/MetaService.js'; import multer from 'fastify-multer'; -import { apiAuthMastodon, apiAccountMastodon, apiFilterMastodon, apiNotifyMastodon, apiSearchMastodon } from './endpoints.js'; +import { apiAuthMastodon, apiAccountMastodon, apiFilterMastodon, apiNotifyMastodon, apiSearchMastodon, apiTimelineMastodon } from './endpoints.js'; const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); @@ -722,6 +722,31 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); + //#endregion + + //#region Timelines + const TLEndpoint = new apiTimelineMastodon(fastify); + + // 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 done(); } diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts index d5528ade51..111f9ef9c5 100644 --- a/packages/backend/src/server/api/mastodon/endpoints.ts +++ b/packages/backend/src/server/api/mastodon/endpoints.ts @@ -3,11 +3,13 @@ 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'; export { apiAccountMastodon, apiAuthMastodon, apiSearchMastodon, apiNotifyMastodon, - apiFilterMastodon + apiFilterMastodon, + apiTimelineMastodon } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 1d2812c37c..ec7010ee70 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -1,5 +1,8 @@ import { convertId, IdConvertType as IdType, convertAccount, convertConversation, convertList, convertStatus } from '../converters.js'; import { ParsedUrlQuery } from 'querystring'; +import type { Entity, MegalodonInterface } from 'megalodon'; +import type { FastifyInstance } from 'fastify'; +import { getClient } from '../MastodonApiServerService.js'; export function limitToInt(q: ParsedUrlQuery) { let object: any = q; @@ -65,4 +68,250 @@ function nl2br(str: string) { str = str.replace(/\r\n/g, '
'); str = str.replace(/(\n|\r)/g, '
'); return str; +} + +export class apiTimelineMastodon { + private fastify: FastifyInstance; + + constructor(fastify: FastifyInstance) { + this.fastify = fastify; + } + + public async getTL() { + this.fastify.get('/v1/timelines/public', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; + const data = query.local === 'true' + ? await client.getLocalTimeline(convertTimelinesArgsId(argsToBools(limitToInt(query)))) + : await client.getPublicTimeline(convertTimelinesArgsId(argsToBools(limitToInt(query)))); + reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async getHomeTl() { + this.fastify.get('/v1/timelines/home', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; + const data = await client.getHomeTimeline(convertTimelinesArgsId(limitToInt(query))); + reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async getTagTl() { + this.fastify.get<{ Params: { hashtag: string } }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; + const params: any = _request.params; + const data = await client.getTagTimeline(params.hashtag, convertTimelinesArgsId(limitToInt(query))); + reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async getListTL() { + this.fastify.get<{ Params: { id: string } }>('/v1/timelines/list/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; + const params: any = _request.params; + const data = await client.getListTimeline(convertId(params.id, IdType.SharkeyId), convertTimelinesArgsId(limitToInt(query))); + reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async getConversations() { + this.fastify.get('/v1/conversations', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; + const data = await client.getConversationTimeline(convertTimelinesArgsId(limitToInt(query))); + reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async getList(){ + this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { + try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const params: any = _request.params; + const data = await client.getList(convertId(params.id, IdType.SharkeyId)); + reply.send(convertList(data.data)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async getLists() { + this.fastify.get('/v1/lists', async (_request, reply) => { + try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const account = await client.verifyAccountCredentials(); + const data = await client.getLists(account.data.id); + reply.send(data.data.map((list: Entity.List) => convertList(list))); + } catch (e: any) { + console.error(e); + return e.response.data; + } + }); + } + + public async getListAccounts(){ + this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { + try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const params: any = _request.params; + const query: any = _request.query; + const data = await client.getAccountsInList( + convertId(params.id, IdType.SharkeyId), + convertTimelinesArgsId(query) + ); + reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async addListAccount() { + this.fastify.post<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { + try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const params: any = _request.params; + const query: any = _request.query; + const data = await client.addAccountsToList( + convertId(params.id, IdType.SharkeyId), + (query.accounts_id as string[]).map((id) => convertId(id, IdType.SharkeyId)) + ); + reply.send(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async rmListAccount() { + this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { + try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const params: any = _request.params; + const query: any = _request.query; + const data = await client.deleteAccountsFromList( + convertId(params.id, IdType.SharkeyId), + (query.accounts_id as string[]).map((id) => convertId(id, IdType.SharkeyId)) + ); + reply.send(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async createList() { + this.fastify.post('/v1/lists', async (_request, reply) => { + try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const body: any = _request.body; + const data = await client.createList(body.title); + reply.send(convertList(data.data)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async updateList() { + this.fastify.put<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { + try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const body: any = _request.body; + const params: any = _request.params; + const data = await client.updateList(convertId(params.id, IdType.SharkeyId), body.title); + reply.send(convertList(data.data)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + + public async deleteList() { + this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { + try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const params: any = _request.params; + const data = await client.deleteList(convertId(params.id, IdType.SharkeyId)); + reply.send(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + } + } \ No newline at end of file From 76409853320006774dc307c8d3f0c921985db838 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 15:41:38 +0200 Subject: [PATCH 22/31] add: status to masto endpoints --- packages/backend/src/server/api/mastodon/endpoints.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts index 111f9ef9c5..92cf6fe3d1 100644 --- a/packages/backend/src/server/api/mastodon/endpoints.ts +++ b/packages/backend/src/server/api/mastodon/endpoints.ts @@ -4,6 +4,7 @@ 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, @@ -11,5 +12,6 @@ export { apiSearchMastodon, apiNotifyMastodon, apiFilterMastodon, - apiTimelineMastodon + apiTimelineMastodon, + apiStatusMastodon } From 55e85fe1c140a266ecf4c2df96177986404ca653 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 15:42:09 +0200 Subject: [PATCH 23/31] upd: import status endpoint into service --- .../src/server/api/mastodon/MastodonApiServerService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index b3c4f77d1c..eb2b37fdcc 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -11,7 +11,7 @@ import type { Config } from '@/config.js'; import { getInstance } from './endpoints/meta.js'; import { MetaService } from '@/core/MetaService.js'; import multer from 'fastify-multer'; -import { apiAuthMastodon, apiAccountMastodon, apiFilterMastodon, apiNotifyMastodon, apiSearchMastodon, apiTimelineMastodon } from './endpoints.js'; +import { apiAuthMastodon, apiAccountMastodon, apiFilterMastodon, apiNotifyMastodon, apiSearchMastodon, apiTimelineMastodon, apiStatusMastodon } from './endpoints.js'; const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); @@ -747,6 +747,10 @@ export class MastodonApiServerService { // DELETE Endpoints TLEndpoint.deleteList(); TLEndpoint.rmListAccount(); + //#endregion + + //#region Status + //#endregion done(); } From 7c3f3390b497b15c0570e54231ccdd1d63b55db0 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 16:14:50 +0200 Subject: [PATCH 24/31] add: status get endpoints to masto api --- .../api/mastodon/MastodonApiServerService.ts | 10 ++ .../server/api/mastodon/endpoints/status.ts | 126 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 packages/backend/src/server/api/mastodon/endpoints/status.ts diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index eb2b37fdcc..bd42c91caa 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -750,6 +750,16 @@ export class MastodonApiServerService { //#endregion //#region Status + const NoteEndpoint = new apiStatusMastodon(fastify); + + // GET Endpoints + NoteEndpoint.getStatus(); + NoteEndpoint.getContext(); + NoteEndpoint.getHistory(); + NoteEndpoint.getReblogged(); + NoteEndpoint.getFavourites(); + NoteEndpoint.getMedia(); + NoteEndpoint.getPoll(); //#endregion done(); diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts new file mode 100644 index 0000000000..6f263ad572 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -0,0 +1,126 @@ +import { convertId, IdConvertType as IdType, convertAccount, convertAttachment, convertPoll, convertStatus } from '../converters.js'; +import querystring from 'querystring'; +import type { Entity, MegalodonInterface } from 'megalodon'; +import type { FastifyInstance } from 'fastify'; +import { getClient } from '../MastodonApiServerService.js'; +import { convertTimelinesArgsId, limitToInt } from './timeline.js'; + +function normalizeQuery(data: any) { + const str = querystring.stringify(data); + return querystring.parse(str); +} + +export class apiStatusMastodon { + private fastify: FastifyInstance; + + constructor(fastify: FastifyInstance) { + this.fastify = fastify; + } + + public async getStatus() { + this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(_request.is404 ? 404 : 401).send(e.response.data); + } + }); + } + + public async getContext() { + this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id/context", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const query: any = _request.query; + try { + const data = await client.getStatusContext( + convertId(_request.params.id, IdType.SharkeyId), + convertTimelinesArgsId(limitToInt(query)) + ); + data.data.ancestors = data.data.ancestors.map((status: Entity.Status) => convertStatus(status)); + data.data.descendants = data.data.descendants.map((status: Entity.Status) => convertStatus(status)); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(_request.is404 ? 404 : 401).send(e.response.data); + } + }); + } + + public async getHistory() { + this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id/history", async (_request, reply) => { + try { + reply.code(401).send({ message: 'Not Implemented' }); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async getReblogged() { + this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id/reblogged_by", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getStatusRebloggedBy(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async getFavourites() { + this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id/favourited_by", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getStatusFavouritedBy(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async getMedia() { + this.fastify.get<{ Params: { id: string } }>("/v1/media/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getMedia(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertAttachment(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async getPoll() { + this.fastify.get<{ Params: { id: string } }>("/v1/polls/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getPoll(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertPoll(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } +} \ No newline at end of file From 6a0df806cabb7dcda97ac598017c6619d65784cc Mon Sep 17 00:00:00 2001 From: Amelia Yukii <123300075+Insert5StarName@users.noreply.github.com> Date: Sun, 24 Sep 2023 16:17:24 +0200 Subject: [PATCH 25/31] fix: domain in readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 99ccd9a989..24b0c1af85 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@
- + Sharky logo -**🌎 **[Sharky](https://dev.transfem.social/)** is an open source, decentralized social media platform that's free forever! 🚀** +**🌎 **[Sharky](https://test.transfem.social/)** is an open source, decentralized social media platform that's free forever! 🚀** --- @@ -15,7 +15,7 @@
- + ## ✨ Features - **ActivityPub support**\ From 54bb1efe8f9286deb2dde8beda74e47e4a01da8c Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 16:26:27 +0200 Subject: [PATCH 26/31] add: new emojiregex function --- packages/backend/src/misc/emoji-regex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts index 24e4092aeb..2dd079bd7c 100644 --- a/packages/backend/src/misc/emoji-regex.ts +++ b/packages/backend/src/misc/emoji-regex.ts @@ -7,3 +7,4 @@ const twemojiRegex = /(?:\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83e\udef1\ud83c\udffb\u200d\ud83e\udef2\ud83c[\udffc-\udfff]|\ud83e\udef1\ud83c\udffc\u200d\ud83e\udef2\ud83c[\udffb\udffd-\udfff]|\ud83e\udef1\ud83c\udffd\u200d\ud83e\udef2\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\udef1\ud83c\udffe\u200d\ud83e\udef2\ud83c[\udffb-\udffd\udfff]|\ud83e\udef1\ud83c\udfff\u200d\ud83e\udef2\ud83c[\udffb-\udffe]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83e\uddd1\u200d\ud83e\udd1d\u200d\ud83e\uddd1|\ud83d\udc6b\ud83c[\udffb-\udfff]|\ud83d\udc6c\ud83c[\udffb-\udfff]|\ud83d\udc6d\ud83c[\udffb-\udfff]|\ud83d\udc8f\ud83c[\udffb-\udfff]|\ud83d\udc91\ud83c[\udffb-\udfff]|\ud83e\udd1d\ud83c[\udffb-\udfff]|\ud83d[\udc6b-\udc6d\udc8f\udc91]|\ud83e\udd1d)|(?:\ud83d[\udc68\udc69]|\ud83e\uddd1)(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf7c\udf84\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddaf-\uddb3\uddbc\uddbd])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc70\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddcd-\uddcf\uddd4\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83d\ude36\u200d\ud83c\udf2b\ufe0f|\u2764\ufe0f\u200d\ud83d\udd25|\u2764\ufe0f\u200d\ud83e\ude79|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc15\u200d\ud83e\uddba|\ud83d\udc3b\u200d\u2744\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83d\ude2e\u200d\ud83d\udca8|\ud83d\ude35\u200d\ud83d\udcab|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f|\ud83d\udc08\u200d\u2b1b)|[#*0-9]\ufe0f?\u20e3|(?:[©®\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd0c\udd0f\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\udd77\uddb5\uddb6\uddb8\uddb9\uddbb\uddcd-\uddcf\uddd1-\udddd\udec3-\udec5\udef0-\udef6]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udc8e\udc90\udc92-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\uded5-\uded7\udedd-\udedf\udeeb\udeec\udef4-\udefc\udfe0-\udfeb\udff0]|\ud83e[\udd0d\udd0e\udd10-\udd17\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd3f-\udd45\udd47-\udd76\udd78-\uddb4\uddb7\uddba\uddbc-\uddcc\uddd0\uddde-\uddff\ude70-\ude74\ude78-\ude7c\ude80-\ude86\ude90-\udeac\udeb0-\udeba\udec0-\udec2\uded0-\uded9\udee0-\udee7]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f/g; export const emojiRegex = new RegExp(`(${twemojiRegex.source})`); +export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`); From 410e6515d308da39bb0d07d40028cf2426b559bd Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 17:10:33 +0200 Subject: [PATCH 27/31] upd: complete status endpoint for masto --- .../api/mastodon/MastodonApiServerService.ts | 19 ++ .../server/api/mastodon/endpoints/status.ts | 281 ++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index bd42c91caa..da5ab28319 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -761,6 +761,25 @@ export class MastodonApiServerService { 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 + NoteEndpoint.updateMedia(); + + // DELETE Endpoint + NoteEndpoint.deleteStatus(); //#endregion done(); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 6f263ad572..85a67a4a1a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -4,6 +4,8 @@ import type { Entity, MegalodonInterface } from 'megalodon'; import type { FastifyInstance } from 'fastify'; import { getClient } from '../MastodonApiServerService.js'; import { convertTimelinesArgsId, limitToInt } from './timeline.js'; +import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js"; +import { MetaService } from '@/core/MetaService.js'; function normalizeQuery(data: any) { const str = querystring.stringify(data); @@ -12,6 +14,7 @@ function normalizeQuery(data: any) { export class apiStatusMastodon { private fastify: FastifyInstance; + private metaService: MetaService; constructor(fastify: FastifyInstance) { this.fastify = fastify; @@ -123,4 +126,282 @@ export class apiStatusMastodon { } }); } + + public async votePoll() { + this.fastify.post<{ Params: { id: string } }>("/v1/polls/:id/votes", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const body: any = _request.body; + try { + const data = await client.votePoll(convertId(_request.params.id, IdType.SharkeyId), body.choices); + reply.send(convertPoll(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async postStatus() { + this.fastify.post("/v1/statuses", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + let body: any = _request.body; + try { + if (body.in_reply_to_id) + body.in_reply_to_id = convertId(body.in_reply_to_id, IdType.SharkeyId); + if (body.quote_id) + body.quote_id = convertId(body.quote_id, IdType.SharkeyId); + 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) || isCustomEmoji) { + const a = await client.createEmojiReaction( + body.in_reply_to_id, + removed, + ); + reply.send(a.data); + } + if (body.in_reply_to_id && removed === "/unreact") { + try { + const id = body.in_reply_to_id; + const post = await client.getStatus(id); + const react = post.data.emoji_reactions.filter((e: any) => e.me)[0].name; + const data = await client.deleteEmojiReaction(id, react); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + } + if (!body.media_ids) body.media_ids = undefined; + if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; + if (body.media_ids) { + body.media_ids = (body.media_ids as string[]).map((p) =>convertId(p, IdType.SharkeyId)); + } + + const { sensitive } = body; + body.sensitive = typeof sensitive === "string" ? sensitive === "true" : sensitive; + + if (body.poll) { + if ( + body.poll.expires_in != null && + typeof body.poll.expires_in === "string" + ) + body.poll.expires_in = parseInt(body.poll.expires_in); + if ( + body.poll.multiple != null && + typeof body.poll.multiple === "string" + ) + body.poll.multiple = body.poll.multiple == "true"; + if ( + body.poll.hide_totals != null && + typeof body.poll.hide_totals === "string" + ) + body.poll.hide_totals = body.poll.hide_totals == "true"; + } + + const data = await client.postStatus(text, body); + reply.send(convertStatus(data.data as Entity.Status)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async addFavourite() { + this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/favourite", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = (await client.createEmojiReaction( + convertId(_request.params.id, IdType.SharkeyId), + '⭐' + )) as any; + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async rmFavourite() { + this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/unfavourite", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.deleteEmojiReaction( + convertId(_request.params.id, IdType.SharkeyId), + '⭐' + ); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async reblogStatus() { + this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/reblog", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.reblogStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async unreblogStatus() { + this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/unreblog", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unreblogStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async bookmarkStatus() { + this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/bookmark", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.bookmarkStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async unbookmarkStatus() { + this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/unbookmark", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unbookmarkStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async pinStatus() { + this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/pin", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.pinStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async unpinStatus() { + this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/unpin", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unpinStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async reactStatus() { + this.fastify.post<{ Params: { id: string, name: string } }>("/v1/statuses/:id/react/:name", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.createEmojiReaction(convertId(_request.params.id, IdType.SharkeyId), _request.params.name); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async unreactStatus() { + this.fastify.post<{ Params: { id: string, name: string } }>("/v1/statuses/:id/unreact/:name", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.deleteEmojiReaction(convertId(_request.params.id, IdType.SharkeyId), _request.params.name); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async updateMedia() { + this.fastify.put<{ Params: { id: string } }>("/v1/media/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.updateMedia(convertId(_request.params.id, IdType.SharkeyId), _request.body as any); + reply.send(convertAttachment(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async deleteStatus() { + this.fastify.delete<{ Params: { id: string } }>("/v1/statuses/:id", async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.deleteStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } } \ No newline at end of file From db6dc1b52e53dc086440969ed9f6363aceecbfcf Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 17:36:13 +0200 Subject: [PATCH 28/31] chore: lint and update package name --- package.json | 4 +- .../api/mastodon/MastodonApiServerService.ts | 1397 ++++++++--------- .../src/server/api/mastodon/converters.ts | 85 +- .../src/server/api/mastodon/endpoints.ts | 30 +- .../server/api/mastodon/endpoints/account.ts | 360 ++--- .../src/server/api/mastodon/endpoints/auth.ts | 62 +- .../server/api/mastodon/endpoints/filter.ts | 62 +- .../src/server/api/mastodon/endpoints/meta.ts | 8 +- .../api/mastodon/endpoints/notifications.ts | 74 +- .../server/api/mastodon/endpoints/search.ts | 131 +- .../server/api/mastodon/endpoints/status.ts | 759 +++++---- .../server/api/mastodon/endpoints/timeline.ts | 347 ++-- 12 files changed, 1636 insertions(+), 1683 deletions(-) diff --git a/package.json b/package.json index 8b5f574947..6389287c4e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "misskey", + "name": "sharkey", "version": "2023.9.0-beta.10", "codename": "nasubi", "repository": { "type": "git", - "url": "https://github.com/misskey-dev/misskey.git" + "url": "https://github.com/transfem-org/sharkey.git" }, "packageManager": "pnpm@8.7.6", "workspaces": [ diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index da5ab28319..883d25aaad 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -1,19 +1,16 @@ -import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; +import megalodon, { Entity, MegalodonInterface } from 'megalodon'; +import { IsNull } from 'typeorm'; +import multer from 'fastify-multer'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import megalodon, { Entity, MegalodonInterface } from 'megalodon'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; -import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList } from './converters.js'; -import { IsNull } from 'typeorm'; import type { Config } from '@/config.js'; -import { getInstance } from './endpoints/meta.js'; import { MetaService } from '@/core/MetaService.js'; -import multer from 'fastify-multer'; -import { apiAuthMastodon, apiAccountMastodon, apiFilterMastodon, apiNotifyMastodon, apiSearchMastodon, apiTimelineMastodon, apiStatusMastodon } from './endpoints.js'; - -const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); +import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList } from './converters.js'; +import { getInstance } from './endpoints/meta.js'; +import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { const accessTokenArr = authorization?.split(' ') ?? [null]; @@ -35,752 +32,752 @@ export class MastodonApiServerService { @bindThis public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); + const upload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: this.config.maxFileSize || 262144000, + files: 1, + }, + }); - fastify.register(multer.contentParser); + fastify.register(multer.contentParser); - fastify.get('/v1/custom_emojis', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getInstanceCustomEmojis(); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/custom_emojis', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getInstanceCustomEmojis(); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/instance', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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 : convertAccount((await client.getAccount(admin.id)).data); - reply.send(await getInstance(data.data, contact, this.config, await this.metaService.fetch())); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/instance', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 : convertAccount((await client.getAccount(admin.id)).data); + reply.send(await getInstance(data.data, contact, this.config, await this.metaService.fetch())); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/announcements', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getInstanceAnnouncements(); - reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/announcements', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getInstanceAnnouncements(); + reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.dismissInstanceAnnouncement( - convertId(_request.body['id'], IdType.SharkeyId) - ); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }, - ); + fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.dismissInstanceAnnouncement( + convertId(_request.body['id'], IdType.SharkeyId), + ); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }, + ); - fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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 data = await client.uploadMedia(multipartData); - reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 data = await client.uploadMedia(multipartData); + reply.send(convertAttachment(data.data as Entity.Attachment)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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 data = await client.uploadMedia(multipartData, _request.body!); - reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 data = await client.uploadMedia(multipartData, _request.body!); + reply.send(convertAttachment(data.data as Entity.Attachment)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/filters', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/filters', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/trends', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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.getInstanceTrends(); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/trends', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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.getInstanceTrends(); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.post('/v1/apps', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.post('/v1/apps', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/preferences', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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.getPreferences(); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/preferences', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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.getPreferences(); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - //#region Accounts - fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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 account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.verifyCredentials()); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + //#region Accounts + fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.verifyCredentials()); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.patch('/v1/accounts/update_credentials', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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 account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.updateCredentials()); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.patch('/v1/accounts/update_credentials', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.updateCredentials()); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/accounts/lookup', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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 account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.lookup()); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/accounts/lookup', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.lookup()); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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 - let users; - try { - let ids = _request.query ? (_request.query as any)['id[]'] : null; - if (typeof ids === 'string') { - ids = [ids]; - } - users = ids; - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.getRelationships(users)); - } catch (e: any) { - console.error(e); - let data = e.response.data; - data.users = users; - console.error(data); - reply.code(401).send(data); - } - }); + fastify.get('/v1/accounts/relationships', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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 + let users; + try { + let ids = _request.query ? (_request.query as any)['id[]'] : null; + if (typeof ids === 'string') { + ids = [ids]; + } + users = ids; + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getRelationships(users)); + } catch (e: any) { + console.error(e); + const data = e.response.data; + data.users = users; + console.error(data); + reply.code(401).send(data); + } + }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const sharkId = convertId(_request.params.id, IdType.SharkeyId); - const data = await client.getAccount(sharkId); - reply.send(convertAccount(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const sharkId = convertId(_request.params.id, IdType.SharkeyId); + const data = await client.getAccount(sharkId); + reply.send(convertAccount(data.data)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.getStatuses()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getStatuses()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFeaturedTags(); - reply.send(data.data.map((tag) => convertFeaturedTag(tag))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFeaturedTags(); + reply.send(data.data.map((tag) => convertFeaturedTag(tag))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.getFollowers()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getFollowers()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.getFollowing()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getFollowing()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getAccountLists(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(data.data.map((list) => convertList(list))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountLists(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data.map((list) => convertList(list))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.addFollow()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.addFollow()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.rmFollow()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.rmFollow()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.addBlock()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.addBlock()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.rmBlock()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.rmBlock()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.addMute()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.addMute()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.rmMute()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.rmMute()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/followed_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFollowedTags(); - reply.send(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/followed_tags', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFollowedTags(); + reply.send(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/bookmarks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.getBookmarks()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/bookmarks', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getBookmarks()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/favourites', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.getFavourites()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/favourites', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getFavourites()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/mutes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.getMutes()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/mutes', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getMutes()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/blocks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.getBlocks()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/blocks', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.getBlocks()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/follow_requests', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit ); - reply.send(data.data.map((account) => convertAccount(account as Entity.Account))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/follow_requests', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit ); + reply.send(data.data.map((account) => convertAccount(account as Entity.Account))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.acceptFollow()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.acceptFollow()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const account = new apiAccountMastodon(_request, client, BASE_URL); - reply.send(await account.rejectFollow()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - //#endregion + fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const account = new ApiAccountMastodon(_request, client, BASE_URL); + reply.send(await account.rejectFollow()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + //#endregion - //#region Search - fastify.get('/v1/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const search = new apiSearchMastodon(_request, client, BASE_URL); - reply.send(await search.SearchV1()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + //#region Search + fastify.get('/v1/search', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const search = new ApiSearchMastodon(_request, client, BASE_URL); + reply.send(await search.SearchV1()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v2/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const search = new apiSearchMastodon(_request, client, BASE_URL); - reply.send(await search.SearchV2()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v2/search', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const search = new ApiSearchMastodon(_request, client, BASE_URL); + reply.send(await search.SearchV2()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const search = new apiSearchMastodon(_request, client, BASE_URL); - reply.send(await search.getStatusTrends()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get('/v1/trends/statuses', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const search = new ApiSearchMastodon(_request, client, BASE_URL); + reply.send(await search.getStatusTrends()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get('/v2/suggestions', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const search = new apiSearchMastodon(_request, client, BASE_URL); - reply.send(await search.getSuggestions()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - //#endregion + fastify.get('/v2/suggestions', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const search = new ApiSearchMastodon(_request, client, BASE_URL); + reply.send(await search.getSuggestions()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + //#endregion - //#region Notifications - fastify.get('/v1/notifications', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const notify = new apiNotifyMastodon(_request, client); - reply.send(await notify.getNotifications()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + //#region Notifications + fastify.get('/v1/notifications', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const notify = new ApiNotifyMastodon(_request, client); + reply.send(await notify.getNotifications()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const notify = new apiNotifyMastodon(_request, client); - reply.send(await notify.getNotification()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const notify = new ApiNotifyMastodon(_request, client); + reply.send(await notify.getNotification()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const notify = new apiNotifyMastodon(_request, client); - reply.send(await notify.rmNotification()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const notify = new ApiNotifyMastodon(_request, client); + reply.send(await notify.rmNotification()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post('/v1/notifications/clear', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const notify = new apiNotifyMastodon(_request, client); - reply.send(await notify.rmNotifications()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - //#endregion + fastify.post('/v1/notifications/clear', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const notify = new ApiNotifyMastodon(_request, client); + reply.send(await notify.rmNotifications()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + //#endregion - //#region Filters - fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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.getFilters()) : reply.send(await filter.getFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + //#region Filters + fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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.getFilters()) : reply.send(await filter.getFilter()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post('/v1/filters', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - 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: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post('/v1/filters', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + 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: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.post<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new apiFilterMastodon(_request, client); - reply.send(await filter.updateFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + fastify.post<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const filter = new ApiFilterMastodon(_request, client); + reply.send(await filter.updateFilter()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); - fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const filter = new apiFilterMastodon(_request, client); - reply.send(await filter.rmFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); - //#endregion + fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const filter = new ApiFilterMastodon(_request, client); + reply.send(await filter.rmFilter()); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); + //#endregion - //#region Timelines - const TLEndpoint = new apiTimelineMastodon(fastify); + //#region Timelines + const TLEndpoint = new ApiTimelineMastodon(fastify); - // GET Endpoints - TLEndpoint.getTL(); - TLEndpoint.getHomeTl(); - TLEndpoint.getListTL(); - TLEndpoint.getTagTl(); - TLEndpoint.getConversations(); - TLEndpoint.getList(); - TLEndpoint.getLists(); - TLEndpoint.getListAccounts(); + // GET Endpoints + TLEndpoint.getTL(); + TLEndpoint.getHomeTl(); + TLEndpoint.getListTL(); + TLEndpoint.getTagTl(); + TLEndpoint.getConversations(); + TLEndpoint.getList(); + TLEndpoint.getLists(); + TLEndpoint.getListAccounts(); - // POST Endpoints - TLEndpoint.createList(); - TLEndpoint.addListAccount(); + // POST Endpoints + TLEndpoint.createList(); + TLEndpoint.addListAccount(); - // PUT Endpoint - TLEndpoint.updateList(); + // PUT Endpoint + TLEndpoint.updateList(); - // DELETE Endpoints - TLEndpoint.deleteList(); - TLEndpoint.rmListAccount(); - //#endregion + // DELETE Endpoints + TLEndpoint.deleteList(); + TLEndpoint.rmListAccount(); + //#endregion - //#region Status - const NoteEndpoint = new apiStatusMastodon(fastify); + //#region Status + const NoteEndpoint = new ApiStatusMastodon(fastify); - // GET Endpoints - NoteEndpoint.getStatus(); - NoteEndpoint.getContext(); - NoteEndpoint.getHistory(); - NoteEndpoint.getReblogged(); - NoteEndpoint.getFavourites(); - NoteEndpoint.getMedia(); - NoteEndpoint.getPoll(); + // GET Endpoints + NoteEndpoint.getStatus(); + 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(); + //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 - NoteEndpoint.updateMedia(); + // PUT Endpoint + NoteEndpoint.updateMedia(); - // DELETE Endpoint - NoteEndpoint.deleteStatus(); - //#endregion + // DELETE Endpoint + NoteEndpoint.deleteStatus(); + //#endregion done(); } -} \ No newline at end of file +} diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 5d77a3e3f5..4621a50ff4 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -1,6 +1,6 @@ import { Entity } from 'megalodon'; -const CHAR_COLLECTION: string = '0123456789abcdefghijklmnopqrstuvwxyz'; +const CHAR_COLLECTION = '0123456789abcdefghijklmnopqrstuvwxyz'; export enum IdConvertType { MastodonId, @@ -8,48 +8,50 @@ export enum IdConvertType { } export function convertId(in_id: string, id_convert_type: IdConvertType): string { - switch (id_convert_type) { - case IdConvertType.MastodonId: - let out: bigint = BigInt(0); - const lowerCaseId = in_id.toLowerCase(); - for (let i = 0; i < lowerCaseId.length; i++) { - const charValue = numFromChar(lowerCaseId.charAt(i)); - out += BigInt(charValue) * BigInt(36) ** BigInt(i); - } - return out.toString(); + switch (id_convert_type) { + case IdConvertType.MastodonId: { + let out = BigInt(0); + const lowerCaseId = in_id.toLowerCase(); + for (let i = 0; i < lowerCaseId.length; i++) { + const charValue = numFromChar(lowerCaseId.charAt(i)); + out += BigInt(charValue) * BigInt(36) ** BigInt(i); + } + return out.toString(); + } - case IdConvertType.SharkeyId: - let input: bigint = BigInt(in_id); - let outStr = ''; - while (input > BigInt(0)) { - const remainder = Number(input % BigInt(36)); - outStr = charFromNum(remainder) + outStr; - input /= BigInt(36); - } - let ReversedoutStr = outStr.split('').reduce((acc, char) => char + acc, ''); - return ReversedoutStr; + case IdConvertType.SharkeyId: { + let input = BigInt(in_id); + let outStr = ''; + while (input > BigInt(0)) { + const remainder = Number(input % BigInt(36)); + outStr = charFromNum(remainder) + outStr; + input /= BigInt(36); + } + const ReversedoutStr = outStr.split('').reduce((acc, char) => char + acc, ''); + return ReversedoutStr; + } - default: - throw new Error('Invalid ID conversion type'); - } + default: + throw new Error('Invalid ID conversion type'); + } } function numFromChar(character: string): number { - for (let i = 0; i < CHAR_COLLECTION.length; i++) { - if (CHAR_COLLECTION.charAt(i) === character) { - return i; - } - } + for (let i = 0; i < CHAR_COLLECTION.length; i++) { + if (CHAR_COLLECTION.charAt(i) === character) { + return i; + } + } - throw new Error('Invalid character in parsed base36 id'); + throw new Error('Invalid character in parsed base36 id'); } function charFromNum(number: number): string { - if (number >= 0 && number < CHAR_COLLECTION.length) { - return CHAR_COLLECTION.charAt(number); - } else { - throw new Error('Invalid number for base-36 encoding'); - } + if (number >= 0 && number < CHAR_COLLECTION.length) { + return CHAR_COLLECTION.charAt(number); + } else { + throw new Error('Invalid number for base-36 encoding'); + } } function simpleConvert(data: any) { @@ -81,8 +83,7 @@ export function convertFeaturedTag(tag: Entity.FeaturedTag) { export function convertNotification(notification: Entity.Notification) { notification.account = convertAccount(notification.account); notification.id = convertId(notification.id, IdConvertType.MastodonId); - if (notification.status) - notification.status = convertStatus(notification.status); + if (notification.status) notification.status = convertStatus(notification.status); return notification; } @@ -102,13 +103,11 @@ export function convertRelationship(relationship: Entity.Relationship) { export function convertStatus(status: Entity.Status) { status.account = convertAccount(status.account); status.id = convertId(status.id, IdConvertType.MastodonId); - if (status.in_reply_to_account_id) - status.in_reply_to_account_id = convertId( - status.in_reply_to_account_id, - IdConvertType.MastodonId, - ); - if (status.in_reply_to_id) - status.in_reply_to_id = convertId(status.in_reply_to_id, IdConvertType.MastodonId); + if (status.in_reply_to_account_id) status.in_reply_to_account_id = convertId( + status.in_reply_to_account_id, + IdConvertType.MastodonId, + ); + if (status.in_reply_to_id) status.in_reply_to_id = convertId(status.in_reply_to_id, IdConvertType.MastodonId); status.media_attachments = status.media_attachments.map((attachment) => convertAttachment(attachment), ); diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts index 92cf6fe3d1..5a75823891 100644 --- a/packages/backend/src/server/api/mastodon/endpoints.ts +++ b/packages/backend/src/server/api/mastodon/endpoints.ts @@ -1,17 +1,17 @@ -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'; +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 -} + 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 21e1fa9fc3..23c96281f0 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -1,7 +1,7 @@ +import { convertId, IdConvertType as IdType, convertAccount, convertRelationship, convertStatus } from '../converters.js'; +import { argsToBools, convertTimelinesArgsId, limitToInt } from './timeline.js'; import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -import { argsToBools, convertTimelinesArgsId, limitToInt } from './timeline.js'; -import { convertId, IdConvertType as IdType, convertAccount, convertRelationship, convertStatus } from '../converters.js'; const relationshipModel = { id: '', @@ -20,21 +20,21 @@ const relationshipModel = { note: '', }; -export class apiAccountMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - private BASE_URL: string; +export class ApiAccountMastodon { + private request: FastifyRequest; + private client: MegalodonInterface; + private BASE_URL: string; - constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) { - this.request = request; - this.client = client; - this.BASE_URL = BASE_URL; - } + constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) { + this.request = request; + this.client = client; + this.BASE_URL = BASE_URL; + } - public async verifyCredentials() { - try { + public async verifyCredentials() { + try { const data = await this.client.verifyAccountCredentials(); - let acct = data.data; + const acct = data.data; acct.id = convertId(acct.id, IdType.MastodonId); acct.display_name = acct.display_name || acct.username; acct.url = `${this.BASE_URL}/@${acct.url}`; @@ -56,230 +56,230 @@ export class apiAccountMastodon { console.error(e.response.data); return e.response.data; } - } + } - public async updateCredentials() { - try { - const data = await this.client.updateCredentials(this.request.body as any); - return convertAccount(data.data); - } catch (e: any) { - console.error(e); + public async updateCredentials() { + try { + const data = await this.client.updateCredentials(this.request.body as any); + return convertAccount(data.data); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async lookup() { - try { - const data = await this.client.search((this.request.query as any).acct, { type: 'accounts' }); - return convertAccount(data.data.accounts[0]); - } catch (e: any) { - console.error(e); + public async lookup() { + try { + const data = await this.client.search((this.request.query as any).acct, { type: 'accounts' }); + return convertAccount(data.data.accounts[0]); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async getRelationships(users: [string]) { - try { - relationshipModel.id = users?.toString() || '1'; + public async getRelationships(users: [string]) { + try { + relationshipModel.id = users.toString() || '1'; - if (!users) { + if (!(users.length > 0)) { return [relationshipModel]; } - let reqIds = []; + const reqIds = []; for (let i = 0; i < users.length; i++) { reqIds.push(convertId(users[i], IdType.SharkeyId)); } const data = await this.client.getRelationships(reqIds); return data.data.map((relationship) => convertRelationship(relationship)); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async getStatuses() { - try { - const data = await this.client.getAccountStatuses( - convertId((this.request.params as any).id, IdType.SharkeyId), - convertTimelinesArgsId(argsToBools(limitToInt(this.request.query as any))) - ); + public async getStatuses() { + try { + const data = await this.client.getAccountStatuses( + convertId((this.request.params as any).id, IdType.SharkeyId), + convertTimelinesArgsId(argsToBools(limitToInt(this.request.query as any))), + ); return data.data.map((status) => convertStatus(status)); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async getFollowers() { - try { - const data = await this.client.getAccountFollowers( - convertId((this.request.params as any).id, IdType.SharkeyId), - convertTimelinesArgsId(limitToInt(this.request.query as any)) - ); + public async getFollowers() { + try { + const data = await this.client.getAccountFollowers( + convertId((this.request.params as any).id, IdType.SharkeyId), + convertTimelinesArgsId(limitToInt(this.request.query as any)), + ); return data.data.map((account) => convertAccount(account)); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async getFollowing() { - try { - const data = await this.client.getAccountFollowing( - convertId((this.request.params as any).id, IdType.SharkeyId), - convertTimelinesArgsId(limitToInt(this.request.query as any)) - ); + public async getFollowing() { + try { + const data = await this.client.getAccountFollowing( + convertId((this.request.params as any).id, IdType.SharkeyId), + convertTimelinesArgsId(limitToInt(this.request.query as any)), + ); return data.data.map((account) => convertAccount(account)); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async addFollow() { - try { - const data = await this.client.followAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); - let acct = convertRelationship(data.data); - acct.following = true; + public async addFollow() { + try { + const data = await this.client.followAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + const acct = convertRelationship(data.data); + acct.following = true; return acct; - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async rmFollow() { - try { - const data = await this.client.unfollowAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); - let acct = convertRelationship(data.data); - acct.following = false; + public async rmFollow() { + try { + const data = await this.client.unfollowAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + const acct = convertRelationship(data.data); + acct.following = false; return acct; - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async addBlock() { - try { - const data = await this.client.blockAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + public async addBlock() { + try { + const data = await this.client.blockAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); return convertRelationship(data.data); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async rmBlock() { - try { - const data = await this.client.unblockAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + public async rmBlock() { + try { + const data = await this.client.unblockAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); return convertRelationship(data.data); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async addMute() { - try { - const data = await this.client.muteAccount( - convertId((this.request.params as any).id, IdType.SharkeyId), - this.request.body as any - ); + public async addMute() { + try { + const data = await this.client.muteAccount( + convertId((this.request.params as any).id, IdType.SharkeyId), + this.request.body as any, + ); return convertRelationship(data.data); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async rmMute() { - try { - const data = await this.client.unmuteAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); + public async rmMute() { + try { + const data = await this.client.unmuteAccount( convertId((this.request.params as any).id, IdType.SharkeyId) ); return convertRelationship(data.data); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async getBookmarks() { - try { - const data = await this.client.getBookmarks( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); + public async getBookmarks() { + try { + const data = await this.client.getBookmarks( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); return data.data.map((status) => convertStatus(status)); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async getFavourites() { - try { - const data = await this.client.getFavourites( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); + public async getFavourites() { + try { + const data = await this.client.getFavourites( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); return data.data.map((status) => convertStatus(status)); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async getMutes() { - try { - const data = await this.client.getMutes( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); + public async getMutes() { + try { + const data = await this.client.getMutes( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); return data.data.map((account) => convertAccount(account)); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async getBlocks() { - try { - const data = await this.client.getBlocks( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); + public async getBlocks() { + try { + const data = await this.client.getBlocks( convertTimelinesArgsId(limitToInt(this.request.query as any)) ); return data.data.map((account) => convertAccount(account)); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async acceptFollow() { - try { - const data = await this.client.acceptFollowRequest( convertId((this.request.params as any).id, IdType.SharkeyId) ); + public async acceptFollow() { + try { + const data = await this.client.acceptFollowRequest( convertId((this.request.params as any).id, IdType.SharkeyId) ); return convertRelationship(data.data); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } + return e.response.data; + } + } - public async rejectFollow() { - try { - const data = await this.client.rejectFollowRequest( convertId((this.request.params as any).id, IdType.SharkeyId) ); + public async rejectFollow() { + try { + const data = await this.client.rejectFollowRequest( convertId((this.request.params as any).id, IdType.SharkeyId) ); return convertRelationship(data.data); - } catch (e: any) { - console.error(e); + } catch (e: any) { + console.error(e); console.error(e.response.data); - return e.response.data; - } - } -} \ No newline at end of file + return e.response.data; + } + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts index 27664ae1fa..3eb92644bc 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -39,36 +39,36 @@ const writeScope = [ 'write:gallery-likes', ]; -export async function apiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { - const body: any = request.body || request.query; - try { - let scope = body.scopes; - if (typeof scope === 'string') scope = scope.split(' '); - 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 scopeArr = Array.from(pushScope); - - const red = body.redirect_uris; - const appData = await client.registerApp(body.client_name, { - scopes: scopeArr, - redirect_uris: red, - website: body.website, - }); - const returns = { - 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, - }; - - return returns; - } catch (e: any) { - console.error(e); - return e.response.data; +export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { + const body: any = request.body || request.query; + try { + let scope = body.scopes; + if (typeof scope === 'string') scope = scope.split(' '); + 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 scopeArr = Array.from(pushScope); + + const red = body.redirect_uris; + const appData = await client.registerApp(body.client_name, { + scopes: scopeArr, + redirect_uris: red, + website: body.website, + }); + const returns = { + 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, + }; + + return returns; + } catch (e: any) { + console.error(e); + return e.response.data; + } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 175f2d519e..e27bc956fa 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -1,65 +1,65 @@ +import { IdConvertType as IdType, convertId, convertFilter } from '../converters.js'; import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -import { IdConvertType as IdType, convertId, convertFilter } from '../converters.js'; -export class apiFilterMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; +export class ApiFilterMastodon { + private request: FastifyRequest; + private client: MegalodonInterface; - constructor(request: FastifyRequest, client: MegalodonInterface) { - this.request = request; - this.client = client; - } + constructor(request: FastifyRequest, client: MegalodonInterface) { + this.request = request; + this.client = client; + } - public async getFilters() { - try { + public async getFilters() { + try { const data = await this.client.getFilters(); - return data.data.map((filter) => convertFilter(filter)); + return data.data.map((filter) => convertFilter(filter)); } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async getFilter() { - try { + public async getFilter() { + try { const data = await this.client.getFilter( convertId((this.request.params as any).id, IdType.SharkeyId) ); - return convertFilter(data.data); + return convertFilter(data.data); } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async createFilter() { - try { - const body: any = this.request.body; + public async createFilter() { + try { + const body: any = this.request.body; const data = await this.client.createFilter(body.pharse, body.context, body); - return convertFilter(data.data); + return convertFilter(data.data); } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async updateFilter() { - try { - const body: any = this.request.body; + public async updateFilter() { + try { + const body: any = this.request.body; const data = await this.client.updateFilter(convertId((this.request.params as any).id, IdType.SharkeyId), body.pharse, body.context); - return convertFilter(data.data); + return convertFilter(data.data); } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async rmFilter() { - try { + public async rmFilter() { + try { const data = await this.client.deleteFilter( convertId((this.request.params as any).id, IdType.SharkeyId) ); - return data.data; + return data.data; } catch (e: any) { console.error(e); return e.response.data; } - } -} \ No newline at end of file + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts index 28ea6c08fd..77b643ff7f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -6,8 +6,8 @@ import type { MiMeta } from '@/models/Meta.js'; export async function getInstance( response: Entity.Instance, contact: Entity.Account, - config: Config, - meta: MiMeta, + config: Config, + meta: MiMeta, ) { return { uri: config.url, @@ -16,7 +16,7 @@ export async function getInstance( meta.description?.substring(0, 50) || 'See real server website', description: meta.description || - "This is a vanilla Sharkey Instance. It doesn't seem to have a 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, @@ -60,4 +60,4 @@ export async function getInstance( contact_account: contact, rules: [], }; -} \ No newline at end of file +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 667379e8bc..1dbb8d8b72 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -1,71 +1,71 @@ -import type { MegalodonInterface } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; -import { convertTimelinesArgsId } from './timeline.js'; import { IdConvertType as IdType, convertId, convertNotification } from '../converters.js'; +import { convertTimelinesArgsId } from './timeline.js'; +import type { MegalodonInterface, Entity } from 'megalodon'; +import type { FastifyRequest } from 'fastify'; function toLimitToInt(q: any) { - if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10); - return q; + if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10); + return q; } -export class apiNotifyMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; +export class ApiNotifyMastodon { + private request: FastifyRequest; + private client: MegalodonInterface; - constructor(request: FastifyRequest, client: MegalodonInterface) { - this.request = request; - this.client = client; - } + constructor(request: FastifyRequest, client: MegalodonInterface) { + this.request = request; + this.client = client; + } - public async getNotifications() { - try { + public async getNotifications() { + try { const data = await this.client.getNotifications( convertTimelinesArgsId(toLimitToInt(this.request.query)) ); - const notifs = data.data; - const processed = notifs.map((n) => { - n = convertNotification(n); - if (n.type !== 'follow' && n.type !== 'follow_request') { - if (n.type === 'reaction') n.type = 'favourite'; - return n; - } else { - return n; - } - }); + const notifs = data.data; + const processed = notifs.map((n: Entity.Notification) => { + let convertedn = convertNotification(n); + if (convertedn.type !== 'follow' && convertedn.type !== 'follow_request') { + if (convertedn.type === 'reaction') convertedn.type = 'favourite'; + return convertedn; + } else { + return convertedn; + } + }); return processed; } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async getNotification() { - try { + public async getNotification() { + try { const data = await this.client.getNotification( convertId((this.request.params as any).id, IdType.SharkeyId) ); - const notif = convertNotification(data.data); - if (notif.type !== 'follow' && notif.type !== 'follow_request' && notif.type === 'reaction') notif.type = 'favourite'; + const notif = convertNotification(data.data); + if (notif.type !== 'follow' && notif.type !== 'follow_request' && notif.type === 'reaction') notif.type = 'favourite'; return notif; } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async rmNotification() { - try { + public async rmNotification() { + try { const data = await this.client.dismissNotification( convertId((this.request.params as any).id, IdType.SharkeyId) ); return data.data; } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async rmNotifications() { - try { + public async rmNotifications() { + try { const data = await this.client.dismissNotifications(); return data.data; } catch (e: any) { console.error(e); return e.response.data; } - } -} \ No newline at end of file + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 6b36582b91..5c68402ed8 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -1,30 +1,29 @@ -import type { MegalodonInterface } from 'megalodon'; -import { Converter } from 'megalodon'; -import type { FastifyRequest } from 'fastify'; -import { convertTimelinesArgsId, limitToInt } from './timeline.js'; +import { Converter } from 'megalodon'; import { convertAccount, convertStatus } from '../converters.js'; +import { convertTimelinesArgsId, limitToInt } from './timeline.js'; +import type { MegalodonInterface } from 'megalodon'; +import type { FastifyRequest } from 'fastify'; async function getHighlight( BASE_URL: string, domain: string, accessTokens: string | undefined, ) { - const accessTokenArr = accessTokens?.split(" ") ?? [null]; + const accessTokenArr = accessTokens?.split(' ') ?? [null]; const accessToken = accessTokenArr[accessTokenArr.length - 1]; try { - const apicall = await fetch(`${BASE_URL}/api/notes/featured`, - { - method: 'POST', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({i: accessToken}) - }); - const api = await apicall.json(); - const data: MisskeyEntity.Note[] = api; - return data.map((note) => Converter.note(note, domain)); + { + method: 'POST', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ i: accessToken }), + }); + const api = await apicall.json(); + const data: MisskeyEntity.Note[] = api; + return data.map((note) => Converter.note(note, domain)); } catch (e: any) { console.log(e); console.log(e.response.data); @@ -33,76 +32,76 @@ async function getHighlight( } async function getFeaturedUser( BASE_URL: string, host: string, accessTokens: string | undefined, limit: number ) { - const accessTokenArr = accessTokens?.split(" ") ?? [null]; + const accessTokenArr = accessTokens?.split(' ') ?? [null]; const accessToken = accessTokenArr[accessTokenArr.length - 1]; try { - const apicall = await fetch(`${BASE_URL}/api/users`, - { - method: 'POST', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({i: accessToken, limit, origin: "local", sort: "+follower", state: "alive"}) - }); - const api = await apicall.json(); - const data: MisskeyEntity.UserDetail[] = api; - return data.map((u) => { - return { - source: "past_interactions", - account: Converter.userDetail(u, host), - }; - }); + const apicall = await fetch(`${BASE_URL}/api/users`, + { + method: 'POST', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ i: accessToken, limit, origin: 'local', sort: '+follower', state: 'alive' }), + }); + const api = await apicall.json(); + const data: MisskeyEntity.UserDetail[] = api; + return data.map((u) => { + return { + source: 'past_interactions', + account: Converter.userDetail(u, host), + }; + }); } catch (e: any) { console.log(e); console.log(e.response.data); return []; } } -export class apiSearchMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - private BASE_URL: string; +export class ApiSearchMastodon { + private request: FastifyRequest; + private client: MegalodonInterface; + private BASE_URL: string; - constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) { - this.request = request; - this.client = client; - this.BASE_URL = BASE_URL; - } + constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) { + this.request = request; + this.client = client; + this.BASE_URL = BASE_URL; + } - public async SearchV1() { - try { + public async SearchV1() { + try { const query: any = convertTimelinesArgsId(limitToInt(this.request.query as any)); - const type = query.type || ""; + const type = query.type || ''; const data = await this.client.search(query.q, { type: type, ...query }); return data.data; } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async SearchV2() { - try { + public async SearchV2() { + try { const query: any = convertTimelinesArgsId(limitToInt(this.request.query as any)); const type = query.type; - const acct = !type || type === "accounts" ? await this.client.search(query.q, { type: "accounts", ...query }) : null; - const stat = !type || type === "statuses" ? await this.client.search(query.q, { type: "statuses", ...query }) : null; - const tags = !type || type === "hashtags" ? await this.client.search(query.q, { type: "hashtags", ...query }) : null; + const acct = !type || type === 'accounts' ? await this.client.search(query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await this.client.search(query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await this.client.search(query.q, { type: 'hashtags', ...query }) : null; const data = { - accounts: acct?.data.accounts.map((account) => convertAccount(account)) ?? [], - statuses: stat?.data.statuses.map((status) => convertStatus(status)) ?? [], - hashtags: tags?.data.hashtags ?? [] - }; + accounts: acct?.data.accounts.map((account) => convertAccount(account)) ?? [], + statuses: stat?.data.statuses.map((status) => convertStatus(status)) ?? [], + hashtags: tags?.data.hashtags ?? [], + }; return data; } catch (e: any) { console.error(e); return e.response.data; } - } + } - public async getStatusTrends() { - try { + public async getStatusTrends() { + try { const data = await getHighlight( this.BASE_URL, this.request.hostname, @@ -113,14 +112,14 @@ export class apiSearchMastodon { console.error(e); return e.response.data; } - } + } - public async getSuggestions() { - try { + public async getSuggestions() { + try { const data = await getFeaturedUser( this.BASE_URL, this.request.hostname, - this.request.headers.authorization, + this.request.headers.authorization, (this.request.query as any).limit || 20, ); return data.map((suggestion) => { suggestion.account = convertAccount(suggestion.account); return suggestion; }); @@ -128,5 +127,5 @@ export class apiSearchMastodon { console.error(e); return e.response.data; } - } -} \ No newline at end of file + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 85a67a4a1a..5ce0c8941e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -1,407 +1,400 @@ -import { convertId, IdConvertType as IdType, convertAccount, convertAttachment, convertPoll, convertStatus } from '../converters.js'; import querystring from 'querystring'; -import type { Entity, MegalodonInterface } from 'megalodon'; -import type { FastifyInstance } from 'fastify'; +import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; +import { convertId, IdConvertType as IdType, convertAccount, convertAttachment, convertPoll, convertStatus } from '../converters.js'; import { getClient } from '../MastodonApiServerService.js'; import { convertTimelinesArgsId, limitToInt } from './timeline.js'; -import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js"; -import { MetaService } from '@/core/MetaService.js'; +import type { Entity } from 'megalodon'; +import type { FastifyInstance } from 'fastify'; function normalizeQuery(data: any) { const str = querystring.stringify(data); return querystring.parse(str); } -export class apiStatusMastodon { - private fastify: FastifyInstance; - private metaService: MetaService; +export class ApiStatusMastodon { + private fastify: FastifyInstance; - constructor(fastify: FastifyInstance) { - this.fastify = fastify; - } + constructor(fastify: FastifyInstance) { + this.fastify = fastify; + } - public async getStatus() { - this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getStatus(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); - } - }); - } + public async getStatus() { + this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(_request.is404 ? 404 : 401).send(e.response.data); + } + }); + } - public async getContext() { - this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id/context", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const query: any = _request.query; - try { - const data = await client.getStatusContext( - convertId(_request.params.id, IdType.SharkeyId), - convertTimelinesArgsId(limitToInt(query)) - ); - data.data.ancestors = data.data.ancestors.map((status: Entity.Status) => convertStatus(status)); - data.data.descendants = data.data.descendants.map((status: Entity.Status) => convertStatus(status)); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); - } - }); - } - - public async getHistory() { - this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id/history", async (_request, reply) => { - try { - reply.code(401).send({ message: 'Not Implemented' }); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async getReblogged() { - this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id/reblogged_by", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getStatusRebloggedBy(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async getFavourites() { - this.fastify.get<{ Params: { id: string } }>("/v1/statuses/:id/favourited_by", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getStatusFavouritedBy(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async getMedia() { - this.fastify.get<{ Params: { id: string } }>("/v1/media/:id", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getMedia(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertAttachment(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async getPoll() { - this.fastify.get<{ Params: { id: string } }>("/v1/polls/:id", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getPoll(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertPoll(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async votePoll() { - this.fastify.post<{ Params: { id: string } }>("/v1/polls/:id/votes", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; - try { - const data = await client.votePoll(convertId(_request.params.id, IdType.SharkeyId), body.choices); - reply.send(convertPoll(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async postStatus() { - this.fastify.post("/v1/statuses", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - let body: any = _request.body; - try { - if (body.in_reply_to_id) - body.in_reply_to_id = convertId(body.in_reply_to_id, IdType.SharkeyId); - if (body.quote_id) - body.quote_id = convertId(body.quote_id, IdType.SharkeyId); - 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) || isCustomEmoji) { - const a = await client.createEmojiReaction( - body.in_reply_to_id, - removed, - ); - reply.send(a.data); - } - if (body.in_reply_to_id && removed === "/unreact") { - try { - const id = body.in_reply_to_id; - const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter((e: any) => e.me)[0].name; - const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - } - if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - if (body.media_ids) { - body.media_ids = (body.media_ids as string[]).map((p) =>convertId(p, IdType.SharkeyId)); - } - - const { sensitive } = body; - body.sensitive = typeof sensitive === "string" ? sensitive === "true" : sensitive; - - if (body.poll) { - if ( - body.poll.expires_in != null && - typeof body.poll.expires_in === "string" - ) - body.poll.expires_in = parseInt(body.poll.expires_in); - if ( - body.poll.multiple != null && - typeof body.poll.multiple === "string" - ) - body.poll.multiple = body.poll.multiple == "true"; - if ( - body.poll.hide_totals != null && - typeof body.poll.hide_totals === "string" - ) - body.poll.hide_totals = body.poll.hide_totals == "true"; - } - - const data = await client.postStatus(text, body); - reply.send(convertStatus(data.data as Entity.Status)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async addFavourite() { - this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/favourite", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = (await client.createEmojiReaction( + public async getContext() { + this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/context', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const query: any = _request.query; + try { + const data = await client.getStatusContext( convertId(_request.params.id, IdType.SharkeyId), - '⭐' - )) as any; - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } - - public async rmFavourite() { - this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/unfavourite", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.deleteEmojiReaction( - convertId(_request.params.id, IdType.SharkeyId), - '⭐' + convertTimelinesArgsId(limitToInt(query)), ); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + data.data.ancestors = data.data.ancestors.map((status: Entity.Status) => convertStatus(status)); + data.data.descendants = data.data.descendants.map((status: Entity.Status) => convertStatus(status)); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(_request.is404 ? 404 : 401).send(e.response.data); + } + }); + } - public async reblogStatus() { - this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/reblog", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.reblogStatus(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + public async getHistory() { + this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/history', async (_request, reply) => { + try { + reply.code(401).send({ message: 'Not Implemented' }); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } - public async unreblogStatus() { - this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/unreblog", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.unreblogStatus(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + public async getReblogged() { + this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getStatusRebloggedBy(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } - public async bookmarkStatus() { - this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/bookmark", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.bookmarkStatus(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + public async getFavourites() { + this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getStatusFavouritedBy(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } - public async unbookmarkStatus() { - this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/unbookmark", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.unbookmarkStatus(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + public async getMedia() { + this.fastify.get<{ Params: { id: string } }>('/v1/media/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getMedia(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertAttachment(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } - public async pinStatus() { - this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/pin", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.pinStatus(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + public async getPoll() { + this.fastify.get<{ Params: { id: string } }>('/v1/polls/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getPoll(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertPoll(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } - public async unpinStatus() { - this.fastify.post<{ Params: { id: string } }>("/v1/statuses/:id/unpin", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.unpinStatus(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + public async votePoll() { + this.fastify.post<{ Params: { id: string } }>('/v1/polls/:id/votes', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const body: any = _request.body; + try { + const data = await client.votePoll(convertId(_request.params.id, IdType.SharkeyId), body.choices); + reply.send(convertPoll(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } - public async reactStatus() { - this.fastify.post<{ Params: { id: string, name: string } }>("/v1/statuses/:id/react/:name", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.createEmojiReaction(convertId(_request.params.id, IdType.SharkeyId), _request.params.name); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + public async postStatus() { + this.fastify.post('/v1/statuses', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + let body: any = _request.body; + try { + if (body.in_reply_to_id) body.in_reply_to_id = convertId(body.in_reply_to_id, IdType.SharkeyId); + if (body.quote_id) body.quote_id = convertId(body.quote_id, IdType.SharkeyId); + 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) || isCustomEmoji) { + const a = await client.createEmojiReaction( + body.in_reply_to_id, + removed, + ); + reply.send(a.data); + } + if (body.in_reply_to_id && removed === '/unreact') { + try { + const id = body.in_reply_to_id; + const post = await client.getStatus(id); + const react = post.data.emoji_reactions.filter((e: any) => e.me)[0].name; + const data = await client.deleteEmojiReaction(id, react); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + } + if (!body.media_ids) body.media_ids = undefined; + if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; + if (body.media_ids) { + body.media_ids = (body.media_ids as string[]).map((p) => convertId(p, IdType.SharkeyId)); + } - public async unreactStatus() { - this.fastify.post<{ Params: { id: string, name: string } }>("/v1/statuses/:id/unreact/:name", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.deleteEmojiReaction(convertId(_request.params.id, IdType.SharkeyId), _request.params.name); - reply.send(convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + const { sensitive } = body; + body.sensitive = typeof sensitive === 'string' ? sensitive === 'true' : sensitive; - public async updateMedia() { - this.fastify.put<{ Params: { id: string } }>("/v1/media/:id", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.updateMedia(convertId(_request.params.id, IdType.SharkeyId), _request.body as any); - reply.send(convertAttachment(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } + if (body.poll) { + if ( + body.poll.expires_in != null && + typeof body.poll.expires_in === 'string' + ) body.poll.expires_in = parseInt(body.poll.expires_in); + if ( + body.poll.multiple != null && + typeof body.poll.multiple === 'string' + ) body.poll.multiple = body.poll.multiple === 'true'; + if ( + body.poll.hide_totals != null && + typeof body.poll.hide_totals === 'string' + ) body.poll.hide_totals = body.poll.hide_totals === 'true'; + } - public async deleteStatus() { - this.fastify.delete<{ Params: { id: string } }>("/v1/statuses/:id", async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.deleteStatus(convertId(_request.params.id, IdType.SharkeyId)); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } - }); - } -} \ No newline at end of file + const data = await client.postStatus(text, body); + reply.send(convertStatus(data.data as Entity.Status)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async addFavourite() { + this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = (await client.createEmojiReaction( + convertId(_request.params.id, IdType.SharkeyId), + '⭐', + )) as any; + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async rmFavourite() { + this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.deleteEmojiReaction( + convertId(_request.params.id, IdType.SharkeyId), + '⭐', + ); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async reblogStatus() { + this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.reblogStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async unreblogStatus() { + this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unreblogStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async bookmarkStatus() { + this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.bookmarkStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async unbookmarkStatus() { + this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unbookmarkStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async pinStatus() { + this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.pinStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async unpinStatus() { + this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unpinStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async reactStatus() { + this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.createEmojiReaction(convertId(_request.params.id, IdType.SharkeyId), _request.params.name); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async unreactStatus() { + this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.deleteEmojiReaction(convertId(_request.params.id, IdType.SharkeyId), _request.params.name); + reply.send(convertStatus(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async updateMedia() { + this.fastify.put<{ Params: { id: string } }>('/v1/media/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.updateMedia(convertId(_request.params.id, IdType.SharkeyId), _request.body as any); + reply.send(convertAttachment(data.data)); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } + + public async deleteStatus() { + this.fastify.delete<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.deleteStatus(convertId(_request.params.id, IdType.SharkeyId)); + reply.send(data.data); + } catch (e: any) { + console.error(e); + reply.code(401).send(e.response.data); + } + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index ec7010ee70..a171205161 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -1,15 +1,13 @@ -import { convertId, IdConvertType as IdType, convertAccount, convertConversation, convertList, convertStatus } from '../converters.js'; import { ParsedUrlQuery } from 'querystring'; -import type { Entity, MegalodonInterface } from 'megalodon'; -import type { FastifyInstance } from 'fastify'; +import { convertId, IdConvertType as IdType, convertAccount, convertConversation, convertList, convertStatus } from '../converters.js'; import { getClient } from '../MastodonApiServerService.js'; +import type { Entity } from 'megalodon'; +import type { FastifyInstance } from 'fastify'; export function limitToInt(q: ParsedUrlQuery) { - let object: any = q; - if (q.limit) - if (typeof q.limit === 'string') object.limit = parseInt(q.limit, 10); - if (q.offset) - if (typeof q.offset === 'string') object.offset = parseInt(q.offset, 10); + const object: any = q; + if (q.limit) if (typeof q.limit === 'string') object.limit = parseInt(q.limit, 10); + if (q.offset) if (typeof q.offset === 'string') object.offset = parseInt(q.offset, 10); return object; } @@ -22,165 +20,133 @@ export function argsToBools(q: ParsedUrlQuery) { // - https://docs.joinmastodon.org/methods/accounts/#statuses // - https://docs.joinmastodon.org/methods/timelines/#public // - https://docs.joinmastodon.org/methods/timelines/#tag - let object: any = q; - if (q.only_media) - if (typeof q.only_media === 'string') - object.only_media = toBoolean(q.only_media); - if (q.exclude_replies) - if (typeof q.exclude_replies === 'string') - object.exclude_replies = toBoolean(q.exclude_replies); - if (q.exclude_reblogs) - if (typeof q.exclude_reblogs === 'string') - object.exclude_reblogs = toBoolean(q.exclude_reblogs); - if (q.pinned) - if (typeof q.pinned === 'string') object.pinned = toBoolean(q.pinned); - if (q.local) - if (typeof q.local === 'string') object.local = toBoolean(q.local); + const object: any = q; + if (q.only_media) if (typeof q.only_media === 'string') object.only_media = toBoolean(q.only_media); + if (q.exclude_replies) if (typeof q.exclude_replies === 'string') object.exclude_replies = toBoolean(q.exclude_replies); + if (q.exclude_reblogs) if (typeof q.exclude_reblogs === 'string') object.exclude_reblogs = toBoolean(q.exclude_reblogs); + if (q.pinned) if (typeof q.pinned === 'string') object.pinned = toBoolean(q.pinned); + if (q.local) if (typeof q.local === 'string') object.local = toBoolean(q.local); return q; } export function convertTimelinesArgsId(q: ParsedUrlQuery) { - if (typeof q.min_id === 'string') - q.min_id = convertId(q.min_id, IdType.SharkeyId); - if (typeof q.max_id === 'string') - q.max_id = convertId(q.max_id, IdType.SharkeyId); - if (typeof q.since_id === 'string') - q.since_id = convertId(q.since_id, IdType.SharkeyId); + if (typeof q.min_id === 'string') q.min_id = convertId(q.min_id, IdType.SharkeyId); + if (typeof q.max_id === 'string') q.max_id = convertId(q.max_id, IdType.SharkeyId); + if (typeof q.since_id === 'string') q.since_id = convertId(q.since_id, IdType.SharkeyId); return q; } -function escapeHTML(str: string) { - if (!str) { - return ''; - } - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/'/g, '"') - .replace(/'/g, '''); -} - -function nl2br(str: string) { - if (!str) { - return ''; - } - str = str.replace(/\r\n/g, '
'); - str = str.replace(/(\n|\r)/g, '
'); - return str; -} - -export class apiTimelineMastodon { +export class ApiTimelineMastodon { private fastify: FastifyInstance; - constructor(fastify: FastifyInstance) { + constructor(fastify: FastifyInstance) { this.fastify = fastify; - } + } public async getTL() { this.fastify.get('/v1/timelines/public', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; const data = query.local === 'true' ? await client.getLocalTimeline(convertTimelinesArgsId(argsToBools(limitToInt(query)))) : await client.getPublicTimeline(convertTimelinesArgsId(argsToBools(limitToInt(query)))); - reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async getHomeTl() { this.fastify.get('/v1/timelines/home', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; const data = await client.getHomeTimeline(convertTimelinesArgsId(limitToInt(query))); reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async getTagTl() { this.fastify.get<{ Params: { hashtag: string } }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; const params: any = _request.params; const data = await client.getTagTimeline(params.hashtag, convertTimelinesArgsId(limitToInt(query))); reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async getListTL() { this.fastify.get<{ Params: { id: string } }>('/v1/timelines/list/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const query: any = _request.query; + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = _request.query; const params: any = _request.params; const data = await client.getListTimeline(convertId(params.id, IdType.SharkeyId), convertTimelinesArgsId(limitToInt(query))); reply.send(data.data.map((status: Entity.Status) => convertStatus(status))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async getConversations() { this.fastify.get('/v1/conversations', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { + const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { const query: any = _request.query; const data = await client.getConversationTimeline(convertTimelinesArgsId(limitToInt(query))); - reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } - public async getList(){ + public async getList() { this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { - try { + try { const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const params: any = _request.params; const data = await client.getList(convertId(params.id, IdType.SharkeyId)); - reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(convertList(data.data)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async getLists() { @@ -197,121 +163,120 @@ export class apiTimelineMastodon { return e.response.data; } }); - } + } - public async getListAccounts(){ + public async getListAccounts() { this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { + try { const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const params: any = _request.params; const query: any = _request.query; const data = await client.getAccountsInList( convertId(params.id, IdType.SharkeyId), - convertTimelinesArgsId(query) + convertTimelinesArgsId(query), ); - reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(data.data.map((account: Entity.Account) => convertAccount(account))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async addListAccount() { this.fastify.post<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { + try { const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const params: any = _request.params; const query: any = _request.query; const data = await client.addAccountsToList( convertId(params.id, IdType.SharkeyId), - (query.accounts_id as string[]).map((id) => convertId(id, IdType.SharkeyId)) + (query.accounts_id as string[]).map((id) => convertId(id, IdType.SharkeyId)), ); - reply.send(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async rmListAccount() { this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { - try { + try { const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); const params: any = _request.params; const query: any = _request.query; const data = await client.deleteAccountsFromList( convertId(params.id, IdType.SharkeyId), - (query.accounts_id as string[]).map((id) => convertId(id, IdType.SharkeyId)) + (query.accounts_id as string[]).map((id) => convertId(id, IdType.SharkeyId)), ); - reply.send(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async createList() { this.fastify.post('/v1/lists', async (_request, reply) => { - try { + try { const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const body: any = _request.body; const data = await client.createList(body.title); - reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(convertList(data.data)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async updateList() { this.fastify.put<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { - try { + try { const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const body: any = _request.body; const params: any = _request.params; const data = await client.updateList(convertId(params.id, IdType.SharkeyId), body.title); - reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(convertList(data.data)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } public async deleteList() { this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { - try { + try { const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + const accessTokens = _request.headers.authorization; + const client = getClient(BASE_URL, accessTokens); const params: any = _request.params; const data = await client.deleteList(convertId(params.id, IdType.SharkeyId)); - reply.send(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); - } - }); + reply.send(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + reply.code(401).send(e.response.data); + } + }); } - -} \ No newline at end of file +} From c6c687398473e941aa6281812d70ff03773e8130 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 17:38:31 +0200 Subject: [PATCH 29/31] chore: change const to let --- packages/backend/src/server/api/mastodon/endpoints/account.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 23c96281f0..63817c3782 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -34,7 +34,7 @@ export class ApiAccountMastodon { public async verifyCredentials() { try { const data = await this.client.verifyAccountCredentials(); - const acct = data.data; + let acct = data.data; acct.id = convertId(acct.id, IdType.MastodonId); acct.display_name = acct.display_name || acct.username; acct.url = `${this.BASE_URL}/@${acct.url}`; From b107ff5a1faaf46917f0cea97e74c6052cde7890 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 17:42:31 +0200 Subject: [PATCH 30/31] chore: lint says const happy --- packages/backend/src/server/api/mastodon/endpoints/account.ts | 2 +- .../backend/src/server/api/mastodon/endpoints/notifications.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 63817c3782..23c96281f0 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -34,7 +34,7 @@ export class ApiAccountMastodon { public async verifyCredentials() { try { const data = await this.client.verifyAccountCredentials(); - let acct = data.data; + const acct = data.data; acct.id = convertId(acct.id, IdType.MastodonId); acct.display_name = acct.display_name || acct.username; acct.url = `${this.BASE_URL}/@${acct.url}`; diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 1dbb8d8b72..dc801dd053 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -22,7 +22,7 @@ export class ApiNotifyMastodon { const data = await this.client.getNotifications( convertTimelinesArgsId(toLimitToInt(this.request.query)) ); const notifs = data.data; const processed = notifs.map((n: Entity.Notification) => { - let convertedn = convertNotification(n); + const convertedn = convertNotification(n); if (convertedn.type !== 'follow' && convertedn.type !== 'follow_request') { if (convertedn.type === 'reaction') convertedn.type = 'favourite'; return convertedn; From 2432a9a18417bc8649ae98449830d3b076feb5a8 Mon Sep 17 00:00:00 2001 From: Mar0xy Date: Sun, 24 Sep 2023 17:45:49 +0200 Subject: [PATCH 31/31] chore: typecheck fix --- packages/backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 1c2ffcfb6d..b82bd73a0c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -15,7 +15,7 @@ "watch:swc": "swc src -d built -D -w", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "node watch.mjs", - "typecheck": "tsc --noEmit", + "typecheck": "pnpm --filter megalodon build && tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit",