diff options
| author | Kagami Sascha Rosylight <saschanaz@outlook.com> | 2023-03-25 08:09:24 +0100 |
|---|---|---|
| committer | Kagami Sascha Rosylight <saschanaz@outlook.com> | 2023-03-25 08:09:24 +0100 |
| commit | 3b524f32bfadfa76d28ef26600642bd190118da3 (patch) | |
| tree | 9c4d37a3eeb902b65f0d8e487106282a11578ec7 /packages/misskey-js/src | |
| parent | Update CHANGELOG.md (diff) | |
| parent | feat: add type of gallery (#55) (diff) | |
| download | misskey-3b524f32bfadfa76d28ef26600642bd190118da3.tar.gz misskey-3b524f32bfadfa76d28ef26600642bd190118da3.tar.bz2 misskey-3b524f32bfadfa76d28ef26600642bd190118da3.zip | |
Subtree merged in packages/misskey-js
Diffstat (limited to 'packages/misskey-js/src')
| -rw-r--r-- | packages/misskey-js/src/acct.ts | 14 | ||||
| -rw-r--r-- | packages/misskey-js/src/api.ts | 102 | ||||
| -rw-r--r-- | packages/misskey-js/src/api.types.ts | 605 | ||||
| -rw-r--r-- | packages/misskey-js/src/consts.ts | 42 | ||||
| -rw-r--r-- | packages/misskey-js/src/entities.ts | 508 | ||||
| -rw-r--r-- | packages/misskey-js/src/index.ts | 26 | ||||
| -rw-r--r-- | packages/misskey-js/src/streaming.ts | 340 | ||||
| -rw-r--r-- | packages/misskey-js/src/streaming.types.ts | 152 |
8 files changed, 1789 insertions, 0 deletions
diff --git a/packages/misskey-js/src/acct.ts b/packages/misskey-js/src/acct.ts new file mode 100644 index 0000000000..c32cee86c9 --- /dev/null +++ b/packages/misskey-js/src/acct.ts @@ -0,0 +1,14 @@ +export type Acct = { + username: string; + host: string | null; +}; + +export function parse(acct: string): Acct { + if (acct.startsWith('@')) acct = acct.substr(1); + const split = acct.split('@', 2); + return { username: split[0], host: split[1] || null }; +} + +export function toString(acct: Acct): string { + return acct.host == null ? acct.username : `${acct.username}@${acct.host}`; +} diff --git a/packages/misskey-js/src/api.ts b/packages/misskey-js/src/api.ts new file mode 100644 index 0000000000..1704b6e462 --- /dev/null +++ b/packages/misskey-js/src/api.ts @@ -0,0 +1,102 @@ +import { Endpoints } from './api.types'; + +const MK_API_ERROR = Symbol(); + +export type APIError = { + id: string; + code: string; + message: string; + kind: 'client' | 'server'; + info: Record<string, any>; +}; + +export function isAPIError(reason: any): reason is APIError { + return reason[MK_API_ERROR] === true; +} + +export type FetchLike = (input: string, init?: { + method?: string; + body?: string; + credentials?: RequestCredentials; + cache?: RequestCache; + headers: {[key in string]: string} + }) => Promise<{ + status: number; + json(): Promise<any>; + }>; + +type IsNeverType<T> = [T] extends [never] ? true : false; + +type StrictExtract<Union, Cond> = Cond extends Union ? Union : never; + +type IsCaseMatched<E extends keyof Endpoints, P extends Endpoints[E]['req'], C extends number> = + IsNeverType<StrictExtract<Endpoints[E]['res']['$switch']['$cases'][C], [P, any]>> extends false ? true : false; + +type GetCaseResult<E extends keyof Endpoints, P extends Endpoints[E]['req'], C extends number> = + StrictExtract<Endpoints[E]['res']['$switch']['$cases'][C], [P, any]>[1]; + +export class APIClient { + public origin: string; + public credential: string | null | undefined; + public fetch: FetchLike; + + constructor(opts: { + origin: APIClient['origin']; + credential?: APIClient['credential']; + fetch?: APIClient['fetch'] | null | undefined; + }) { + this.origin = opts.origin; + this.credential = opts.credential; + // ネイティブ関数をそのまま変数に代入して使おうとするとChromiumではIllegal invocationエラーが発生するため、 + // 環境で実装されているfetchを使う場合は無名関数でラップして使用する + this.fetch = opts.fetch || ((...args) => fetch(...args)); + } + + public request<E extends keyof Endpoints, P extends Endpoints[E]['req']>( + endpoint: E, params: P = {} as P, credential?: string | null | undefined, + ): Promise<Endpoints[E]['res'] extends { $switch: { $cases: [any, any][]; $default: any; }; } + ? + IsCaseMatched<E, P, 0> extends true ? GetCaseResult<E, P, 0> : + IsCaseMatched<E, P, 1> extends true ? GetCaseResult<E, P, 1> : + IsCaseMatched<E, P, 2> extends true ? GetCaseResult<E, P, 2> : + IsCaseMatched<E, P, 3> extends true ? GetCaseResult<E, P, 3> : + IsCaseMatched<E, P, 4> extends true ? GetCaseResult<E, P, 4> : + IsCaseMatched<E, P, 5> extends true ? GetCaseResult<E, P, 5> : + IsCaseMatched<E, P, 6> extends true ? GetCaseResult<E, P, 6> : + IsCaseMatched<E, P, 7> extends true ? GetCaseResult<E, P, 7> : + IsCaseMatched<E, P, 8> extends true ? GetCaseResult<E, P, 8> : + IsCaseMatched<E, P, 9> extends true ? GetCaseResult<E, P, 9> : + Endpoints[E]['res']['$switch']['$default'] + : Endpoints[E]['res']> + { + const promise = new Promise((resolve, reject) => { + this.fetch(`${this.origin}/api/${endpoint}`, { + method: 'POST', + body: JSON.stringify({ + ...params, + i: credential !== undefined ? credential : this.credential, + }), + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'omit', + cache: 'no-cache', + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(null); + } else { + reject({ + [MK_API_ERROR]: true, + ...body.error, + }); + } + }).catch(reject); + }); + + return promise as any; + } +} diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts new file mode 100644 index 0000000000..4fbf42f917 --- /dev/null +++ b/packages/misskey-js/src/api.types.ts @@ -0,0 +1,605 @@ +import { + Ad, Announcement, Antenna, App, AuthSession, Blocking, Channel, Clip, DateString, DetailedInstanceMetadata, DriveFile, DriveFolder, Following, FollowingFolloweePopulated, FollowingFollowerPopulated, FollowRequest, GalleryPost, Instance, InstanceMetadata, + LiteInstanceMetadata, + MeDetailed, + Note, NoteFavorite, OriginType, Page, ServerInfo, Stats, User, UserDetailed, UserGroup, UserList, UserSorting, Notification, NoteReaction, Signin, MessagingMessage, +} from './entities'; + +type TODO = Record<string, any> | null; + +type NoParams = Record<string, never>; + +type ShowUserReq = { username: string; host?: string; } | { userId: User['id']; }; + +export type Endpoints = { + // admin + 'admin/abuse-user-reports': { req: TODO; res: TODO; }; + 'admin/delete-all-files-of-a-user': { req: { userId: User['id']; }; res: null; }; + 'admin/delete-logs': { req: NoParams; res: null; }; + 'admin/get-index-stats': { req: TODO; res: TODO; }; + 'admin/get-table-stats': { req: TODO; res: TODO; }; + 'admin/invite': { req: TODO; res: TODO; }; + 'admin/logs': { req: TODO; res: TODO; }; + 'admin/reset-password': { req: TODO; res: TODO; }; + 'admin/resolve-abuse-user-report': { req: TODO; res: TODO; }; + 'admin/resync-chart': { req: TODO; res: TODO; }; + 'admin/send-email': { req: TODO; res: TODO; }; + 'admin/server-info': { req: TODO; res: TODO; }; + 'admin/show-moderation-logs': { req: TODO; res: TODO; }; + 'admin/show-user': { req: TODO; res: TODO; }; + 'admin/show-users': { req: TODO; res: TODO; }; + 'admin/silence-user': { req: TODO; res: TODO; }; + 'admin/suspend-user': { req: TODO; res: TODO; }; + 'admin/unsilence-user': { req: TODO; res: TODO; }; + 'admin/unsuspend-user': { req: TODO; res: TODO; }; + 'admin/update-meta': { req: TODO; res: TODO; }; + 'admin/vacuum': { req: TODO; res: TODO; }; + 'admin/accounts/create': { req: TODO; res: TODO; }; + 'admin/ad/create': { req: TODO; res: TODO; }; + 'admin/ad/delete': { req: { id: Ad['id']; }; res: null; }; + 'admin/ad/list': { req: TODO; res: TODO; }; + 'admin/ad/update': { req: TODO; res: TODO; }; + 'admin/announcements/create': { req: TODO; res: TODO; }; + 'admin/announcements/delete': { req: { id: Announcement['id'] }; res: null; }; + 'admin/announcements/list': { req: TODO; res: TODO; }; + 'admin/announcements/update': { req: TODO; res: TODO; }; + 'admin/drive/clean-remote-files': { req: TODO; res: TODO; }; + 'admin/drive/cleanup': { req: TODO; res: TODO; }; + 'admin/drive/files': { req: TODO; res: TODO; }; + 'admin/drive/show-file': { req: TODO; res: TODO; }; + 'admin/emoji/add': { req: TODO; res: TODO; }; + 'admin/emoji/copy': { req: TODO; res: TODO; }; + 'admin/emoji/list-remote': { req: TODO; res: TODO; }; + 'admin/emoji/list': { req: TODO; res: TODO; }; + 'admin/emoji/remove': { req: TODO; res: TODO; }; + 'admin/emoji/update': { req: TODO; res: TODO; }; + 'admin/federation/delete-all-files': { req: { host: string; }; res: null; }; + 'admin/federation/refresh-remote-instance-metadata': { req: TODO; res: TODO; }; + 'admin/federation/remove-all-following': { req: TODO; res: TODO; }; + 'admin/federation/update-instance': { req: TODO; res: TODO; }; + 'admin/moderators/add': { req: TODO; res: TODO; }; + 'admin/moderators/remove': { req: TODO; res: TODO; }; + 'admin/promo/create': { req: TODO; res: TODO; }; + 'admin/queue/clear': { req: TODO; res: TODO; }; + 'admin/queue/deliver-delayed': { req: TODO; res: TODO; }; + 'admin/queue/inbox-delayed': { req: TODO; res: TODO; }; + 'admin/queue/jobs': { req: TODO; res: TODO; }; + 'admin/queue/stats': { req: TODO; res: TODO; }; + 'admin/relays/add': { req: TODO; res: TODO; }; + 'admin/relays/list': { req: TODO; res: TODO; }; + 'admin/relays/remove': { req: TODO; res: TODO; }; + + // announcements + 'announcements': { req: { limit?: number; withUnreads?: boolean; sinceId?: Announcement['id']; untilId?: Announcement['id']; }; res: Announcement[]; }; + + // antennas + 'antennas/create': { req: TODO; res: Antenna; }; + 'antennas/delete': { req: { antennaId: Antenna['id']; }; res: null; }; + 'antennas/list': { req: NoParams; res: Antenna[]; }; + 'antennas/notes': { req: { antennaId: Antenna['id']; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; + 'antennas/show': { req: { antennaId: Antenna['id']; }; res: Antenna; }; + 'antennas/update': { req: TODO; res: Antenna; }; + + // ap + 'ap/get': { req: { uri: string; }; res: Record<string, any>; }; + 'ap/show': { req: { uri: string; }; res: { + type: 'Note'; + object: Note; + } | { + type: 'User'; + object: UserDetailed; + }; }; + + // app + 'app/create': { req: TODO; res: App; }; + 'app/show': { req: { appId: App['id']; }; res: App; }; + + // auth + 'auth/accept': { req: { token: string; }; res: null; }; + 'auth/session/generate': { req: { appSecret: string; }; res: { token: string; url: string; }; }; + 'auth/session/show': { req: { token: string; }; res: AuthSession; }; + 'auth/session/userkey': { req: { appSecret: string; token: string; }; res: { accessToken: string; user: User }; }; + + // blocking + 'blocking/create': { req: { userId: User['id'] }; res: UserDetailed; }; + 'blocking/delete': { req: { userId: User['id'] }; res: UserDetailed; }; + 'blocking/list': { req: { limit?: number; sinceId?: Blocking['id']; untilId?: Blocking['id']; }; res: Blocking[]; }; + + // channels + 'channels/create': { req: TODO; res: TODO; }; + 'channels/featured': { req: TODO; res: TODO; }; + 'channels/follow': { req: TODO; res: TODO; }; + 'channels/followed': { req: TODO; res: TODO; }; + 'channels/owned': { req: TODO; res: TODO; }; + 'channels/pin-note': { req: TODO; res: TODO; }; + 'channels/show': { req: TODO; res: TODO; }; + 'channels/timeline': { req: TODO; res: TODO; }; + 'channels/unfollow': { req: TODO; res: TODO; }; + 'channels/update': { req: TODO; res: TODO; }; + + // charts + 'charts/active-users': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; }; res: { + local: { + users: number[]; + }; + remote: { + users: number[]; + }; + }; }; + 'charts/drive': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; }; res: { + local: { + decCount: number[]; + decSize: number[]; + incCount: number[]; + incSize: number[]; + totalCount: number[]; + totalSize: number[]; + }; + remote: { + decCount: number[]; + decSize: number[]; + incCount: number[]; + incSize: number[]; + totalCount: number[]; + totalSize: number[]; + }; + }; }; + 'charts/federation': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; }; res: { + instance: { + dec: number[]; + inc: number[]; + total: number[]; + }; + }; }; + 'charts/hashtag': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; }; res: TODO; }; + 'charts/instance': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; host: string; }; res: { + drive: { + decFiles: number[]; + decUsage: number[]; + incFiles: number[]; + incUsage: number[]; + totalFiles: number[]; + totalUsage: number[]; + }; + followers: { + dec: number[]; + inc: number[]; + total: number[]; + }; + following: { + dec: number[]; + inc: number[]; + total: number[]; + }; + notes: { + dec: number[]; + inc: number[]; + total: number[]; + diffs: { + normal: number[]; + renote: number[]; + reply: number[]; + }; + }; + requests: { + failed: number[]; + received: number[]; + succeeded: number[]; + }; + users: { + dec: number[]; + inc: number[]; + total: number[]; + }; + }; }; + 'charts/network': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; }; res: TODO; }; + 'charts/notes': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; }; res: { + local: { + dec: number[]; + inc: number[]; + total: number[]; + diffs: { + normal: number[]; + renote: number[]; + reply: number[]; + }; + }; + remote: { + dec: number[]; + inc: number[]; + total: number[]; + diffs: { + normal: number[]; + renote: number[]; + reply: number[]; + }; + }; + }; }; + 'charts/user/drive': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; userId: User['id']; }; res: { + decCount: number[]; + decSize: number[]; + incCount: number[]; + incSize: number[]; + totalCount: number[]; + totalSize: number[]; + }; }; + 'charts/user/following': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; userId: User['id']; }; res: TODO; }; + 'charts/user/notes': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; userId: User['id']; }; res: { + dec: number[]; + inc: number[]; + total: number[]; + diffs: { + normal: number[]; + renote: number[]; + reply: number[]; + }; + }; }; + 'charts/user/reactions': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; userId: User['id']; }; res: TODO; }; + 'charts/users': { req: { span: 'day' | 'hour'; limit?: number; offset?: number | null; }; res: { + local: { + dec: number[]; + inc: number[]; + total: number[]; + }; + remote: { + dec: number[]; + inc: number[]; + total: number[]; + }; + }; }; + + // clips + 'clips/add-note': { req: TODO; res: TODO; }; + 'clips/create': { req: TODO; res: TODO; }; + 'clips/delete': { req: { clipId: Clip['id']; }; res: null; }; + 'clips/list': { req: TODO; res: TODO; }; + 'clips/notes': { req: TODO; res: TODO; }; + 'clips/show': { req: TODO; res: TODO; }; + 'clips/update': { req: TODO; res: TODO; }; + + // drive + 'drive': { req: NoParams; res: { capacity: number; usage: number; }; }; + 'drive/files': { req: { folderId?: DriveFolder['id'] | null; type?: DriveFile['type'] | null; limit?: number; sinceId?: DriveFile['id']; untilId?: DriveFile['id']; }; res: DriveFile[]; }; + 'drive/files/attached-notes': { req: TODO; res: TODO; }; + 'drive/files/check-existence': { req: TODO; res: TODO; }; + 'drive/files/create': { req: TODO; res: TODO; }; + 'drive/files/delete': { req: { fileId: DriveFile['id']; }; res: null; }; + 'drive/files/find-by-hash': { req: TODO; res: TODO; }; + 'drive/files/find': { req: { name: string; folderId?: DriveFolder['id'] | null; }; res: DriveFile[]; }; + 'drive/files/show': { req: { fileId?: DriveFile['id']; url?: string; }; res: DriveFile; }; + 'drive/files/update': { req: { fileId: DriveFile['id']; folderId?: DriveFolder['id'] | null; name?: string; isSensitive?: boolean; comment?: string | null; }; res: DriveFile; }; + 'drive/files/upload-from-url': { req: { url: string; folderId?: DriveFolder['id'] | null; isSensitive?: boolean; comment?: string | null; marker?: string | null; force?: boolean; }; res: null; }; + 'drive/folders': { req: { folderId?: DriveFolder['id'] | null; limit?: number; sinceId?: DriveFile['id']; untilId?: DriveFile['id']; }; res: DriveFolder[]; }; + 'drive/folders/create': { req: { name?: string; parentId?: DriveFolder['id'] | null; }; res: DriveFolder; }; + 'drive/folders/delete': { req: { folderId: DriveFolder['id']; }; res: null; }; + 'drive/folders/find': { req: { name: string; parentId?: DriveFolder['id'] | null; }; res: DriveFolder[]; }; + 'drive/folders/show': { req: { folderId: DriveFolder['id']; }; res: DriveFolder; }; + 'drive/folders/update': { req: { folderId: DriveFolder['id']; name?: string; parentId?: DriveFolder['id'] | null; }; res: DriveFolder; }; + 'drive/stream': { req: { type?: DriveFile['type'] | null; limit?: number; sinceId?: DriveFile['id']; untilId?: DriveFile['id']; }; res: DriveFile[]; }; + + // endpoint + 'endpoint': { req: { endpoint: string; }; res: { params: { name: string; type: string; }[]; }; }; + + // endpoints + 'endpoints': { req: NoParams; res: string[]; }; + + // federation + 'federation/dns': { req: { host: string; }; res: { + a: string[]; + aaaa: string[]; + cname: string[]; + txt: string[]; + }; }; + 'federation/followers': { req: { host: string; limit?: number; sinceId?: Following['id']; untilId?: Following['id']; }; res: FollowingFolloweePopulated[]; }; + 'federation/following': { req: { host: string; limit?: number; sinceId?: Following['id']; untilId?: Following['id']; }; res: FollowingFolloweePopulated[]; }; + 'federation/instances': { req: { + host?: string | null; + blocked?: boolean | null; + notResponding?: boolean | null; + suspended?: boolean | null; + federating?: boolean | null; + subscribing?: boolean | null; + publishing?: boolean | null; + limit?: number; + offset?: number; + sort?: '+pubSub' | '-pubSub' | '+notes' | '-notes' | '+users' | '-users' | '+following' | '-following' | '+followers' | '-followers' | '+caughtAt' | '-caughtAt' | '+lastCommunicatedAt' | '-lastCommunicatedAt' | '+driveUsage' | '-driveUsage' | '+driveFiles' | '-driveFiles'; + }; res: Instance[]; }; + 'federation/show-instance': { req: { host: string; }; res: Instance; }; + 'federation/update-remote-user': { req: { userId: User['id']; }; res: null; }; + 'federation/users': { req: { host: string; limit?: number; sinceId?: User['id']; untilId?: User['id']; }; res: UserDetailed[]; }; + + // following + 'following/create': { req: { userId: User['id'] }; res: User; }; + 'following/delete': { req: { userId: User['id'] }; res: User; }; + 'following/requests/accept': { req: { userId: User['id'] }; res: null; }; + 'following/requests/cancel': { req: { userId: User['id'] }; res: User; }; + 'following/requests/list': { req: NoParams; res: FollowRequest[]; }; + 'following/requests/reject': { req: { userId: User['id'] }; res: null; }; + + // gallery + 'gallery/featured': { req: null; res: GalleryPost[]; }; + 'gallery/popular': { req: null; res: GalleryPost[]; }; + 'gallery/posts': { req: { limit?: number; sinceId?: GalleryPost['id']; untilId?: GalleryPost['id']; }; res: GalleryPost[]; }; + 'gallery/posts/create': { req: { title: GalleryPost['title']; description?: GalleryPost['description']; fileIds: GalleryPost['fileIds']; isSensitive?: GalleryPost['isSensitive'] }; res: GalleryPost; }; + 'gallery/posts/delete': { req: { postId: GalleryPost['id'] }; res: null; }; + 'gallery/posts/like': { req: { postId: GalleryPost['id'] }; res: null; }; + 'gallery/posts/show': { req: { postId: GalleryPost['id'] }; res: GalleryPost; }; + 'gallery/posts/unlike': { req: { postId: GalleryPost['id'] }; res: null; }; + 'gallery/posts/update': { req: { postId: GalleryPost['id']; title: GalleryPost['title']; description?: GalleryPost['description']; fileIds: GalleryPost['fileIds']; isSensitive?: GalleryPost['isSensitive'] }; res: GalleryPost; }; + + // games + 'games/reversi/games': { req: TODO; res: TODO; }; + 'games/reversi/games/show': { req: TODO; res: TODO; }; + 'games/reversi/games/surrender': { req: TODO; res: TODO; }; + 'games/reversi/invitations': { req: TODO; res: TODO; }; + 'games/reversi/match': { req: TODO; res: TODO; }; + 'games/reversi/match/cancel': { req: TODO; res: TODO; }; + + // get-online-users-count + 'get-online-users-count': { req: NoParams; res: { count: number; }; }; + + // hashtags + 'hashtags/list': { req: TODO; res: TODO; }; + 'hashtags/search': { req: TODO; res: TODO; }; + 'hashtags/show': { req: TODO; res: TODO; }; + 'hashtags/trend': { req: TODO; res: TODO; }; + 'hashtags/users': { req: TODO; res: TODO; }; + + // i + 'i': { req: NoParams; res: User; }; + 'i/apps': { req: TODO; res: TODO; }; + 'i/authorized-apps': { req: TODO; res: TODO; }; + 'i/change-password': { req: TODO; res: TODO; }; + 'i/delete-account': { req: { password: string; }; res: null; }; + 'i/export-blocking': { req: TODO; res: TODO; }; + 'i/export-following': { req: TODO; res: TODO; }; + 'i/export-mute': { req: TODO; res: TODO; }; + 'i/export-notes': { req: TODO; res: TODO; }; + 'i/export-user-lists': { req: TODO; res: TODO; }; + 'i/favorites': { req: { limit?: number; sinceId?: NoteFavorite['id']; untilId?: NoteFavorite['id']; }; res: NoteFavorite[]; }; + 'i/gallery/likes': { req: TODO; res: TODO; }; + 'i/gallery/posts': { req: TODO; res: TODO; }; + 'i/get-word-muted-notes-count': { req: TODO; res: TODO; }; + 'i/import-following': { req: TODO; res: TODO; }; + 'i/import-user-lists': { req: TODO; res: TODO; }; + 'i/notifications': { req: { + limit?: number; + sinceId?: Notification['id']; + untilId?: Notification['id']; + following?: boolean; + markAsRead?: boolean; + includeTypes?: Notification['type'][]; + excludeTypes?: Notification['type'][]; + }; res: Notification[]; }; + 'i/page-likes': { req: TODO; res: TODO; }; + 'i/pages': { req: TODO; res: TODO; }; + 'i/pin': { req: { noteId: Note['id']; }; res: MeDetailed; }; + 'i/read-all-messaging-messages': { req: TODO; res: TODO; }; + 'i/read-all-unread-notes': { req: TODO; res: TODO; }; + 'i/read-announcement': { req: TODO; res: TODO; }; + 'i/regenerate-token': { req: { password: string; }; res: null; }; + 'i/registry/get-all': { req: { scope?: string[]; }; res: Record<string, any>; }; + 'i/registry/get-detail': { req: { key: string; scope?: string[]; }; res: { updatedAt: DateString; value: any; }; }; + 'i/registry/get': { req: { key: string; scope?: string[]; }; res: any; }; + 'i/registry/keys-with-type': { req: { scope?: string[]; }; res: Record<string, 'null' | 'array' | 'number' | 'string' | 'boolean' | 'object'>; }; + 'i/registry/keys': { req: { scope?: string[]; }; res: string[]; }; + 'i/registry/remove': { req: { key: string; scope?: string[]; }; res: null; }; + 'i/registry/scopes': { req: NoParams; res: string[][]; }; + 'i/registry/set': { req: { key: string; value: any; scope?: string[]; }; res: null; }; + 'i/revoke-token': { req: TODO; res: TODO; }; + 'i/signin-history': { req: { limit?: number; sinceId?: Signin['id']; untilId?: Signin['id']; }; res: Signin[]; }; + 'i/unpin': { req: { noteId: Note['id']; }; res: MeDetailed; }; + 'i/update-email': { req: { + password: string; + email?: string | null; + }; res: MeDetailed; }; + 'i/update': { req: { + name?: string | null; + description?: string | null; + lang?: string | null; + location?: string | null; + birthday?: string | null; + avatarId?: DriveFile['id'] | null; + bannerId?: DriveFile['id'] | null; + fields?: { + name: string; + value: string; + }[]; + isLocked?: boolean; + isExplorable?: boolean; + hideOnlineStatus?: boolean; + carefulBot?: boolean; + autoAcceptFollowed?: boolean; + noCrawle?: boolean; + isBot?: boolean; + isCat?: boolean; + injectFeaturedNote?: boolean; + receiveAnnouncementEmail?: boolean; + alwaysMarkNsfw?: boolean; + mutedWords?: string[][]; + mutingNotificationTypes?: Notification['type'][]; + emailNotificationTypes?: string[]; + }; res: MeDetailed; }; + 'i/user-group-invites': { req: TODO; res: TODO; }; + 'i/2fa/done': { req: TODO; res: TODO; }; + 'i/2fa/key-done': { req: TODO; res: TODO; }; + 'i/2fa/password-less': { req: TODO; res: TODO; }; + 'i/2fa/register-key': { req: TODO; res: TODO; }; + 'i/2fa/register': { req: TODO; res: TODO; }; + 'i/2fa/remove-key': { req: TODO; res: TODO; }; + 'i/2fa/unregister': { req: TODO; res: TODO; }; + + // messaging + 'messaging/history': { req: { limit?: number; group?: boolean; }; res: MessagingMessage[]; }; + 'messaging/messages': { req: { userId?: User['id']; groupId?: UserGroup['id']; limit?: number; sinceId?: MessagingMessage['id']; untilId?: MessagingMessage['id']; markAsRead?: boolean; }; res: MessagingMessage[]; }; + 'messaging/messages/create': { req: { userId?: User['id']; groupId?: UserGroup['id']; text?: string; fileId?: DriveFile['id']; }; res: MessagingMessage; }; + 'messaging/messages/delete': { req: { messageId: MessagingMessage['id']; }; res: null; }; + 'messaging/messages/read': { req: { messageId: MessagingMessage['id']; }; res: null; }; + + // meta + 'meta': { req: { detail?: boolean; }; res: { + $switch: { + $cases: [[ + { detail: true; }, + DetailedInstanceMetadata, + ], [ + { detail: false; }, + LiteInstanceMetadata, + ], [ + { detail: boolean; }, + LiteInstanceMetadata | DetailedInstanceMetadata, + ]]; + $default: LiteInstanceMetadata; + }; + }; }; + + // miauth + 'miauth/gen-token': { req: TODO; res: TODO; }; + + // mute + 'mute/create': { req: TODO; res: TODO; }; + 'mute/delete': { req: { userId: User['id'] }; res: null; }; + 'mute/list': { req: TODO; res: TODO; }; + + // my + 'my/apps': { req: TODO; res: TODO; }; + + // notes + 'notes': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; + 'notes/children': { req: { noteId: Note['id']; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; + 'notes/clips': { req: TODO; res: TODO; }; + 'notes/conversation': { req: TODO; res: TODO; }; + 'notes/create': { req: { + visibility?: 'public' | 'home' | 'followers' | 'specified', + visibleUserIds?: User['id'][]; + text?: null | string; + cw?: null | string; + viaMobile?: boolean; + localOnly?: boolean; + fileIds?: DriveFile['id'][]; + replyId?: null | Note['id']; + renoteId?: null | Note['id']; + channelId?: null | Channel['id']; + poll?: null | { + choices: string[]; + multiple?: boolean; + expiresAt?: null | number; + expiredAfter?: null | number; + }; + }; res: { createdNote: Note }; }; + 'notes/delete': { req: { noteId: Note['id']; }; res: null; }; + 'notes/favorites/create': { req: { noteId: Note['id']; }; res: null; }; + 'notes/favorites/delete': { req: { noteId: Note['id']; }; res: null; }; + 'notes/featured': { req: TODO; res: Note[]; }; + 'notes/global-timeline': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; sinceDate?: number; untilDate?: number; }; res: Note[]; }; + 'notes/hybrid-timeline': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; sinceDate?: number; untilDate?: number; }; res: Note[]; }; + 'notes/local-timeline': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; sinceDate?: number; untilDate?: number; }; res: Note[]; }; + 'notes/mentions': { req: { following?: boolean; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; + 'notes/polls/recommendation': { req: TODO; res: TODO; }; + 'notes/polls/vote': { req: { noteId: Note['id']; choice: number; }; res: null; }; + 'notes/reactions': { req: { noteId: Note['id']; type?: string | null; limit?: number; }; res: NoteReaction[]; }; + 'notes/reactions/create': { req: { noteId: Note['id']; reaction: string; }; res: null; }; + 'notes/reactions/delete': { req: { noteId: Note['id']; }; res: null; }; + 'notes/renotes': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; noteId: Note['id']; }; res: Note[]; }; + 'notes/replies': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; noteId: Note['id']; }; res: Note[]; }; + 'notes/search-by-tag': { req: TODO; res: TODO; }; + 'notes/search': { req: TODO; res: TODO; }; + 'notes/show': { req: { noteId: Note['id']; }; res: Note; }; + 'notes/state': { req: TODO; res: TODO; }; + 'notes/timeline': { req: { limit?: number; sinceId?: Note['id']; untilId?: Note['id']; sinceDate?: number; untilDate?: number; }; res: Note[]; }; + 'notes/unrenote': { req: { noteId: Note['id']; }; res: null; }; + 'notes/user-list-timeline': { req: { listId: UserList['id']; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; sinceDate?: number; untilDate?: number; }; res: Note[]; }; + 'notes/watching/create': { req: TODO; res: TODO; }; + 'notes/watching/delete': { req: { noteId: Note['id']; }; res: null; }; + + // notifications + 'notifications/create': { req: { body: string; header?: string | null; icon?: string | null; }; res: null; }; + 'notifications/mark-all-as-read': { req: NoParams; res: null; }; + 'notifications/read': { req: { notificationId: Notification['id']; }; res: null; }; + + // page-push + 'page-push': { req: { pageId: Page['id']; event: string; var?: any; }; res: null; }; + + // pages + 'pages/create': { req: TODO; res: Page; }; + 'pages/delete': { req: { pageId: Page['id']; }; res: null; }; + 'pages/featured': { req: NoParams; res: Page[]; }; + 'pages/like': { req: { pageId: Page['id']; }; res: null; }; + 'pages/show': { req: { pageId?: Page['id']; name?: string; username?: string; }; res: Page; }; + 'pages/unlike': { req: { pageId: Page['id']; }; res: null; }; + 'pages/update': { req: TODO; res: null; }; + + // ping + 'ping': { req: NoParams; res: { pong: number; }; }; + + // pinned-users + 'pinned-users': { req: TODO; res: TODO; }; + + // promo + 'promo/read': { req: TODO; res: TODO; }; + + // request-reset-password + 'request-reset-password': { req: { username: string; email: string; }; res: null; }; + + // reset-password + 'reset-password': { req: { token: string; password: string; }; res: null; }; + + // room + 'room/show': { req: TODO; res: TODO; }; + 'room/update': { req: TODO; res: TODO; }; + + // stats + 'stats': { req: NoParams; res: Stats; }; + + // server-info + 'server-info': { req: NoParams; res: ServerInfo; }; + + // sw + 'sw/register': { req: TODO; res: TODO; }; + + // username + 'username/available': { req: { username: string; }; res: { available: boolean; }; }; + + // users + 'users': { req: { limit?: number; offset?: number; sort?: UserSorting; origin?: OriginType; }; res: User[]; }; + 'users/clips': { req: TODO; res: TODO; }; + 'users/followers': { req: { userId?: User['id']; username?: User['username']; host?: User['host'] | null; limit?: number; sinceId?: Following['id']; untilId?: Following['id']; }; res: FollowingFollowerPopulated[]; }; + 'users/following': { req: { userId?: User['id']; username?: User['username']; host?: User['host'] | null; limit?: number; sinceId?: Following['id']; untilId?: Following['id']; }; res: FollowingFolloweePopulated[]; }; + 'users/gallery/posts': { req: TODO; res: TODO; }; + 'users/get-frequently-replied-users': { req: TODO; res: TODO; }; + 'users/groups/create': { req: TODO; res: TODO; }; + 'users/groups/delete': { req: { groupId: UserGroup['id'] }; res: null; }; + 'users/groups/invitations/accept': { req: TODO; res: TODO; }; + 'users/groups/invitations/reject': { req: TODO; res: TODO; }; + 'users/groups/invite': { req: TODO; res: TODO; }; + 'users/groups/joined': { req: TODO; res: TODO; }; + 'users/groups/owned': { req: TODO; res: TODO; }; + 'users/groups/pull': { req: TODO; res: TODO; }; + 'users/groups/show': { req: TODO; res: TODO; }; + 'users/groups/transfer': { req: TODO; res: TODO; }; + 'users/groups/update': { req: TODO; res: TODO; }; + 'users/lists/create': { req: { name: string; }; res: UserList; }; + 'users/lists/delete': { req: { listId: UserList['id']; }; res: null; }; + 'users/lists/list': { req: NoParams; res: UserList[]; }; + 'users/lists/pull': { req: { listId: UserList['id']; userId: User['id']; }; res: null; }; + 'users/lists/push': { req: { listId: UserList['id']; userId: User['id']; }; res: null; }; + 'users/lists/show': { req: { listId: UserList['id']; }; res: UserList; }; + 'users/lists/update': { req: { listId: UserList['id']; name: string; }; res: UserList; }; + 'users/notes': { req: { userId: User['id']; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; sinceDate?: number; untilDate?: number; }; res: Note[]; }; + 'users/pages': { req: TODO; res: TODO; }; + 'users/recommendation': { req: TODO; res: TODO; }; + 'users/relation': { req: TODO; res: TODO; }; + 'users/report-abuse': { req: TODO; res: TODO; }; + 'users/search-by-username-and-host': { req: TODO; res: TODO; }; + 'users/search': { req: TODO; res: TODO; }; + 'users/show': { req: ShowUserReq | { userIds: User['id'][]; }; res: { + $switch: { + $cases: [[ + { userIds: User['id'][]; }, + UserDetailed[], + ]]; + $default: UserDetailed; + }; + }; }; + 'users/stats': { req: TODO; res: TODO; }; +}; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts new file mode 100644 index 0000000000..261ecd33f4 --- /dev/null +++ b/packages/misskey-js/src/consts.ts @@ -0,0 +1,42 @@ +export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; + +export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; + +export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; + +export const ffVisibility = ['public', 'followers', 'private'] as const; + +export const permissions = [ + 'read:account', + 'write:account', + 'read:blocks', + 'write:blocks', + 'read:drive', + 'write:drive', + 'read:favorites', + 'write:favorites', + 'read:following', + 'write:following', + 'read:messaging', + 'write:messaging', + 'read:mutes', + 'write:mutes', + 'write:notes', + 'read:notifications', + 'write:notifications', + 'read:reactions', + 'write:reactions', + 'write:votes', + 'read:pages', + 'write:pages', + 'write:page-likes', + 'read:page-likes', + 'read:user-groups', + 'write:user-groups', + 'read:channels', + 'write:channels', + 'read:gallery', + 'write:gallery', + 'read:gallery-likes', + 'write:gallery-likes', +]; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts new file mode 100644 index 0000000000..37a8bc6184 --- /dev/null +++ b/packages/misskey-js/src/entities.ts @@ -0,0 +1,508 @@ +export type ID = string; +export type DateString = string; + +type TODO = Record<string, any>; + +// NOTE: 極力この型を使うのは避け、UserLite か UserDetailed か明示するように +export type User = UserLite | UserDetailed; + +export type UserLite = { + id: ID; + username: string; + host: string | null; + name: string; + onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; + avatarUrl: string; + avatarBlurhash: string; + emojis: { + name: string; + url: string; + }[]; + instance?: { + name: Instance['name']; + softwareName: Instance['softwareName']; + softwareVersion: Instance['softwareVersion']; + iconUrl: Instance['iconUrl']; + faviconUrl: Instance['faviconUrl']; + themeColor: Instance['themeColor']; + }; +}; + +export type UserDetailed = UserLite & { + bannerBlurhash: string | null; + bannerColor: string | null; + bannerUrl: string | null; + birthday: string | null; + createdAt: DateString; + description: string | null; + ffVisibility: 'public' | 'followers' | 'private'; + fields: {name: string; value: string}[]; + followersCount: number; + followingCount: number; + hasPendingFollowRequestFromYou: boolean; + hasPendingFollowRequestToYou: boolean; + isAdmin: boolean; + isBlocked: boolean; + isBlocking: boolean; + isBot: boolean; + isCat: boolean; + isFollowed: boolean; + isFollowing: boolean; + isLocked: boolean; + isModerator: boolean; + isMuted: boolean; + isSilenced: boolean; + isSuspended: boolean; + lang: string | null; + lastFetchedAt?: DateString; + location: string | null; + notesCount: number; + pinnedNoteIds: ID[]; + pinnedNotes: Note[]; + pinnedPage: Page | null; + pinnedPageId: string | null; + publicReactions: boolean; + securityKeys: boolean; + twoFactorEnabled: boolean; + updatedAt: DateString | null; + uri: string | null; + url: string | null; +}; + +export type UserGroup = TODO; + +export type UserList = { + id: ID; + createdAt: DateString; + name: string; + userIds: User['id'][]; +}; + +export type MeDetailed = UserDetailed & { + avatarId: DriveFile['id']; + bannerId: DriveFile['id']; + autoAcceptFollowed: boolean; + alwaysMarkNsfw: boolean; + carefulBot: boolean; + emailNotificationTypes: string[]; + hasPendingReceivedFollowRequest: boolean; + hasUnreadAnnouncement: boolean; + hasUnreadAntenna: boolean; + hasUnreadChannel: boolean; + hasUnreadMentions: boolean; + hasUnreadMessagingMessage: boolean; + hasUnreadNotification: boolean; + hasUnreadSpecifiedNotes: boolean; + hideOnlineStatus: boolean; + injectFeaturedNote: boolean; + integrations: Record<string, any>; + isDeleted: boolean; + isExplorable: boolean; + mutedWords: string[][]; + mutingNotificationTypes: string[]; + noCrawle: boolean; + receiveAnnouncementEmail: boolean; + usePasswordLessLogin: boolean; + [other: string]: any; +}; + +export type DriveFile = { + id: ID; + createdAt: DateString; + isSensitive: boolean; + name: string; + thumbnailUrl: string; + url: string; + type: string; + size: number; + md5: string; + blurhash: string; + comment: string | null; + properties: Record<string, any>; +}; + +export type DriveFolder = TODO; + +export type GalleryPost = { + id: ID; + createdAt: DateString; + updatedAt: DateString; + userId: User['id']; + user: User; + title: string; + description: string | null; + fileIds: DriveFile['id'][]; + files: DriveFile[]; + isSensitive: boolean; + likedCount: number; + isLiked?: boolean; +}; + +export type Note = { + id: ID; + createdAt: DateString; + text: string | null; + cw: string | null; + user: User; + userId: User['id']; + reply?: Note; + replyId: Note['id']; + renote?: Note; + renoteId: Note['id']; + files: DriveFile[]; + fileIds: DriveFile['id'][]; + visibility: 'public' | 'home' | 'followers' | 'specified'; + visibleUserIds?: User['id'][]; + localOnly?: boolean; + myReaction?: string; + reactions: Record<string, number>; + renoteCount: number; + repliesCount: number; + poll?: { + expiresAt: DateString | null; + multiple: boolean; + choices: { + isVoted: boolean; + text: string; + votes: number; + }[]; + }; + emojis: { + name: string; + url: string; + }[]; + uri?: string; + url?: string; + isHidden?: boolean; +}; + +export type NoteReaction = { + id: ID; + createdAt: DateString; + user: UserLite; + type: string; +}; + +export type Notification = { + id: ID; + createdAt: DateString; + isRead: boolean; +} & ({ + type: 'reaction'; + reaction: string; + user: User; + userId: User['id']; + note: Note; +} | { + type: 'reply'; + user: User; + userId: User['id']; + note: Note; +} | { + type: 'renote'; + user: User; + userId: User['id']; + note: Note; +} | { + type: 'quote'; + user: User; + userId: User['id']; + note: Note; +} | { + type: 'mention'; + user: User; + userId: User['id']; + note: Note; +} | { + type: 'pollVote'; + user: User; + userId: User['id']; + note: Note; +} | { + type: 'follow'; + user: User; + userId: User['id']; +} | { + type: 'followRequestAccepted'; + user: User; + userId: User['id']; +} | { + type: 'receiveFollowRequest'; + user: User; + userId: User['id']; +} | { + type: 'groupInvited'; + invitation: UserGroup; + user: User; + userId: User['id']; +} | { + type: 'app'; + header?: string | null; + body: string; + icon?: string | null; +}); + +export type MessagingMessage = { + id: ID; + createdAt: DateString; + file: DriveFile | null; + fileId: DriveFile['id'] | null; + isRead: boolean; + reads: User['id'][]; + text: string | null; + user: User; + userId: User['id']; + recipient?: User | null; + recipientId: User['id'] | null; + group?: UserGroup | null; + groupId: UserGroup['id'] | null; +}; + +export type CustomEmoji = { + id: string; + name: string; + url: string; + category: string; + aliases: string[]; +}; + +export type LiteInstanceMetadata = { + maintainerName: string | null; + maintainerEmail: string | null; + version: string; + name: string | null; + uri: string; + description: string | null; + langs: string[]; + tosUrl: string | null; + repositoryUrl: string; + feedbackUrl: string; + disableRegistration: boolean; + disableLocalTimeline: boolean; + disableGlobalTimeline: boolean; + driveCapacityPerLocalUserMb: number; + driveCapacityPerRemoteUserMb: number; + emailRequiredForSignup: boolean; + enableHcaptcha: boolean; + hcaptchaSiteKey: string | null; + enableRecaptcha: boolean; + recaptchaSiteKey: string | null; + enableTurnstile: boolean; + turnstileSiteKey: string | null; + swPublickey: string | null; + themeColor: string | null; + mascotImageUrl: string | null; + bannerUrl: string | null; + errorImageUrl: string | null; + iconUrl: string | null; + backgroundImageUrl: string | null; + logoImageUrl: string | null; + maxNoteTextLength: number; + enableEmail: boolean; + enableTwitterIntegration: boolean; + enableGithubIntegration: boolean; + enableDiscordIntegration: boolean; + enableServiceWorker: boolean; + emojis: CustomEmoji[]; + defaultDarkTheme: string | null; + defaultLightTheme: string | null; + ads: { + id: ID; + ratio: number; + place: string; + url: string; + imageUrl: string; + }[]; + translatorAvailable: boolean; +}; + +export type DetailedInstanceMetadata = LiteInstanceMetadata & { + pinnedPages: string[]; + pinnedClipId: string | null; + cacheRemoteFiles: boolean; + requireSetup: boolean; + proxyAccountName: string | null; + features: Record<string, any>; +}; + +export type InstanceMetadata = LiteInstanceMetadata | DetailedInstanceMetadata; + +export type ServerInfo = { + machine: string; + cpu: { + model: string; + cores: number; + }; + mem: { + total: number; + }; + fs: { + total: number; + used: number; + }; +}; + +export type Stats = { + notesCount: number; + originalNotesCount: number; + usersCount: number; + originalUsersCount: number; + instances: number; + driveUsageLocal: number; + driveUsageRemote: number; +}; + +export type Page = { + id: ID; + createdAt: DateString; + updatedAt: DateString; + userId: User['id']; + user: User; + content: Record<string, any>[]; + variables: Record<string, any>[]; + title: string; + name: string; + summary: string | null; + hideTitleWhenPinned: boolean; + alignCenter: boolean; + font: string; + script: string; + eyeCatchingImageId: DriveFile['id'] | null; + eyeCatchingImage: DriveFile | null; + attachedFiles: any; + likedCount: number; + isLiked?: boolean; +}; + +export type PageEvent = { + pageId: Page['id']; + event: string; + var: any; + userId: User['id']; + user: User; +}; + +export type Announcement = { + id: ID; + createdAt: DateString; + updatedAt: DateString | null; + text: string; + title: string; + imageUrl: string | null; + isRead?: boolean; +}; + +export type Antenna = { + id: ID; + createdAt: DateString; + name: string; + keywords: string[][]; // TODO + excludeKeywords: string[][]; // TODO + src: 'home' | 'all' | 'users' | 'list' | 'group'; + userListId: ID | null; // TODO + userGroupId: ID | null; // TODO + users: string[]; // TODO + caseSensitive: boolean; + notify: boolean; + withReplies: boolean; + withFile: boolean; + hasUnreadNote: boolean; +}; + +export type App = TODO; + +export type AuthSession = { + id: ID; + app: App; + token: string; +}; + +export type Ad = TODO; + +export type Clip = TODO; + +export type NoteFavorite = { + id: ID; + createdAt: DateString; + noteId: Note['id']; + note: Note; +}; + +export type FollowRequest = { + id: ID; + follower: User; + followee: User; +}; + +export type Channel = { + id: ID; + // TODO +}; + +export type Following = { + id: ID; + createdAt: DateString; + followerId: User['id']; + followeeId: User['id']; +}; + +export type FollowingFolloweePopulated = Following & { + followee: UserDetailed; +}; + +export type FollowingFollowerPopulated = Following & { + follower: UserDetailed; +}; + +export type Blocking = { + id: ID; + createdAt: DateString; + blockeeId: User['id']; + blockee: UserDetailed; +}; + +export type Instance = { + id: ID; + caughtAt: DateString; + host: string; + usersCount: number; + notesCount: number; + followingCount: number; + followersCount: number; + driveUsage: number; + driveFiles: number; + latestRequestSentAt: DateString | null; + latestStatus: number | null; + latestRequestReceivedAt: DateString | null; + lastCommunicatedAt: DateString; + isNotResponding: boolean; + isSuspended: boolean; + softwareName: string | null; + softwareVersion: string | null; + openRegistrations: boolean | null; + name: string | null; + description: string | null; + maintainerName: string | null; + maintainerEmail: string | null; + iconUrl: string | null; + faviconUrl: string | null; + themeColor: string | null; + infoUpdatedAt: DateString | null; +}; + +export type Signin = { + id: ID; + createdAt: DateString; + ip: string; + headers: Record<string, any>; + success: boolean; +}; + +export type UserSorting = + | '+follower' + | '-follower' + | '+createdAt' + | '-createdAt' + | '+updatedAt' + | '-updatedAt'; +export type OriginType = 'combined' | 'local' | 'remote'; diff --git a/packages/misskey-js/src/index.ts b/packages/misskey-js/src/index.ts new file mode 100644 index 0000000000..f431d65cc7 --- /dev/null +++ b/packages/misskey-js/src/index.ts @@ -0,0 +1,26 @@ +import { Endpoints } from './api.types'; +import Stream, { Connection } from './streaming'; +import { Channels } from './streaming.types'; +import { Acct } from './acct'; +import * as consts from './consts'; + +export { + Endpoints, + Stream, + Connection as ChannelConnection, + Channels, + Acct, +}; + +export const permissions = consts.permissions; +export const notificationTypes = consts.notificationTypes; +export const noteVisibilities = consts.noteVisibilities; +export const mutedNoteReasons = consts.mutedNoteReasons; +export const ffVisibility = consts.ffVisibility; + +// api extractor not supported yet +//export * as api from './api'; +//export * as entities from './entities'; +import * as api from './api'; +import * as entities from './entities'; +export { api, entities }; diff --git a/packages/misskey-js/src/streaming.ts b/packages/misskey-js/src/streaming.ts new file mode 100644 index 0000000000..92b83192cf --- /dev/null +++ b/packages/misskey-js/src/streaming.ts @@ -0,0 +1,340 @@ +import autobind from 'autobind-decorator'; +import { EventEmitter } from 'eventemitter3'; +import ReconnectingWebsocket from 'reconnecting-websocket'; +import { BroadcastEvents, Channels } from './streaming.types'; + +export function urlQuery(obj: Record<string, string | number | boolean | undefined>): string { + const params = Object.entries(obj) + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .reduce((a, [k, v]) => (a[k] = v!, a), {} as Record<string, string | number | boolean>); + + return Object.entries(params) + .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`) + .join('&'); +} + +type AnyOf<T extends Record<any, any>> = T[keyof T]; + +type StreamEvents = { + _connected_: void; + _disconnected_: void; +} & BroadcastEvents; + +/** + * Misskey stream connection + */ +export default class Stream extends EventEmitter<StreamEvents> { + private stream: ReconnectingWebsocket; + public state: 'initializing' | 'reconnecting' | 'connected' = 'initializing'; + private sharedConnectionPools: Pool[] = []; + private sharedConnections: SharedConnection[] = []; + private nonSharedConnections: NonSharedConnection[] = []; + private idCounter = 0; + + constructor(origin: string, user: { token: string; } | null, options?: { + WebSocket?: any; + }) { + super(); + options = options || { }; + + const query = urlQuery({ + i: user?.token, + + // To prevent cache of an HTML such as error screen + _t: Date.now(), + }); + + const wsOrigin = origin.replace('http://', 'ws://').replace('https://', 'wss://'); + + this.stream = new ReconnectingWebsocket(`${wsOrigin}/streaming?${query}`, '', { + minReconnectionDelay: 1, // https://github.com/pladaria/reconnecting-websocket/issues/91 + WebSocket: options.WebSocket, + }); + this.stream.addEventListener('open', this.onOpen); + this.stream.addEventListener('close', this.onClose); + this.stream.addEventListener('message', this.onMessage); + } + + @autobind + private genId(): string { + return (++this.idCounter).toString(); + } + + @autobind + public useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): Connection<Channels[C]> { + if (params) { + return this.connectToChannel(channel, params); + } else { + return this.useSharedConnection(channel, name); + } + } + + @autobind + private useSharedConnection<C extends keyof Channels>(channel: C, name?: string): SharedConnection<Channels[C]> { + let pool = this.sharedConnectionPools.find(p => p.channel === channel); + + if (pool == null) { + pool = new Pool(this, channel, this.genId()); + this.sharedConnectionPools.push(pool); + } + + const connection = new SharedConnection(this, channel, pool, name); + this.sharedConnections.push(connection); + return connection; + } + + @autobind + public removeSharedConnection(connection: SharedConnection): void { + this.sharedConnections = this.sharedConnections.filter(c => c !== connection); + } + + @autobind + public removeSharedConnectionPool(pool: Pool): void { + this.sharedConnectionPools = this.sharedConnectionPools.filter(p => p !== pool); + } + + @autobind + private connectToChannel<C extends keyof Channels>(channel: C, params: Channels[C]['params']): NonSharedConnection<Channels[C]> { + const connection = new NonSharedConnection(this, channel, this.genId(), params); + this.nonSharedConnections.push(connection); + return connection; + } + + @autobind + public disconnectToChannel(connection: NonSharedConnection): void { + this.nonSharedConnections = this.nonSharedConnections.filter(c => c !== connection); + } + + /** + * Callback of when open connection + */ + @autobind + private onOpen(): void { + const isReconnect = this.state === 'reconnecting'; + + this.state = 'connected'; + this.emit('_connected_'); + + // チャンネル再接続 + if (isReconnect) { + for (const p of this.sharedConnectionPools) p.connect(); + for (const c of this.nonSharedConnections) c.connect(); + } + } + + /** + * Callback of when close connection + */ + @autobind + private onClose(): void { + if (this.state === 'connected') { + this.state = 'reconnecting'; + this.emit('_disconnected_'); + } + } + + /** + * Callback of when received a message from connection + */ + @autobind + private onMessage(message: { data: string; }): void { + const { type, body } = JSON.parse(message.data); + + if (type === 'channel') { + const id = body.id; + + let connections: Connection[]; + + connections = this.sharedConnections.filter(c => c.id === id); + + if (connections.length === 0) { + const found = this.nonSharedConnections.find(c => c.id === id); + if (found) { + connections = [found]; + } + } + + for (const c of connections) { + c.emit(body.type, body.body); + c.inCount++; + } + } else { + this.emit(type, body); + } + } + + /** + * Send a message to connection + */ + @autobind + public send(typeOrPayload: any, payload?: any): void { + const data = payload === undefined ? typeOrPayload : { + type: typeOrPayload, + body: payload, + }; + + this.stream.send(JSON.stringify(data)); + } + + /** + * Close this connection + */ + @autobind + public close(): void { + this.stream.close(); + } +} + +// TODO: これらのクラスを Stream クラスの内部クラスにすれば余計なメンバをpublicにしないで済むかも? +// もしくは @internal を使う? https://www.typescriptlang.org/tsconfig#stripInternal +class Pool { + public channel: string; + public id: string; + protected stream: Stream; + public users = 0; + private disposeTimerId: any; + private isConnected = false; + + constructor(stream: Stream, channel: string, id: string) { + this.channel = channel; + this.stream = stream; + this.id = id; + + this.stream.on('_disconnected_', this.onStreamDisconnected); + } + + @autobind + private onStreamDisconnected(): void { + this.isConnected = false; + } + + @autobind + public inc(): void { + if (this.users === 0 && !this.isConnected) { + this.connect(); + } + + this.users++; + + // タイマー解除 + if (this.disposeTimerId) { + clearTimeout(this.disposeTimerId); + this.disposeTimerId = null; + } + } + + @autobind + public dec(): void { + this.users--; + + // そのコネクションの利用者が誰もいなくなったら + if (this.users === 0) { + // また直ぐに再利用される可能性があるので、一定時間待ち、 + // 新たな利用者が現れなければコネクションを切断する + this.disposeTimerId = setTimeout(() => { + this.disconnect(); + }, 3000); + } + } + + @autobind + public connect(): void { + if (this.isConnected) return; + this.isConnected = true; + this.stream.send('connect', { + channel: this.channel, + id: this.id, + }); + } + + @autobind + private disconnect(): void { + this.stream.off('_disconnected_', this.onStreamDisconnected); + this.stream.send('disconnect', { id: this.id }); + this.stream.removeSharedConnectionPool(this); + } +} + +export abstract class Connection<Channel extends AnyOf<Channels> = any> extends EventEmitter<Channel['events']> { + public channel: string; + protected stream: Stream; + public abstract id: string; + + public name?: string; // for debug + public inCount = 0; // for debug + public outCount = 0; // for debug + + constructor(stream: Stream, channel: string, name?: string) { + super(); + + this.stream = stream; + this.channel = channel; + this.name = name; + } + + @autobind + public send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void { + this.stream.send('ch', { + id: this.id, + type: type, + body: body, + }); + + this.outCount++; + } + + public abstract dispose(): void; +} + +class SharedConnection<Channel extends AnyOf<Channels> = any> extends Connection<Channel> { + private pool: Pool; + + public get id(): string { + return this.pool.id; + } + + constructor(stream: Stream, channel: string, pool: Pool, name?: string) { + super(stream, channel, name); + + this.pool = pool; + this.pool.inc(); + } + + @autobind + public dispose(): void { + this.pool.dec(); + this.removeAllListeners(); + this.stream.removeSharedConnection(this); + } +} + +class NonSharedConnection<Channel extends AnyOf<Channels> = any> extends Connection<Channel> { + public id: string; + protected params: Channel['params']; + + constructor(stream: Stream, channel: string, id: string, params: Channel['params']) { + super(stream, channel); + + this.params = params; + this.id = id; + + this.connect(); + } + + @autobind + public connect(): void { + this.stream.send('connect', { + channel: this.channel, + id: this.id, + params: this.params, + }); + } + + @autobind + public dispose(): void { + this.removeAllListeners(); + this.stream.send('disconnect', { id: this.id }); + this.stream.disconnectToChannel(this); + } +} diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts new file mode 100644 index 0000000000..f711b8d6eb --- /dev/null +++ b/packages/misskey-js/src/streaming.types.ts @@ -0,0 +1,152 @@ +import { Antenna, CustomEmoji, DriveFile, MeDetailed, MessagingMessage, Note, Notification, PageEvent, User, UserGroup } from './entities'; + +type FIXME = any; + +export type Channels = { + main: { + params: null; + events: { + notification: (payload: Notification) => void; + mention: (payload: Note) => void; + reply: (payload: Note) => void; + renote: (payload: Note) => void; + follow: (payload: User) => void; // 自分が他人をフォローしたとき + followed: (payload: User) => void; // 他人が自分をフォローしたとき + unfollow: (payload: User) => void; // 自分が他人をフォロー解除したとき + meUpdated: (payload: MeDetailed) => void; + pageEvent: (payload: PageEvent) => void; + urlUploadFinished: (payload: { marker: string; file: DriveFile; }) => void; + readAllNotifications: () => void; + unreadNotification: (payload: Notification) => void; + unreadMention: (payload: Note['id']) => void; + readAllUnreadMentions: () => void; + unreadSpecifiedNote: (payload: Note['id']) => void; + readAllUnreadSpecifiedNotes: () => void; + readAllMessagingMessages: () => void; + messagingMessage: (payload: MessagingMessage) => void; + unreadMessagingMessage: (payload: MessagingMessage) => void; + readAllAntennas: () => void; + unreadAntenna: (payload: Antenna) => void; + readAllAnnouncements: () => void; + readAllChannels: () => void; + unreadChannel: (payload: Note['id']) => void; + myTokenRegenerated: () => void; + reversiNoInvites: () => void; + reversiInvited: (payload: FIXME) => void; + signin: (payload: FIXME) => void; + registryUpdated: (payload: { + scope?: string[]; + key: string; + value: any | null; + }) => void; + driveFileCreated: (payload: DriveFile) => void; + readAntenna: (payload: Antenna) => void; + }; + receives: null; + }; + homeTimeline: { + params: null; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; + localTimeline: { + params: null; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; + hybridTimeline: { + params: null; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; + globalTimeline: { + params: null; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; + messaging: { + params: { + otherparty?: User['id'] | null; + group?: UserGroup['id'] | null; + }; + events: { + message: (payload: MessagingMessage) => void; + deleted: (payload: MessagingMessage['id']) => void; + read: (payload: MessagingMessage['id'][]) => void; + typers: (payload: User[]) => void; + }; + receives: { + read: { + id: MessagingMessage['id']; + }; + }; + }; + serverStats: { + params: null; + events: { + stats: (payload: FIXME) => void; + }; + receives: { + requestLog: { + id: string | number; + length: number; + }; + }; + }; + queueStats: { + params: null; + events: { + stats: (payload: FIXME) => void; + }; + receives: { + requestLog: { + id: string | number; + length: number; + }; + }; + }; +}; + +export type NoteUpdatedEvent = { + id: Note['id']; + type: 'reacted'; + body: { + reaction: string; + userId: User['id']; + }; +} | { + id: Note['id']; + type: 'unreacted'; + body: { + reaction: string; + userId: User['id']; + }; +} | { + id: Note['id']; + type: 'deleted'; + body: { + deletedAt: string; + }; +} | { + id: Note['id']; + type: 'pollVoted'; + body: { + choice: number; + userId: User['id']; + }; +}; + +export type BroadcastEvents = { + noteUpdated: (payload: NoteUpdatedEvent) => void; + emojiAdded: (payload: { + emoji: CustomEmoji; + }) => void; +}; |