diff --git a/client/lysand/base.ts b/client/lysand/base.ts index 5660386..8bfdace 100644 --- a/client/lysand/base.ts +++ b/client/lysand/base.ts @@ -1,10 +1,17 @@ import { DEFAULT_UA } from "./constants"; type HttpVerb = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; -type ConvertibleObject = Record< - string, - string | number | boolean | File | undefined | null ->; +type ConvertibleObject = { + [key: string]: + | string + | number + | boolean + | File + | undefined + | null + | ConvertibleObject[] + | ConvertibleObject; +}; export interface Output { data: ReturnType; @@ -14,6 +21,30 @@ export interface Output { const objectToFormData = (obj: ConvertibleObject): FormData => { return Object.keys(obj).reduce((formData, key) => { if (obj[key] === undefined || obj[key] === null) return formData; + if (obj[key] instanceof File) { + formData.append(key, obj[key] as Blob); + return formData; + } + if (Array.isArray(obj[key])) { + (obj[key] as ConvertibleObject[]).forEach((item, index) => { + if (item instanceof File) { + formData.append(`${key}[${index}]`, item as Blob); + return; + } + formData.append(`${key}[${index}]`, String(item)); + }); + + return formData; + } + if (typeof obj[key] === "object") { + const nested = objectToFormData(obj[key] as ConvertibleObject); + + for (const [nestedKey, value] of nested.entries()) { + formData.append(`${key}[${nestedKey}]`, value); + } + + return formData; + } formData.append(key, String(obj[key])); return formData; }, new FormData()); diff --git a/client/lysand/lysand.ts b/client/lysand/lysand.ts index e770d48..e2bd108 100644 --- a/client/lysand/lysand.ts +++ b/client/lysand/lysand.ts @@ -3,6 +3,7 @@ import type { Account } from "../types/account"; import type { Activity } from "../types/activity"; import type { Announcement } from "../types/announcement"; import type { Application, ApplicationData } from "../types/application"; +import type { AsyncAttachment } from "../types/async_attachment"; import type { Attachment } from "../types/attachment"; import type { Context } from "../types/context"; import type { Conversation } from "../types/conversation"; @@ -13,9 +14,13 @@ import type { Marker } from "../types/marker"; import type { Notification } from "../types/notification"; import type { Poll } from "../types/poll"; import type { Preferences } from "../types/preferences"; +import type { PushSubscription } from "../types/push_subscription"; import type { Relationship } from "../types/relationship"; +import type { Category, Report } from "../types/report"; +import type { Results } from "../types/results"; import type { ScheduledStatus } from "../types/scheduled_status"; -import type { Status } from "../types/status"; +import type { Status, StatusVisibility } from "../types/status"; +import type { StatusSource } from "../types/status_source"; import type { Tag } from "../types/tag"; import type { Token } from "../types/token"; import { BaseClient, type Output } from "./base"; @@ -367,11 +372,16 @@ export class LysandClient extends BaseClient { public followAccount( id: string, + options?: Partial<{ + reblogs: boolean; + notify: boolean; + languages: string[]; + }>, extra?: RequestInit, ): Promise> { return this.post( `/api/v1/accounts/${id}/follow`, - undefined, + { ...options }, extra, ); } @@ -1104,10 +1114,26 @@ export class LysandClient extends BaseClient { ); } + /** + * GET /api/v1/statuses/:id + * + * @param id The target status id. + * @return Status + */ public getStatus(id: string, extra?: RequestInit): Promise> { return this.get(`/api/v1/statuses/${id}`, extra); } + /** + * GET /api/v1/statuses/:id/context + * + * Get parent and child statuses. + * @param id The target status 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. + * @return Context + */ public getStatusContext( id: string, options?: Partial<{ @@ -1130,4 +1156,856 @@ export class LysandClient extends BaseClient { extra, ); } + + /** + * GET /api/v1/statuses/:id/favourited_by + * + * @param id The target status 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 Array of accounts. + */ + public getStatusFavouritedBy( + id: string, + options?: Partial<{ + max_id: string; + since_id: string; + limit: number; + }>, + extra?: RequestInit, + ): Promise> { + const params = new URLSearchParams(); + + if (options) { + if (options.max_id) params.set("max_id", options.max_id); + if (options.since_id) params.set("since_id", options.since_id); + if (options.limit) params.set("limit", options.limit.toString()); + } + + return this.get( + `/api/v1/statuses/${id}/favourited_by?${params}`, + extra, + ); + } + + /** + * GET /api/v1/statuses/:id/reblogged_by + * + * @param id The target status 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 Array of accounts. + */ + public getStatusRebloggedBy( + id: string, + options?: Partial<{ + max_id: string; + since_id: string; + limit: number; + }>, + extra?: RequestInit, + ): Promise> { + const params = new URLSearchParams(); + + if (options) { + if (options.max_id) params.set("max_id", options.max_id); + if (options.since_id) params.set("since_id", options.since_id); + if (options.limit) params.set("limit", options.limit.toString()); + } + + return this.get( + `/api/v1/statuses/${id}/reblogged_by?${params}`, + extra, + ); + } + + /** + * 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 getStatusSource( + id: string, + extra?: RequestInit, + ): Promise> { + return this.get(`/api/v1/statuses/${id}/source`, extra); + } + + /** + * GET /api/v1/featured_tags/suggestions + * + * @return Array of tag. + */ + public getSuggestedTags(extra?: RequestInit): Promise> { + return this.get("/api/v1/featured_tags/suggestions", extra); + } + + public getSuggestions( + options?: Partial<{ limit: number }>, + ): Promise> { + const params = new URLSearchParams(); + + if (options) { + if (options.limit) params.set("limit", options.limit.toString()); + } + + return this.get(`/api/v1/suggestions?${params}`); + } + + public getTag(id: string, extra?: RequestInit): Promise> { + return this.get(`/api/v1/tags/${id}`, extra); + } + + /** + * 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 getTagTimeline( + id: string, + options?: Partial<{ + max_id: string; + min_id: string; + since_id: string; + limit: number; + local: boolean; + only_media: boolean; + }>, + extra?: RequestInit, + ): Promise> { + const params = new URLSearchParams(); + + if (options) { + if (options.max_id) params.set("max_id", options.max_id); + if (options.min_id) params.set("min_id", options.min_id); + if (options.since_id) params.set("since_id", options.since_id); + if (options.limit) params.set("limit", options.limit.toString()); + if (options.local) params.set("local", "true"); + if (options.only_media) params.set("only_media", "true"); + } + + return this.get( + `/api/v1/timelines/tag/${id}?${params}`, + extra, + ); + } + + // TODO: listStreaming + // TODO: localStreaming + + /** + * GET /api/v1/accounts/lookup + * + * @param acct The username or Webfinger address to lookup. + * @return Account. + */ + public lookupAccount( + acct: string, + extra?: RequestInit, + ): Promise> { + const params = new URLSearchParams(); + + params.set("q", acct); + + return this.get(`/api/v1/accounts/search?${params}`, extra); + } + + /** + * POST /api/v1/accounts/:id/mute + * + * @param id The account ID. + * @param options.notifications Mute notifications in addition to statuses. + * @param options.duration Duration of mute in seconds. Defaults to indefinite. + * @return Relationship + */ + public muteAccount( + id: string, + options?: Partial<{ notifications: boolean; duration: number }>, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/accounts/${id}/mute`, + { ...options }, + extra, + ); + } + + /** + * POST /api/v1/statuses/:id/mute + * + * @param id The target status id. + * @return Status + */ + public muteStatus( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/statuses/${id}/mute`, + undefined, + extra, + ); + } + + /** + * POST /api/v1/accounts/:id/pin + * + * @param id The account ID. + * @return Relationship + */ + public pinAccount( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/accounts/${id}/pin`, + undefined, + extra, + ); + } + + /** + * POST /api/v1/statuses/:id/pin + * @param id The target status id. + * @return Status + */ + public pinStatus(id: string, extra?: RequestInit): Promise> { + return this.post( + `/api/v1/statuses/${id}/pin`, + undefined, + extra, + ); + } + + /** + * 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.quote_id ID of the status being quoted to, if status is a quote. + * @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.content_type Content type of the status (MIME format). + * @param options.language ISO 639 language code for this status. + * @param options.scheduled_at ISO 8601 Datetime at which to schedule a status. + * @param options.local_only Post status to local timeline only? + * @return Status. When options.scheduled_at is present, ScheduledStatus is returned instead. + */ + public postStatus( + status: string, + options: { + in_reply_to_id?: string; + quote_id?: string; + media_ids?: string[]; + sensitive?: boolean; + spoiler_text?: string; + visibility?: StatusVisibility; + content_type?: StatusContentType; + scheduled_at?: string; + language?: string; + local_only?: boolean; + poll?: { + expires_in?: number; + hide_totals?: boolean; + multiple?: boolean; + options: string[]; + }; + }, + extra?: RequestInit, + ): Promise> { + return this.post( + "/api/v1/statuses", + { status, ...options }, + extra, + ); + } + + // TODO: publicStreaming + + public readConversation( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/conversations/${id}/read`, + undefined, + extra, + ); + } + + /** + * POST /api/v1/statuses/:id/reblog + * + * @param id The target status id. + * @param options.visibility Visibility of the reblogged status. + * @return Status. + */ + public reblogStatus( + id: string, + options?: Partial<{ visibility: StatusVisibility }>, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/statuses/${id}/reblog`, + { ...options }, + extra, + ); + } + + /** + * 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 refreshToken( + client_id: string, + client_secret: string, + refresh_token: string, + extra?: RequestInit, + ): Promise> { + return this.post( + "/oauth/token", + { + client_id, + client_secret, + grant_type: "refresh_token", + refresh_token, + }, + extra, + ); + } + + /** + * 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 registerAccount( + username: string, + email: string, + password: string, + agreement: boolean, + locale: string, + reason: string, + extra?: RequestInit, + ): Promise> { + return this.postForm( + "/api/v1/accounts", + { username, email, password, agreement, locale, reason }, + extra, + ); + } + + /** + * POST /api/v1/apps + * + * Create an application. + * @param client_name your application's name + * @param options Form Data + */ + public registerApp( + client_name: string, + options: { + redirect_uris: string; + scopes?: string; + website?: string; + }, + extra?: RequestInit, + ): Promise> { + return this.post( + "/api/v1/apps", + { client_name, ...options }, + extra, + ); + } + + public rejectFollowRequest( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/follow_requests/${id}/reject`, + undefined, + extra, + ); + } + + public removeReactionFromAnnouncement( + id: string, + name: string, + extra?: RequestInit, + ): Promise> { + return this.delete( + `/api/v1/announcements/${id}/reactions/${name}`, + undefined, + extra, + ); + } + + /** + * 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 report( + account_id: string, + options: { + status_ids?: string[]; + rule_ids?: string[]; + comment: string; + forward?: boolean; + category?: Category; + }, + extra?: RequestInit, + ): Promise> { + return this.post( + "/api/v1/reports", + { account_id, ...options }, + extra, + ); + } + + public revokeToken( + client_id: string, + client_secret: string, + token: string, + extra?: RequestInit, + ): Promise> { + return this.post( + "/oauth/revoke", + { client_id, client_secret, token }, + extra, + ); + } + + /** + * 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 saveMarkers( + options: Partial<{ + home: { + last_read_id: string; + }; + notifications: { + last_read_id: string; + }; + }>, + extra?: RequestInit, + ): Promise> { + return this.post("/api/v1/markers", options, extra); + } + + public scheduleStatus( + id: string, + scheduled_at?: string, + extra?: RequestInit, + ): Promise> { + return this.put( + `/api/v1/scheduled_statuses/${id}`, + { scheduled_at }, + extra, + ); + } + + /** + * 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 search( + q: string, + options: Partial<{ + account_id: string; + exclude_unreviewed: boolean; + following: boolean; + limit: number; + max_id: string; + min_id: string; + offset: number; + resolve: boolean; + type: "accounts" | "hashtags" | "statuses"; + }>, + extra?: RequestInit, + ): Promise> { + const params = new URLSearchParams(); + + params.set("q", q); + + if (options) { + if (options.account_id) + params.set("account_id", options.account_id); + if (options.exclude_unreviewed) + params.set("exclude_unreviewed", "true"); + if (options.following) params.set("following", "true"); + if (options.limit) params.set("limit", options.limit.toString()); + if (options.max_id) params.set("max_id", options.max_id); + if (options.min_id) params.set("min_id", options.min_id); + if (options.offset) params.set("offset", options.offset.toString()); + if (options.resolve) params.set("resolve", "true"); + if (options.type) params.set("type", options.type); + } + + return this.get(`/api/v2/search?${params}`, extra); + } + + public searchAccount( + q: string, + options: Partial<{ + following: boolean; + limit: number; + max_id: string; + resolve: boolean; + since_id: string; + }>, + extra?: RequestInit, + ): Promise> { + const params = new URLSearchParams(); + + params.set("q", q); + + if (options) { + if (options.following) params.set("following", "true"); + if (options.limit) params.set("limit", options.limit.toString()); + if (options.max_id) params.set("max_id", options.max_id); + if (options.resolve) params.set("resolve", "true"); + if (options.since_id) params.set("since_id", options.since_id); + } + + return this.get(`/api/v1/accounts/search?${params}`, extra); + } + + // TODO: streamingURL + + /** + * 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? + * @param data.alerts.status Receive status notifications? + * @param data.alerts.follow_request Receive follow request notifications? + * @param data.alerts.update Receive status update notifications? + * @param data.alerts.admin.sign_up Receive sign up notifications? + * @param data.alerts.admin.report Receive report notifications? + * @param data.policy Notification policy. Defaults to all. + * @return PushSubscription. + */ + public subscribePushNotifications( + subscription: { + endpoint: string; + keys: { + auth: string; + p256dh: string; + }; + }, + data?: { + alerts: Partial<{ + favourite: boolean; + follow: boolean; + mention: boolean; + poll: boolean; + reblog: boolean; + status: boolean; + follow_request: boolean; + update: boolean; + "admin.sign_up": boolean; + "admin.report": boolean; + }>; + policy?: "all" | "followed" | "follower" | "none"; + }, + extra?: RequestInit, + ): Promise> { + return this.post( + "/api/v1/push/subscription", + { subscription, data }, + extra, + ); + } + + // TODO: tagStreaming + + public unblockAccount( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/accounts/${id}/unblock`, + undefined, + extra, + ); + } + + public unblockDomain( + domain: string, + extra?: RequestInit, + ): Promise> { + return this.delete("/api/v1/domain_blocks", { domain }, extra); + } + + public unbookmarkStatus( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/statuses/${id}/unbookmark`, + undefined, + extra, + ); + } + + public unfavouriteStatus( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/statuses/${id}/unfavourite`, + undefined, + extra, + ); + } + + public unfollowAccount( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/accounts/${id}/unfollow`, + undefined, + extra, + ); + } + + public unmuteAccount( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/accounts/${id}/unmute`, + undefined, + extra, + ); + } + + public unpinAccount( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/accounts/${id}/unpin`, + undefined, + extra, + ); + } + + public unpinStatus( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/statuses/${id}/unpin`, + undefined, + extra, + ); + } + + public unreblogStatus( + id: string, + extra?: RequestInit, + ): Promise> { + return this.post( + `/api/v1/statuses/${id}/unreblog`, + undefined, + extra, + ); + } + + /** + * PATCH /api/v1/accounts/update_credentials + * + * @param options.avatar Avatar image, as a File + * @param options.bot Is this account a bot? + * @param options.discoverable Should this account be included in directory? + * @param options.display_name Account display name. + * @param options.fields_attributes Array of profile metadata. + * @param options.header Header image, as a File + * @param options.locked Is this account locked? + * @param options.note Brief account description. + * @param options.source Source metadata. + * @return An account. + */ + public updateCredentials( + options: Partial<{ + avatar: File; + bot: boolean; + discoverable: boolean; + display_name: string; + fields_attributes: { + name: string; + value: string; + }[]; + header: File; + locked: boolean; + note: string; + source: Partial<{ + language: string; + privacy: string; + sensitive: boolean; + }>; + }>, + extra?: RequestInit, + ): Promise> { + return this.patchForm( + "/api/v1/accounts/update_credentials", + options, + extra, + ); + } + + // TODO: updateFilter + + /** + * PUT /api/v1/lists/:id + * + * @param id Target list ID. + * @param options.title New list title. + * @param options.replies_policy Which replies should be shown in the list. + * @param options.exclusive Should the members of this list be removed from the home timeline? + * @return List. + */ + public updateList( + id: string, + options: { + title: string; + replies_policy?: "none" | "followed" | "list"; + exclusive?: boolean; + }, + extra?: RequestInit, + ): Promise> { + return this.put(`/api/v1/lists/${id}`, { ...options }, extra); + } + + public updateMedia( + id: string, + options?: Partial<{ + description: string; + file: File; + focus: string; + }>, + extra?: RequestInit, + ): Promise> { + return this.putForm( + `/api/v1/media/${id}`, + { ...options }, + extra, + ); + } + + public updatePushSubscription( + data?: { + alerts: Partial<{ + favourite: boolean; + follow: boolean; + mention: boolean; + poll: boolean; + reblog: boolean; + status: boolean; + follow_request: boolean; + update: boolean; + "admin.sign_up": boolean; + "admin.report": boolean; + }>; + policy?: "all" | "followed" | "follower" | "none"; + }, + extra?: RequestInit, + ): Promise> { + return this.put( + "/api/v1/push/subscription", + { data: { ...data, policy: undefined }, policy: data?.policy }, + extra, + ); + } + + public uploadMedia( + file: File, + options?: Partial<{ + description: string; + focus: string; + }>, + extra?: RequestInit, + ): Promise> { + return this.postForm( + "/api/v2/media", + { file, ...options }, + extra, + ); + } + + // TODO: userStreaming + + public verifyAccountCredentials( + extra?: RequestInit, + ): Promise> { + return this.get("/api/v1/accounts/verify_credentials", extra); + } + + public verifyAppCredentials( + extra?: RequestInit, + ): Promise> { + return this.get("/api/v1/apps/verify_credentials", extra); + } + + public votePoll( + id: string, + choices: number[], + extra?: RequestInit, + ): Promise> { + return this.post(`/api/v1/polls/${id}/votes`, { choices }, extra); + } }