From 30949a9dc5a99d5f28d0e275ed20d912376cf9d0 Mon Sep 17 00:00:00 2001 From: AkiraFukushima Date: Sun, 5 Jan 2025 15:14:18 +0900 Subject: [PATCH] Add Pixelfed client --- .../typescript/src/pixelfed/authorization.ts | 44 + example/typescript/src/pixelfed/instance.ts | 9 + example/typescript/src/pixelfed/timeline.ts | 11 + megalodon/src/detector.ts | 8 +- megalodon/src/index.ts | 2 + megalodon/src/mastodon/api_client.ts | 2 +- megalodon/src/mastodon/web_socket.ts | 4 +- megalodon/src/megalodon.ts | 9 +- megalodon/src/pixelfed.ts | 3112 +++++++++++++++++ megalodon/src/pixelfed/api_client.ts | 607 ++++ megalodon/src/pixelfed/entities/account.ts | 27 + .../src/pixelfed/entities/announcement.ts | 39 + .../src/pixelfed/entities/application.ts | 5 + .../src/pixelfed/entities/async_attachment.ts | 13 + megalodon/src/pixelfed/entities/attachment.ts | 47 + megalodon/src/pixelfed/entities/context.ts | 6 + .../src/pixelfed/entities/conversation.ts | 9 + megalodon/src/pixelfed/entities/emoji.ts | 7 + megalodon/src/pixelfed/entities/field.ts | 5 + megalodon/src/pixelfed/entities/filter.ts | 10 + megalodon/src/pixelfed/entities/history.ts | 5 + megalodon/src/pixelfed/entities/instance.ts | 44 + megalodon/src/pixelfed/entities/marker.ts | 12 + megalodon/src/pixelfed/entities/mention.ts | 6 + .../src/pixelfed/entities/notification.ts | 12 + megalodon/src/pixelfed/entities/poll.ts | 14 + .../src/pixelfed/entities/preferences.ts | 9 + .../src/pixelfed/entities/relationship.ts | 16 + megalodon/src/pixelfed/entities/report.ts | 15 + megalodon/src/pixelfed/entities/results.ts | 9 + .../src/pixelfed/entities/scheduled_status.ts | 9 + megalodon/src/pixelfed/entities/source.ts | 9 + megalodon/src/pixelfed/entities/stats.ts | 5 + megalodon/src/pixelfed/entities/status.ts | 43 + .../src/pixelfed/entities/status_params.ts | 12 + megalodon/src/pixelfed/entities/tag.ts | 8 + megalodon/src/pixelfed/entities/token.ts | 6 + megalodon/src/pixelfed/entity.ts | 62 + megalodon/src/pixelfed/notification.ts | 11 + megalodon/src/pixelfed/oauth.ts | 42 + megalodon/src/pixelfed/web_socket.ts | 341 ++ megalodon/test/integration/detector.spec.ts | 8 + 42 files changed, 4668 insertions(+), 6 deletions(-) create mode 100644 example/typescript/src/pixelfed/authorization.ts create mode 100644 example/typescript/src/pixelfed/instance.ts create mode 100644 example/typescript/src/pixelfed/timeline.ts create mode 100644 megalodon/src/pixelfed.ts create mode 100644 megalodon/src/pixelfed/api_client.ts create mode 100644 megalodon/src/pixelfed/entities/account.ts create mode 100644 megalodon/src/pixelfed/entities/announcement.ts create mode 100644 megalodon/src/pixelfed/entities/application.ts create mode 100644 megalodon/src/pixelfed/entities/async_attachment.ts create mode 100644 megalodon/src/pixelfed/entities/attachment.ts create mode 100644 megalodon/src/pixelfed/entities/context.ts create mode 100644 megalodon/src/pixelfed/entities/conversation.ts create mode 100644 megalodon/src/pixelfed/entities/emoji.ts create mode 100644 megalodon/src/pixelfed/entities/field.ts create mode 100644 megalodon/src/pixelfed/entities/filter.ts create mode 100644 megalodon/src/pixelfed/entities/history.ts create mode 100644 megalodon/src/pixelfed/entities/instance.ts create mode 100644 megalodon/src/pixelfed/entities/marker.ts create mode 100644 megalodon/src/pixelfed/entities/mention.ts create mode 100644 megalodon/src/pixelfed/entities/notification.ts create mode 100644 megalodon/src/pixelfed/entities/poll.ts create mode 100644 megalodon/src/pixelfed/entities/preferences.ts create mode 100644 megalodon/src/pixelfed/entities/relationship.ts create mode 100644 megalodon/src/pixelfed/entities/report.ts create mode 100644 megalodon/src/pixelfed/entities/results.ts create mode 100644 megalodon/src/pixelfed/entities/scheduled_status.ts create mode 100644 megalodon/src/pixelfed/entities/source.ts create mode 100644 megalodon/src/pixelfed/entities/stats.ts create mode 100644 megalodon/src/pixelfed/entities/status.ts create mode 100644 megalodon/src/pixelfed/entities/status_params.ts create mode 100644 megalodon/src/pixelfed/entities/tag.ts create mode 100644 megalodon/src/pixelfed/entities/token.ts create mode 100644 megalodon/src/pixelfed/entity.ts create mode 100644 megalodon/src/pixelfed/notification.ts create mode 100644 megalodon/src/pixelfed/oauth.ts create mode 100644 megalodon/src/pixelfed/web_socket.ts diff --git a/example/typescript/src/pixelfed/authorization.ts b/example/typescript/src/pixelfed/authorization.ts new file mode 100644 index 000000000..c1e0e2dc1 --- /dev/null +++ b/example/typescript/src/pixelfed/authorization.ts @@ -0,0 +1,44 @@ +import * as readline from 'readline' +import generator, { OAuth } from 'megalodon' + +const rl: readline.ReadLine = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +const SCOPES: Array = ['read', 'write', 'follow'] +const BASE_URL: string = process.env.PIXELFED_URL! + +let clientId: string +let clientSecret: string + +const client = generator('pixelfed', BASE_URL) + +client + .registerApp('Test App', { + scopes: SCOPES + }) + .then(appData => { + clientId = appData.client_id + clientSecret = appData.client_secret + console.log('Authorization URL is generated.') + console.log(appData.url) + console.log() + return new Promise(resolve => { + rl.question('Enter the authorization code from website: ', code => { + resolve(code) + rl.close() + }) + }) + }) + .then((code: string) => { + return client.fetchAccessToken(clientId, clientSecret, code) + }) + .then((tokenData: OAuth.TokenData) => { + console.log('\naccess_token:') + console.log(tokenData.access_token) + console.log('\nrefresh_token:') + console.log(tokenData.refresh_token) + console.log() + }) + .catch((err: Error) => console.error(err)) diff --git a/example/typescript/src/pixelfed/instance.ts b/example/typescript/src/pixelfed/instance.ts new file mode 100644 index 000000000..272f44a3d --- /dev/null +++ b/example/typescript/src/pixelfed/instance.ts @@ -0,0 +1,9 @@ +import generator, { Entity, Response } from 'megalodon' + +const BASE_URL: string = process.env.PIXELFED_URL! + +const client = generator('pixelfed', BASE_URL) + +client.getInstance().then((res: Response) => { + console.log(res.data) +}) diff --git a/example/typescript/src/pixelfed/timeline.ts b/example/typescript/src/pixelfed/timeline.ts new file mode 100644 index 000000000..88339f577 --- /dev/null +++ b/example/typescript/src/pixelfed/timeline.ts @@ -0,0 +1,11 @@ +import generator, { MegalodonInterface, Entity, Response } from 'megalodon' + +const BASE_URL: string = process.env.PIXELFED_URL! + +const access_token: string = process.env.PIXELFED_ACCESS_TOKEN! + +const client: MegalodonInterface = generator('pixelfed', BASE_URL, access_token) + +client.getPublicTimeline().then((resp: Response>) => { + console.log(resp.data) +}) diff --git a/megalodon/src/detector.ts b/megalodon/src/detector.ts index 7cc792626..cca66a9e2 100644 --- a/megalodon/src/detector.ts +++ b/megalodon/src/detector.ts @@ -47,7 +47,7 @@ type Metadata = { * @param proxyConfig Proxy setting, or set false if don't use proxy. * @return SNS name. */ -export const detector = async (url: string): Promise<'mastodon' | 'pleroma' | 'friendica' | 'firefish' | 'gotosocial'> => { +export const detector = async (url: string): Promise<'mastodon' | 'pleroma' | 'friendica' | 'firefish' | 'gotosocial' | 'pixelfed'> => { const options: AxiosRequestConfig = { timeout: 20000 } @@ -73,6 +73,8 @@ export const detector = async (url: string): Promise<'mastodon' | 'pleroma' | 'f return 'firefish' case 'mastodon': return 'mastodon' + case 'pixelfed': + return 'pixelfed' case 'pleroma': return 'pleroma' case 'sharkey': @@ -101,6 +103,8 @@ export const detector = async (url: string): Promise<'mastodon' | 'pleroma' | 'f return 'firefish' case 'mastodon': return 'mastodon' + case 'pixelfed': + return 'pixelfed' case 'pleroma': return 'pleroma' case 'sharkey': @@ -129,6 +133,8 @@ export const detector = async (url: string): Promise<'mastodon' | 'pleroma' | 'f return 'firefish' case 'mastodon': return 'mastodon' + case 'pixelfed': + return 'pixelfed' case 'pleroma': return 'pleroma' case 'sharkey': diff --git a/megalodon/src/index.ts b/megalodon/src/index.ts index b3de72ce1..a5a32155a 100644 --- a/megalodon/src/index.ts +++ b/megalodon/src/index.ts @@ -5,6 +5,7 @@ import generator, { MegalodonInterface, WebSocketInterface } from './megalodon' import { detector } from './detector' import Mastodon from './mastodon' import Pleroma from './pleroma' +import Pixelfed from './pixelfed' import Firefish from './firefish' import Gotosocial from './gotosocial' import Entity from './entity' @@ -23,6 +24,7 @@ export { FilterContext, Mastodon, Pleroma, + Pixelfed, Firefish, Gotosocial, Entity diff --git a/megalodon/src/mastodon/api_client.ts b/megalodon/src/mastodon/api_client.ts index fa7291aee..290f42f52 100644 --- a/megalodon/src/mastodon/api_client.ts +++ b/megalodon/src/mastodon/api_client.ts @@ -368,7 +368,7 @@ namespace MastodonAPI { * Get connection and receive websocket connection for Pleroma API. * * @param url Streaming url. - * @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 stream Stream name * @returns WebSocket, which inherits from EventEmitter */ public socket(url: string, stream: string, params?: string): Streaming { diff --git a/megalodon/src/mastodon/web_socket.ts b/megalodon/src/mastodon/web_socket.ts index 4940a6dbf..a5bd4b1bd 100644 --- a/megalodon/src/mastodon/web_socket.ts +++ b/megalodon/src/mastodon/web_socket.ts @@ -27,8 +27,8 @@ export default class Streaming extends EventEmitter implements WebSocketInterfac 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 url Full url of websocket: e.g. https://mastodon.social/api/v1/streaming + * @param stream Stream name * @param accessToken The access token. * @param userAgent The specified User Agent. */ diff --git a/megalodon/src/megalodon.ts b/megalodon/src/megalodon.ts index 97adf87e3..d84a11349 100644 --- a/megalodon/src/megalodon.ts +++ b/megalodon/src/megalodon.ts @@ -6,6 +6,7 @@ import Entity from './entity' import Friendica from './friendica' import Firefish from './firefish' import Gotosocial from './gotosocial' +import Pixelfed from './pixelfed' export interface WebSocketInterface { start(): void @@ -1436,14 +1437,14 @@ export class NodeinfoError extends Error { /** * Get client for each SNS according to megalodon interface. * - * @param sns Name of your SNS, `mastodon`, `pleroma`, `firefish`, or `gotosocial`. + * @param sns Name of your SNS, `mastodon`, `pleroma`, `firefish`, `gotosocial`, or `pixelfed`. * @param baseUrl hostname or base URL. * @param accessToken access token from OAuth2 authorization * @param userAgent UserAgent is specified in header on request. * @return Client instance for each SNS you specified. */ const generator = ( - sns: 'mastodon' | 'pleroma' | 'friendica' | 'firefish' | 'gotosocial', + sns: 'mastodon' | 'pleroma' | 'friendica' | 'firefish' | 'gotosocial' | 'pixelfed', baseUrl: string, accessToken: string | null = null, userAgent: string | null = null @@ -1469,6 +1470,10 @@ const generator = ( const gotosocial = new Gotosocial(baseUrl, accessToken, userAgent) return gotosocial } + case 'pixelfed': { + const pixelfed = new Pixelfed(baseUrl, accessToken, userAgent) + return pixelfed + } } } diff --git a/megalodon/src/pixelfed.ts b/megalodon/src/pixelfed.ts new file mode 100644 index 000000000..eff90d636 --- /dev/null +++ b/megalodon/src/pixelfed.ts @@ -0,0 +1,3112 @@ +import { OAuth2Client } from '@badgateway/oauth2-client' +import FormData from 'form-data' +import dayjs from 'dayjs' + +import { parseLinkHeader } from './parse_link_header' +import OAuth from './oauth' +import PixelfedAPI from './pixelfed/api_client' +import { MegalodonInterface, NotImplementedError, WebSocketInterface } from './megalodon' +import * as PixelfedOAuth from './pixelfed/oauth' +import Entity from './entity' +import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from './default' +import Response from './response' +import { UnknownNotificationTypeError } from './notification' + +export default class Pixelfed implements MegalodonInterface { + public client: PixelfedAPI.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. + */ + constructor(baseUrl: string, accessToken: string | null = null, userAgent: string | null = DEFAULT_UA) { + let token: string = '' + if (accessToken) { + token = accessToken + } + let agent: string = DEFAULT_UA + if (userAgent) { + agent = userAgent + } + this.client = new PixelfedAPI.Client(baseUrl, token, agent) + 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) => PixelfedOAuth.toAppData(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 | null }> + ): Promise { + const scope = options.scope || DEFAULT_SCOPE + const redirect_uri = options.redirect_uri || NO_REDIRECT + return new Promise(resolve => { + const oauthClient = new OAuth2Client({ + server: this.baseUrl, + clientId: clientId, + clientSecret: clientSecret, + tokenEndpoint: '/oauth/token', + authorizationEndpoint: '/oauth/authorize' + }) + const url = oauthClient.authorizationCode.getAuthorizeUri({ + redirectUri: redirect_uri, + scope: scope + }) + 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) => PixelfedOAuth.toTokenData(res.data)) + } + + /** + * POST /oauth/token + * + * 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) => PixelfedOAuth.toTokenData(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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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 => PixelfedAPI.Converter.status(s)) + }) + }) + } + + public getAccountFavourites( + _id: string, + _options?: { + limit?: number + max_id?: string + since_id?: string + } + ): Promise>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + 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: PixelfedAPI.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: PixelfedAPI.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 => PixelfedAPI.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'], undefined, undefined, true) + converted = Object.assign({}, converted, { + data: [...converted.data, ...nextRes.data.map(a => PixelfedAPI.Converter.account(a))] + }) + if (nextRes.headers.link === undefined) { + break + } + 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 new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * GET /api/v1/accounts/:id/identity_proofs + * + * @param id The account ID. + * @return Array of IdentityProof + */ + public async getIdentityProof(_id: string): Promise>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * 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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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 NotImplementedError('Pixelfed does not support this method') + 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 NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * POST /api/v1/accounts/:id/note + * + * @param id + * @param note + * @return Relationship + */ + public async setAccountNote(_id: string, _note?: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + 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: PixelfedAPI.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 => PixelfedAPI.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 => PixelfedAPI.Converter.account(a)) + }) + }) + } + + /** + * GET /api/v1/accounts/lookup + * + * @param acct The username or Webfinger address to lookup. + * @return Account. + */ + public async lookupAccount(_acct: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // 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 => PixelfedAPI.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 => PixelfedAPI.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 => PixelfedAPI.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 => PixelfedAPI.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 => PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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 => PixelfedAPI.Converter.account(a)) + }) + }) + } else { + return this.client.get>('/api/v1/follow_requests').then(res => { + return Object.assign(res, { + data: res.data.map(a => PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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 => PixelfedAPI.Converter.account(a)) + }) + }) + } + + // ====================================== + // accounts/featured_tags + // ====================================== + /** + * GET /api/v1/featured_tags + * + * @return Array of featured tag. + */ + public async getFeaturedTags(): Promise>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * POST /api/v1/featured_tags + * + * @param name Target hashtag name. + * @return FeaturedTag. + */ + public async createFeaturedTag(_name: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * DELETE /api/v1/featured_tags/:id + * + * @param id Target featured tag id. + * @return Empty + */ + public deleteFeaturedTag(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * GET /api/v1/featured_tags/suggestions + * + * @return Array of tag. + */ + public async getSuggestedTags(): Promise>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + 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: PixelfedAPI.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 => PixelfedAPI.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>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // 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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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?: Entity.StatusVisibility + scheduled_at?: string + language?: string + quote_id?: string + } + ): Promise> { + let params = { + status: status + } + let scheduled = false + 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 && dayjs(options.scheduled_at).diff(dayjs(), 'seconds') > 300) { + scheduled = true + 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 (scheduled) { + return this.client.post('/api/v1/statuses', params).then(res => { + return Object.assign(res, { + data: PixelfedAPI.Converter.scheduled_status(res.data) + }) + }) + } + return this.client.post('/api/v1/statuses', params).then(res => { + return Object.assign(res, { + data: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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 new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * 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 => PixelfedAPI.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 => PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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 new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * POST /api/v1/statuses/:id/unmute + * + * @param id The target status id. + * @return Status + */ + public async unmuteStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * POST /api/v1/statuses/:id/pin + * @param id The target status id. + * @return Status + */ + public async pinStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * POST /api/v1/statuses/:id/unpin + * + * @param id The target status id. + * @return Status + */ + public async unpinStatus(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // 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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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 => PixelfedAPI.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: PixelfedAPI.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: PixelfedAPI.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 => PixelfedAPI.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 => PixelfedAPI.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 => PixelfedAPI.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 => PixelfedAPI.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>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // 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 => PixelfedAPI.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: PixelfedAPI.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: [] + }) + }) + } + + /** + * GET /api/v1/lists/:id + * + * @param id Target list ID. + * @return List. + */ + public async getList(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * POST /api/v1/lists + * + * @param title List name. + * @return List. + */ + public async createList(_title: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * 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 new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * DELETE /api/v1/lists/:id + * + * @param id Target list ID. + */ + public deleteList(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * 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>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * 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 new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * 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 new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // 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: PixelfedAPI.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: PixelfedAPI.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 => PixelfedAPI.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 = PixelfedAPI.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> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * POST /api/v1/notifications/clear + */ + public dismissNotifications(): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * POST /api/v1/notifications/:id/dismiss + * + * @param id Target notification ID. + */ + public dismissNotification(_id: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public readNotifications(_options: { id?: string; max_id?: string }): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + 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> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * GET /api/v1/push/subscription + * + * @return PushSubscription. + */ + public async getPushSubscription(): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * 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> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + /** + * DELETE /api/v1/push/subscription + */ + public deletePushSubscription(): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // 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: PixelfedAPI.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: PixelfedAPI.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 new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // 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 => PixelfedAPI.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>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // 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 => PixelfedAPI.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 => PixelfedAPI.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 NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public async deleteEmojiReaction(_id: string, _emoji: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public async getEmojiReactions(_id: string): Promise>> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public async getEmojiReaction(_id: string, _emoji: string): Promise> { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + // ====================================== + // WebSocket + // ====================================== + public async streamingURL(): Promise { + const instance = await this.getInstance() + if (instance.data.urls) { + return instance.data.urls.streaming_api + } + return this.baseUrl + } + + public async userStreaming(): Promise { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public async publicStreaming(): Promise { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public async localStreaming(): Promise { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public async tagStreaming(_tag: string): Promise { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public async listStreaming(_list_id: string): Promise { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } + + public async directStreaming(): Promise { + return new Promise((_, reject) => { + const err = new NotImplementedError('Pixelfed does not support this method') + reject(err) + }) + } +} diff --git a/megalodon/src/pixelfed/api_client.ts b/megalodon/src/pixelfed/api_client.ts new file mode 100644 index 000000000..5236fddcd --- /dev/null +++ b/megalodon/src/pixelfed/api_client.ts @@ -0,0 +1,607 @@ +import axios, { AxiosResponse, AxiosRequestConfig } from 'axios' +import objectAssignDeep from 'object-assign-deep' + +import Response from '../response' +import { RequestCanceledError } from '../cancel' +import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from '../default' +import PixelfedEntity from './entity' +import MegalodonEntity from '../entity' +import NotificationType, { UnknownNotificationTypeError } from '../notification' +import PixelfedNotificationType from './notification' + +namespace PixelfedAPI { + /** + * 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 + } + + /** + * Pixelfed API client. + * + * Using axios for request, you will handle promises. + */ + export class Client implements Interface { + static DEFAULT_SCOPE = DEFAULT_SCOPE + static DEFAULT_URL = 'https://pixelfed.social' + static NO_REDIRECT = NO_REDIRECT + + private accessToken: string | null + private baseUrl: string + private abortController: AbortController + + /** + * @param baseUrl hostname or base URL + * @param accessToken access token from OAuth2 authorization + * @param userAgent UserAgent is specified in header on request. + */ + constructor(baseUrl: string, accessToken: string | null = null, _userAgent: string = DEFAULT_UA) { + this.accessToken = accessToken + this.baseUrl = baseUrl + this.abortController = new AbortController() + axios.defaults.signal = this.abortController.signal + } + + /** + * GET request to pixelfed 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}` + } + }) + } + 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 pixelfed 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}` + } + }) + } + 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 pixelfed 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}` + } + }) + } + 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 pixelfed 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}` + } + }) + } + 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 pixelfed 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}` + } + }) + } + 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 pixelfed 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}` + } + }) + } + 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 pixelfed 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}` + } + }) + } + 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 pixelfed 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}` + } + }) + } + 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() + } + } + + export namespace Entity { + export type Account = PixelfedEntity.Account + export type Announcement = PixelfedEntity.Announcement + export type Application = PixelfedEntity.Application + export type AsyncAttachment = MegalodonEntity.AsyncAttachment + export type Attachment = PixelfedEntity.Attachment + export type Context = PixelfedEntity.Context + export type Conversation = PixelfedEntity.Conversation + export type Emoji = PixelfedEntity.Emoji + export type Field = PixelfedEntity.Field + export type Filter = PixelfedEntity.Filter + export type History = PixelfedEntity.History + export type Instance = PixelfedEntity.Instance + export type Marker = PixelfedEntity.Marker + export type Mention = PixelfedEntity.Mention + export type Notification = PixelfedEntity.Notification + export type Poll = PixelfedEntity.Poll + export type PollOption = PixelfedEntity.PollOption + export type Preferences = PixelfedEntity.Preferences + export type Relationship = PixelfedEntity.Relationship + export type Report = PixelfedEntity.Report + export type Results = PixelfedEntity.Results + export type ScheduledStatus = PixelfedEntity.ScheduledStatus + export type Source = PixelfedEntity.Source + export type Stats = PixelfedEntity.Stats + export type Status = PixelfedEntity.Status + export type StatusParams = PixelfedEntity.StatusParams + export type Tag = PixelfedEntity.Tag + export type Token = PixelfedEntity.Token + } + + export namespace Converter { + export const encodeNotificationType = ( + t: MegalodonEntity.NotificationType + ): PixelfedEntity.NotificationType | UnknownNotificationTypeError => { + switch (t) { + case NotificationType.Follow: + return PixelfedNotificationType.Follow + case NotificationType.Favourite: + return PixelfedNotificationType.Favourite + case NotificationType.Reblog: + return PixelfedNotificationType.Reblog + case NotificationType.Mention: + return PixelfedNotificationType.Mention + case NotificationType.FollowRequest: + return PixelfedNotificationType.FollowRequest + default: + return new UnknownNotificationTypeError() + } + } + + export const decodeNotificationType = ( + t: PixelfedEntity.NotificationType + ): MegalodonEntity.NotificationType | UnknownNotificationTypeError => { + switch (t) { + case PixelfedNotificationType.Follow: + return NotificationType.Follow + case PixelfedNotificationType.Favourite: + return NotificationType.Favourite + case PixelfedNotificationType.Mention: + return NotificationType.Mention + case PixelfedNotificationType.Reblog: + return NotificationType.Reblog + case PixelfedNotificationType.FollowRequest: + return NotificationType.FollowRequest + 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: null, + 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, + moved: null, + fields: a.fields, + bot: null, + source: a.source + }) + 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 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 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 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: null, + stats: stats(i.stats), + languages: i.languages, + registrations: i.registrations, + approval_required: i.approval_required, + configuration: { + statuses: { + max_characters: i.configuration.statuses.max_characters, + max_media_attachments: i.configuration.statuses.max_media_attachments, + characters_reserved_per_url: i.configuration.statuses.characters_reserved_per_url + }, + polls: { + max_options: i.configuration.polls.max_options, + max_characters_per_option: i.configuration.polls.max_characters_per_option, + min_expiration: i.configuration.polls.min_expiration, + max_expiration: i.configuration.polls.max_expiration + } + }, + contact_account: account(i.contact_account), + rules: i.rules + }) + 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 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: r.action_taken_at, + status_ids: r.status_ids, + rule_ids: r.rule_ids, + category: r.category, + comment: r.comment, + forwarded: r.forwarded, + 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 => 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) : null, + content: s.content, + plain_content: null, + created_at: s.created_at, + edited_at: s.edited_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: null, + poll: s.poll ? poll(s.poll) : null, + application: s.application ? application(s.application) : null, + language: s.language, + pinned: false, + emoji_reactions: [], + bookmarked: s.bookmarked ? s.bookmarked : false, + quote: false + }) + export const status_params = (s: Entity.StatusParams): MegalodonEntity.StatusParams => s + export const tag = (t: Entity.Tag): MegalodonEntity.Tag => t + export const token = (t: Entity.Token): MegalodonEntity.Token => t + } +} +export default PixelfedAPI diff --git a/megalodon/src/pixelfed/entities/account.ts b/megalodon/src/pixelfed/entities/account.ts new file mode 100644 index 000000000..a7ca2954a --- /dev/null +++ b/megalodon/src/pixelfed/entities/account.ts @@ -0,0 +1,27 @@ +import { Emoji } from './emoji' +import { Field } from './field' +import { Source } from './source' + +export type Account = { + id: string + username: string + acct: string + display_name: string + discoverable?: boolean + locked: boolean + followers_count: number + following_count: number + statuses_count: number + note: string + url: string + avatar: string + created_at: string + avatar_static: string + bot: boolean + emojis: Array + fields: Array + header: string + header_static: string + last_status_at: string | null + source?: Source +} diff --git a/megalodon/src/pixelfed/entities/announcement.ts b/megalodon/src/pixelfed/entities/announcement.ts new file mode 100644 index 000000000..d7e9ef89c --- /dev/null +++ b/megalodon/src/pixelfed/entities/announcement.ts @@ -0,0 +1,39 @@ +import { StatusTag } from './status' +import { Emoji } from './emoji' + +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/megalodon/src/pixelfed/entities/application.ts b/megalodon/src/pixelfed/entities/application.ts new file mode 100644 index 000000000..884caff81 --- /dev/null +++ b/megalodon/src/pixelfed/entities/application.ts @@ -0,0 +1,5 @@ +export type Application = { + name: string + website?: string | null + vapid_key?: string | null +} diff --git a/megalodon/src/pixelfed/entities/async_attachment.ts b/megalodon/src/pixelfed/entities/async_attachment.ts new file mode 100644 index 000000000..de3b1f8f6 --- /dev/null +++ b/megalodon/src/pixelfed/entities/async_attachment.ts @@ -0,0 +1,13 @@ +import { Meta } from './attachment' + +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/megalodon/src/pixelfed/entities/attachment.ts b/megalodon/src/pixelfed/entities/attachment.ts new file mode 100644 index 000000000..38d367642 --- /dev/null +++ b/megalodon/src/pixelfed/entities/attachment.ts @@ -0,0 +1,47 @@ +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/megalodon/src/pixelfed/entities/context.ts b/megalodon/src/pixelfed/entities/context.ts new file mode 100644 index 000000000..14da5dd8d --- /dev/null +++ b/megalodon/src/pixelfed/entities/context.ts @@ -0,0 +1,6 @@ +import { Status } from './status' + +export type Context = { + ancestors: Array + descendants: Array +} diff --git a/megalodon/src/pixelfed/entities/conversation.ts b/megalodon/src/pixelfed/entities/conversation.ts new file mode 100644 index 000000000..83b900bec --- /dev/null +++ b/megalodon/src/pixelfed/entities/conversation.ts @@ -0,0 +1,9 @@ +import { Account } from './account' +import { Status } from './status' + +export type Conversation = { + id: string + accounts: Array + last_status: Status | null + unread: boolean +} diff --git a/megalodon/src/pixelfed/entities/emoji.ts b/megalodon/src/pixelfed/entities/emoji.ts new file mode 100644 index 000000000..3ab743eb6 --- /dev/null +++ b/megalodon/src/pixelfed/entities/emoji.ts @@ -0,0 +1,7 @@ +export type Emoji = { + shortcode: string + static_url: string + url: string + visible_in_picker: boolean + category?: string +} diff --git a/megalodon/src/pixelfed/entities/field.ts b/megalodon/src/pixelfed/entities/field.ts new file mode 100644 index 000000000..88b9eea1e --- /dev/null +++ b/megalodon/src/pixelfed/entities/field.ts @@ -0,0 +1,5 @@ +export type Field = { + name: string + value: string + verified_at: string | null +} diff --git a/megalodon/src/pixelfed/entities/filter.ts b/megalodon/src/pixelfed/entities/filter.ts new file mode 100644 index 000000000..cf1fa800e --- /dev/null +++ b/megalodon/src/pixelfed/entities/filter.ts @@ -0,0 +1,10 @@ +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/megalodon/src/pixelfed/entities/history.ts b/megalodon/src/pixelfed/entities/history.ts new file mode 100644 index 000000000..6d38a71f9 --- /dev/null +++ b/megalodon/src/pixelfed/entities/history.ts @@ -0,0 +1,5 @@ +export type History = { + day: string + uses: number + accounts: number +} diff --git a/megalodon/src/pixelfed/entities/instance.ts b/megalodon/src/pixelfed/entities/instance.ts new file mode 100644 index 000000000..1602c0a6c --- /dev/null +++ b/megalodon/src/pixelfed/entities/instance.ts @@ -0,0 +1,44 @@ +import { Stats } from './stats' +import { Account } from './account' + +export type Instance = { + uri: string + title: string + short_description: string + description: string + email: string + version: string + thumbnail: string | null + stats: Stats + languages: Array + registrations: boolean + approval_required: 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 + } + } + contact_account: Account + rules: Array +} + +export type InstanceRule = { + id: string + text: string +} diff --git a/megalodon/src/pixelfed/entities/marker.ts b/megalodon/src/pixelfed/entities/marker.ts new file mode 100644 index 000000000..e347a09d3 --- /dev/null +++ b/megalodon/src/pixelfed/entities/marker.ts @@ -0,0 +1,12 @@ +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/megalodon/src/pixelfed/entities/mention.ts b/megalodon/src/pixelfed/entities/mention.ts new file mode 100644 index 000000000..098633673 --- /dev/null +++ b/megalodon/src/pixelfed/entities/mention.ts @@ -0,0 +1,6 @@ +export type Mention = { + id: string + username: string + url: string + acct: string +} diff --git a/megalodon/src/pixelfed/entities/notification.ts b/megalodon/src/pixelfed/entities/notification.ts new file mode 100644 index 000000000..b33b53c8b --- /dev/null +++ b/megalodon/src/pixelfed/entities/notification.ts @@ -0,0 +1,12 @@ +import { Account } from './account' +import { Status } from './status' + +export type Notification = { + account: Account + created_at: string + id: string + status?: Status + type: NotificationType +} + +export type NotificationType = string diff --git a/megalodon/src/pixelfed/entities/poll.ts b/megalodon/src/pixelfed/entities/poll.ts new file mode 100644 index 000000000..d8861108e --- /dev/null +++ b/megalodon/src/pixelfed/entities/poll.ts @@ -0,0 +1,14 @@ +export type Poll = { + id: string + expires_at: string | null + expired: boolean + multiple: boolean + votes_count: number + options: Array + voted: boolean +} + +export type PollOption = { + title: string + votes_count: number | null +} diff --git a/megalodon/src/pixelfed/entities/preferences.ts b/megalodon/src/pixelfed/entities/preferences.ts new file mode 100644 index 000000000..3777c9b21 --- /dev/null +++ b/megalodon/src/pixelfed/entities/preferences.ts @@ -0,0 +1,9 @@ +import { StatusVisibility } from './status' + +export type Preferences = { + 'posting:default:visibility': StatusVisibility + 'posting:default:sensitive': boolean + 'posting:default:language': string | null + 'reading:expand:media': 'default' | 'show_all' | 'hide_all' + 'reading:expand:spoilers': boolean +} diff --git a/megalodon/src/pixelfed/entities/relationship.ts b/megalodon/src/pixelfed/entities/relationship.ts new file mode 100644 index 000000000..6b93e801b --- /dev/null +++ b/megalodon/src/pixelfed/entities/relationship.ts @@ -0,0 +1,16 @@ +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/megalodon/src/pixelfed/entities/report.ts b/megalodon/src/pixelfed/entities/report.ts new file mode 100644 index 000000000..fd62dbe80 --- /dev/null +++ b/megalodon/src/pixelfed/entities/report.ts @@ -0,0 +1,15 @@ +import { Account } from './account' + +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/megalodon/src/pixelfed/entities/results.ts b/megalodon/src/pixelfed/entities/results.ts new file mode 100644 index 000000000..17ee9190b --- /dev/null +++ b/megalodon/src/pixelfed/entities/results.ts @@ -0,0 +1,9 @@ +import { Account } from './account' +import { Status } from './status' +import { Tag } from './tag' + +export type Results = { + accounts: Array + statuses: Array + hashtags: Array +} diff --git a/megalodon/src/pixelfed/entities/scheduled_status.ts b/megalodon/src/pixelfed/entities/scheduled_status.ts new file mode 100644 index 000000000..68932fafb --- /dev/null +++ b/megalodon/src/pixelfed/entities/scheduled_status.ts @@ -0,0 +1,9 @@ +import { Attachment } from './attachment' +import { StatusParams } from './status_params' + +export type ScheduledStatus = { + id: string + scheduled_at: string + params: StatusParams + media_attachments: Array +} diff --git a/megalodon/src/pixelfed/entities/source.ts b/megalodon/src/pixelfed/entities/source.ts new file mode 100644 index 000000000..a82203909 --- /dev/null +++ b/megalodon/src/pixelfed/entities/source.ts @@ -0,0 +1,9 @@ +import { Field } from './field' + +export type Source = { + privacy: string | null + sensitive: boolean | null + language: string | null + note: string + fields: Array +} diff --git a/megalodon/src/pixelfed/entities/stats.ts b/megalodon/src/pixelfed/entities/stats.ts new file mode 100644 index 000000000..b8a8db853 --- /dev/null +++ b/megalodon/src/pixelfed/entities/stats.ts @@ -0,0 +1,5 @@ +export type Stats = { + user_count: number + status_count: number + domain_count: number +} diff --git a/megalodon/src/pixelfed/entities/status.ts b/megalodon/src/pixelfed/entities/status.ts new file mode 100644 index 000000000..29bae8fcb --- /dev/null +++ b/megalodon/src/pixelfed/entities/status.ts @@ -0,0 +1,43 @@ +import { Account } from './account' +import { Application } from './application' +import { Attachment } from './attachment' +import { Mention } from './mention' +import { Emoji } from './emoji' +import { Poll } from './poll' + +export type Status = { + id: string + uri: string + url: string + account: Account + in_reply_to_id: string | null + in_reply_to_account_id: string | null + reblog: Status | null + content: string + created_at: string + edited_at: string | null + emojis: Emoji[] + replies_count: number + reblogs_count: number + favourites_count: number + reblogged: boolean | null + favourited: boolean | null + muted: boolean | null + sensitive: boolean + spoiler_text: string + visibility: StatusVisibility + media_attachments: Array + mentions: Array + tags: Array + poll: Poll | null + application: Application | null + language: string | null + bookmarked?: boolean +} + +export type StatusTag = { + name: string + url: string +} + +export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' diff --git a/megalodon/src/pixelfed/entities/status_params.ts b/megalodon/src/pixelfed/entities/status_params.ts new file mode 100644 index 000000000..0e32f6b11 --- /dev/null +++ b/megalodon/src/pixelfed/entities/status_params.ts @@ -0,0 +1,12 @@ +import { StatusVisibility } from './status' + +export type StatusParams = { + text: string + in_reply_to_id: string | null + media_ids: Array | null + sensitive: boolean | null + spoiler_text: string | null + visibility: StatusVisibility | null + scheduled_at: string | null + application_id: number +} diff --git a/megalodon/src/pixelfed/entities/tag.ts b/megalodon/src/pixelfed/entities/tag.ts new file mode 100644 index 000000000..2fae1efbe --- /dev/null +++ b/megalodon/src/pixelfed/entities/tag.ts @@ -0,0 +1,8 @@ +import { History } from './history' + +export type Tag = { + name: string + url: string + history: Array + following?: boolean +} diff --git a/megalodon/src/pixelfed/entities/token.ts b/megalodon/src/pixelfed/entities/token.ts new file mode 100644 index 000000000..9cf3c1508 --- /dev/null +++ b/megalodon/src/pixelfed/entities/token.ts @@ -0,0 +1,6 @@ +export type Token = { + access_token: string + token_type: string + scope: string + created_at: number +} diff --git a/megalodon/src/pixelfed/entity.ts b/megalodon/src/pixelfed/entity.ts new file mode 100644 index 000000000..691cf4a72 --- /dev/null +++ b/megalodon/src/pixelfed/entity.ts @@ -0,0 +1,62 @@ +import * as account from './entities/account' +import * as announcement from './entities/announcement' +import * as application from './entities/application' +import * as async_attachment from './entities/async_attachment' +import * as attachment from './entities/attachment' +import * as context from './entities/context' +import * as conversation from './entities/conversation' +import * as emoji from './entities/emoji' +import * as field from './entities/field' +import * as filter from './entities/filter' +import * as history from './entities/history' +import * as instance from './entities/instance' +import * as marker from './entities/marker' +import * as mentition from './entities/mention' +import * as notification from './entities/notification' +import * as poll from './entities/poll' +import * as preferences from './entities/preferences' +import * as relationship from './entities/relationship' +import * as report from './entities/report' +import * as results from './entities/results' +import * as scheduled_status from './entities/scheduled_status' +import * as source from './entities/source' +import * as stats from './entities/stats' +import * as status from './entities/status' +import * as status_params from './entities/status_params' +import * as tag from './entities/tag' +import * as token from './entities/token' + +export namespace Entity { + export type Account = account.Account + export type Announcement = announcement.Announcement + export type Application = application.Application + export type AsyncAttachment = async_attachment.AsyncAttachment + export type Attachment = attachment.Attachment + export type Context = context.Context + export type Conversation = conversation.Conversation + export type Emoji = emoji.Emoji + export type Field = field.Field + export type Filter = filter.Filter + export type FilterContext = filter.FilterContext + export type History = history.History + export type Instance = instance.Instance + export type Marker = marker.Marker + export type Mention = mentition.Mention + export type Notification = notification.Notification + export type NotificationType = notification.NotificationType + export type Poll = poll.Poll + export type PollOption = poll.PollOption + export type Preferences = preferences.Preferences + export type Relationship = relationship.Relationship + export type Report = report.Report + export type Results = results.Results + export type ScheduledStatus = scheduled_status.ScheduledStatus + export type Source = source.Source + export type Stats = stats.Stats + export type Status = status.Status + export type StatusParams = status_params.StatusParams + export type Tag = tag.Tag + export type Token = token.Token +} + +export default Entity diff --git a/megalodon/src/pixelfed/notification.ts b/megalodon/src/pixelfed/notification.ts new file mode 100644 index 000000000..77ed83a6b --- /dev/null +++ b/megalodon/src/pixelfed/notification.ts @@ -0,0 +1,11 @@ +import PixelfedEntity from './entity' + +namespace PixelfedNotificationType { + export const Mention: PixelfedEntity.NotificationType = 'mention' + export const Reblog: PixelfedEntity.NotificationType = 'reblog' + export const Favourite: PixelfedEntity.NotificationType = 'favourite' + export const Follow: PixelfedEntity.NotificationType = 'follow' + export const FollowRequest: PixelfedEntity.NotificationType = 'follow_request' +} + +export default PixelfedNotificationType diff --git a/megalodon/src/pixelfed/oauth.ts b/megalodon/src/pixelfed/oauth.ts new file mode 100644 index 000000000..e6f65bfa6 --- /dev/null +++ b/megalodon/src/pixelfed/oauth.ts @@ -0,0 +1,42 @@ +import OAuth from '@/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 + refresh_token: string + token_type: string + expires_in: number + created_at: number +} + +export function toAppData(appData: AppDataFromServer): OAuth.AppData { + return { + id: appData.id, + name: appData.name, + website: appData.website, + redirect_uri: appData.redirect_uri, + client_id: appData.client_id, + client_secret: appData.client_secret, + url: null, + session_token: null + } +} + +export function toTokenData(tokenData: TokenDataFromServer): OAuth.TokenData { + return { + access_token: tokenData.access_token, + token_type: tokenData.token_type, + scope: '', + created_at: tokenData.created_at, + expires_in: tokenData.expires_in, + refresh_token: tokenData.refresh_token + } +} diff --git a/megalodon/src/pixelfed/web_socket.ts b/megalodon/src/pixelfed/web_socket.ts new file mode 100644 index 000000000..5e9358e14 --- /dev/null +++ b/megalodon/src/pixelfed/web_socket.ts @@ -0,0 +1,341 @@ +import WS from 'isomorphic-ws' +import dayjs, { Dayjs } from 'dayjs' +import { EventEmitter } from 'events' +import { WebSocketInterface } from '../megalodon' +import MastodonAPI from './api_client' +import { UnknownNotificationTypeError } from '../notification' +import { isBrowser } from '../default' + +/** + * Streaming + * Connect WebSocket streaming endpoint. + */ +export default class Streaming extends EventEmitter implements WebSocketInterface { + public url: string + public stream: string + public params: string | null + public parser: Parser + public headers: { [key: string]: string } + 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://pixelfed.social/api/v1/streaming + * @param stream Stream name + * @param accessToken The access token. + * @param userAgent The specified User Agent. + */ + constructor(url: string, stream: string, params: string | undefined, accessToken: string, userAgent: string) { + 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._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._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._clearBinding() + 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. + if (isBrowser()) { + this._client.close() + } else { + this._client.terminate() + } + } + // Call connect methods + console.log('Reconnecting') + this._client = this._connect(this.url, this.stream, this.params, this._accessToken, this.headers) + 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. + * @return A WebSocket instance. + */ + private _connect(url: string, stream: string, params: string | null, accessToken: string, headers: { [key: string]: string }): 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('&')}` + if (isBrowser()) { + // This is browser. + // We can't pass options when browser: https://github.com/heineiuo/isomorphic-ws#limitations + const cli = new WS(requestURL) + return cli + } else { + const options: WS.ClientOptions = { + headers: headers + } + + const cli: WS = new WS(requestURL, options) + return cli + } + } + + /** + * Clear binding event for web socket client. + */ + private _clearBinding() { + if (this._client && !isBrowser()) { + 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.onclose = event => { + // Refer the code: https://tools.ietf.org/html/rfc6455#section-7.4 + if (event.code === 1000) { + this.emit('close', {}) + } else { + console.log(`Closed connection with ${event.code}`) + // If already called close method, it does not retry. + if (!this._connectionClosed) { + this._reconnect() + } + } + } + client.onopen = _event => { + this.emit('connect', {}) + if (!isBrowser()) { + // Call first ping event. + setTimeout(() => { + client.ping('') + }, 10000) + } + } + client.onmessage = event => { + this.parser.parse(event) + } + client.onerror = event => { + this.emit('error', event.error) + } + + if (!isBrowser()) { + 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) + }) + } + } + + /** + * 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 event of websocket. + */ + public parse(ev: WS.MessageEvent) { + const data = ev.data + const message = 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/megalodon/test/integration/detector.spec.ts b/megalodon/test/integration/detector.spec.ts index b2245155f..a149ee93c 100644 --- a/megalodon/test/integration/detector.spec.ts +++ b/megalodon/test/integration/detector.spec.ts @@ -65,6 +65,14 @@ describe('detector', () => { }) }) + describe('pixelfed.social', () => { + const url = 'https://pixelfed.social' + it('should be pixelfed', async () => { + const pixelfed = await detector(url) + expect(pixelfed).toEqual('pixelfed') + }) + }) + describe('unknown', () => { const url = 'https://google.com' it('should be null', async () => {