From 41971732d07c6c9ec2de983545ce7e5930745615 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 13 Mar 2023 10:47:38 +0000 Subject: [PATCH] Bring in node-signald --- packages/node-signald/.gitignore | 2 + packages/node-signald/CHANGELOG.md | 9 + packages/node-signald/example/example.ts | 73 + packages/node-signald/example/package.json | 20 + packages/node-signald/example/tsconfig.json | 27 + packages/node-signald/package.json | 44 + packages/node-signald/src/api.ts | 21 + packages/node-signald/src/error.ts | 52 + packages/node-signald/src/generated.ts | 2703 +++++++++++++++++++ packages/node-signald/src/index.ts | 5 + packages/node-signald/src/util.ts | 277 ++ packages/node-signald/tsconfig.json | 12 + packages/node-signald/util/generate.js | 229 ++ 13 files changed, 3474 insertions(+) create mode 100644 packages/node-signald/.gitignore create mode 100644 packages/node-signald/CHANGELOG.md create mode 100644 packages/node-signald/example/example.ts create mode 100644 packages/node-signald/example/package.json create mode 100644 packages/node-signald/example/tsconfig.json create mode 100644 packages/node-signald/package.json create mode 100644 packages/node-signald/src/api.ts create mode 100644 packages/node-signald/src/error.ts create mode 100644 packages/node-signald/src/generated.ts create mode 100644 packages/node-signald/src/index.ts create mode 100644 packages/node-signald/src/util.ts create mode 100644 packages/node-signald/tsconfig.json create mode 100644 packages/node-signald/util/generate.js diff --git a/packages/node-signald/.gitignore b/packages/node-signald/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/packages/node-signald/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/node-signald/CHANGELOG.md b/packages/node-signald/CHANGELOG.md new file mode 100644 index 0000000..55358c9 --- /dev/null +++ b/packages/node-signald/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +### [0.0.3](https://gitlab.com/digiresilience/link/node-signald/compare/v0.0.2...v0.0.3) (2022-01-03) + +### [0.0.2](https://gitlab.com/digiresilience/link/node-signald/compare/v0.0.1...v0.0.2) (2021-10-08) + +### 0.0.1 (2021-10-08) diff --git a/packages/node-signald/example/example.ts b/packages/node-signald/example/example.ts new file mode 100644 index 0000000..7bfdec7 --- /dev/null +++ b/packages/node-signald/example/example.ts @@ -0,0 +1,73 @@ +import { SignaldAPI, CaptchaRequiredException } from "node-signald"; +import * as process from "process"; +import * as prompt from "prompt"; + +const SOCKETFILE = + process.env.SIGNALD_SOCKET || "/run/user/1000/signald/signald.sock"; + +const validate = () => { + if (!process.env.NUMBER) + throw new Error( + "Please set the NUMBER env var to the number you want to test with." + ); +}; +const main = async () => { + validate(); + const signald = new SignaldAPI(); + await signald.connectAsync(SOCKETFILE); + try { + await signald.register(process.env.NUMBER); + } catch (e) { + console.log("GOT A error", e.name); + if (e.name === "CaptchaRequiredException") { + console.log(` + +CAPTCHA REQUIRED +----------------- + +1. Visit https://signalcaptchas.org/registration/generate.html +2. Appease the machine +3. Bring back your captcha gobbly-gook here +`); + console.log("captcha required"); + + prompt.start(); + const { captcha } = await prompt.get(["captcha"]); + await signald.register(process.env.NUMBER, false, captcha); + } else { + console.error(e); + } + } + + const { code } = await prompt.get(["code"]); + await signald.verify(process.env.NUMBER, code); +}; + +const main2 = async () => { + validate(); + const signald = new SignaldAPI(); + await signald.connectAsync(SOCKETFILE); + let result = await signald.listAccounts(); + console.log(JSON.stringify(result)); + signald.on("messagev0", (envelope) => { + const source = envelope.source.number; + const body = envelope.dataMessage.body; + const when = new Date(envelope.timestamp).toDateString(); + console.log(`${when} [${source}]: ${body}`); + }); + + await Promise.all( + result.accounts.map( + async (account: any) => await signald.requestSync(account.address.uuid) + ) + ); + + await Promise.all( + result.accounts.map( + async (account: any) => await signald.subscribev0(account.address.uuid) + ) + ); +}; + +// main(); +main2(); diff --git a/packages/node-signald/example/package.json b/packages/node-signald/example/package.json new file mode 100644 index 0000000..b008f11 --- /dev/null +++ b/packages/node-signald/example/package.json @@ -0,0 +1,20 @@ +{ + "name": "node-signald-example", + "version": "0.0.1", + "description": "example usage", + "main": "index.js", + "license": "AGPL-3.0-only", + "private": false, + "scripts": { + "example": "node --unhandled-rejections=strict -r ts-node/register --unhandled-rejections=strict example.ts", + "linklib": "cd ../ && yarn build && yarn link && cd example && yarn link node-signald" + }, + "devDependencies": { + "ts-node": "^10.0.0", + "typescript": "^4.3.2" + }, + "dependencies": { + "prompt": "^1.1.0", + "uuid": "^8.3.2" + } +} diff --git a/packages/node-signald/example/tsconfig.json b/packages/node-signald/example/tsconfig.json new file mode 100644 index 0000000..8f981f3 --- /dev/null +++ b/packages/node-signald/example/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "incremental": true, + "module": "commonjs", + "target": "es2019", + "lib": ["es2020"], + "declaration": true, + "esModuleInterop": true, + "outDir": "./dist", + "types": ["node", "jest"], + + "moduleResolution": "node", + "inlineSourceMap": true, + "resolveJsonModule": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "traceResolution": false, + "listEmittedFiles": false, + "listFiles": false, + "pretty": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["example.ts"] +} diff --git a/packages/node-signald/package.json b/packages/node-signald/package.json new file mode 100644 index 0000000..35b9a19 --- /dev/null +++ b/packages/node-signald/package.json @@ -0,0 +1,44 @@ +{ + "name": "@digiresilience/node-signald", + "version": "1.0.0", + "description": "signald bindings for node.js", + "author": "Abel Luck ", + "license": "AGPL-3.0-only", + "private": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "/dist" + ], + "engines": { + "node": ">=12.9.0" + }, + "scripts": { + "build": "tsc --build --verbose", + "watch": "tsc --build --verbose --watch", + "generate": "node util/generate.js && prettier src/generated.ts -w --loglevel error && yarn build", + "doc": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --out dist/docs", + "fix": "echo n/a", + "lint": "echo n/a", + "fix:lint": "echo n/a", + "fmt": "prettier \"src/**/*.ts\" --write", + "test": "echo n/a" + }, + "devDependencies": { + "@types/backoff": "^2.5.2", + "camelcase": "^6.2.0", + "typedoc": "^0.22.5", + "tsconfig-link": "*", + "eslint-config-link": "*", + "jest-config-link": "*", + "babel-preset-link": "*" + }, + "dependencies": { + "backoff": "^2.5.0", + "camelcase-keys": "^7.0.1", + "eventemitter3": "^4.0.7", + "snakecase-keys": "^5.1.0", + "ts-custom-error": "^3.2.0", + "uuid": "^8.3.2" + } +} diff --git a/packages/node-signald/src/api.ts b/packages/node-signald/src/api.ts new file mode 100644 index 0000000..7e0a4c1 --- /dev/null +++ b/packages/node-signald/src/api.ts @@ -0,0 +1,21 @@ +import { SignaldGeneratedApi, JsonMessageEnvelopev1 } from "./generated"; + +export class SignaldAPI extends SignaldGeneratedApi { + constructor() { + super(); + } + + public async subscribev0(account: string): Promise { + return this.getResponse({ + type: "subscribe", + username: account, + }) as Promise; + } + + public async unsubscribev0(account: string): Promise { + return this.getResponse({ + type: "unsubscribe", + username: account, + }) as Promise; + } +} diff --git a/packages/node-signald/src/error.ts b/packages/node-signald/src/error.ts new file mode 100644 index 0000000..c374d00 --- /dev/null +++ b/packages/node-signald/src/error.ts @@ -0,0 +1,52 @@ +import { CustomError } from "ts-custom-error"; + +export class SignaldError extends CustomError { + public msg: string; + constructor(public errorType: string, public message: string) { + super(`[${errorType}]: ${message}`); + this.errorType = errorType; + this.message = `[${errorType}]: ${message}`; + this.msg = `${message}`; + } +} + +export class CaptchaRequiredException extends SignaldError { + constructor(errorType: string, message: string) { + super(errorType, message); + } +} + +const isBoolean = (v) => "boolean" === typeof v; +const isString = (v) => typeof v === "string" || v instanceof String; + +export const throwOnError = (response: any) => { + if (response.type === "profile_not_available") + throw new SignaldError("profile_not_available", response.data); + + const { data } = response; + let error; + if (Array.isArray(data)) { + error = response.error || false; + } else if (isString(data)) { + error = false; + } else { + error = response.error || data?.error || false; + } + if (error) { + let type_, msg; + if (isBoolean(error)) { + type_ = response.type; + msg = data.message || ""; + let req = data.request || ""; + msg += req.toString(); + } else { + type_ = error.type; + msg = error.message || ""; + msg += (error.validationResults || [""]).join(""); + } + if (!type_) type_ = response.error_type; + if (type_ === "CaptchaRequired") + throw new CaptchaRequiredException(type_, msg); + throw new SignaldError(type_, msg); + } +}; diff --git a/packages/node-signald/src/generated.ts b/packages/node-signald/src/generated.ts new file mode 100644 index 0000000..6242243 --- /dev/null +++ b/packages/node-signald/src/generated.ts @@ -0,0 +1,2703 @@ +/* Generated by the output of the 'protocol' command of signald + Date: Mon Jan 03 2022 09:59:39 GMT+0000 (Coordinated Universal Time) + Version: 0.15.0-40-56a6c9d2 + */ +import { JSONTransport } from "./util"; + +export type JsonAccountListv0 = { + accounts?: JsonAccountv0[]; +}; + +export type JsonMessageEnvelopev0 = { + username?: string; + uuid?: string; + source?: JsonAddressv0; + sourceDevice?: number; + type?: string; + + /** + this field is no longer available and will never be populated. + */ + relay?: string; + timestamp?: number; + timestampISO?: string; + serverTimestamp?: number; + serverDeliveredTimestamp?: number; + hasLegacyMessage?: boolean; + hasContent?: boolean; + isUnidentifiedSender?: boolean; + dataMessage?: JsonDataMessagev0; + syncMessage?: JsonSyncMessagev0; + callMessage?: JsonCallMessagev0; + receipt?: JsonReceiptMessagev0; + typing?: JsonTypingMessagev0; +}; + +export type JsonAccountv0 = { + deviceId?: number; + username?: string; + filename?: string; + uuid?: string; + registered?: boolean; + has_keys?: boolean; + subscribed?: boolean; +}; + +export type JsonAddressv0 = { + number?: string; + uuid?: string; + relay?: string; +}; + +export type JsonDataMessagev0 = { + /** + the timestamp that the message was sent at, according to the sender's device. This is used to uniquely identify this message for things like reactions and quotes.. + Example: 1615576442475 + */ + timestamp?: number; + + /** + files attached to the incoming message. + */ + attachments?: JsonAttachmentv0[]; + + /** + the text body of the incoming message.. + Example: "hello" + */ + body?: string; + + /** + if the incoming message was sent to a v1 group, information about that group will be here. + */ + group?: JsonGroupInfov0; + + /** + is the incoming message was sent to a v2 group, basic identifying information about that group will be here. For full information, use list_groups. + */ + groupV2?: JsonGroupV2Infov0; + endSession?: boolean; + + /** + the expiry timer on the incoming message. Clients should delete records of the message within this number of seconds. + */ + expiresInSeconds?: number; + profileKeyUpdate?: boolean; + + /** + if the incoming message is a quote or reply to another message, this will contain information about that message. + */ + quote?: JsonQuotev0; + + /** + if the incoming message has a shared contact, the contact's information will be here. + */ + contacts?: SharedContactv0[]; + + /** + if the incoming message has a link preview, information about that preview will be here. + */ + previews?: JsonPreviewv0[]; + + /** + if the incoming message is a sticker, information about the sicker will be here. + */ + sticker?: JsonStickerv0; + + /** + indicates the message is a view once message. View once messages typically include no body and a single image attachment. Official Signal clients will prevent the user from saving the image, and once the user has viewed the image once they will destroy the image.. + */ + viewOnce?: boolean; + + /** + if the message adds or removes a reaction to another message, this will indicate what change is being made. + */ + reaction?: JsonReactionv0; + + /** + if the inbound message is deleting a previously sent message, indicates which message should be deleted. + */ + remoteDelete?: RemoteDeletev0; + + /** + list of mentions in the message. + */ + mentions?: JsonMentionv0[]; +}; + +export type JsonSyncMessagev0 = { + sent?: JsonSentTranscriptMessagev0; + contacts?: JsonAttachmentv0; + contactsComplete?: boolean; + groups?: JsonAttachmentv0; + blockedList?: JsonBlockedListMessagev0; + request?: string; + readMessages?: JsonReadMessagev0[]; + viewOnceOpen?: JsonViewOnceOpenMessagev0; + verified?: JsonVerifiedMessagev0; + configuration?: ConfigurationMessagev0; + stickerPackOperations?: JsonStickerPackOperationMessagev0[]; + fetchType?: string; + messageRequestResponse?: JsonMessageRequestResponseMessagev0; +}; + +export type JsonCallMessagev0 = { + offerMessage?: OfferMessagev0; + answerMessage?: AnswerMessagev0; + busyMessage?: BusyMessagev0; + hangupMessage?: HangupMessagev0; + iceUpdateMessages?: IceUpdateMessagev0[]; + destinationDeviceId?: number; + isMultiRing?: boolean; +}; + +export type JsonReceiptMessagev0 = { + type?: string; + timestamps?: number[]; + when?: number; +}; + +export type JsonTypingMessagev0 = { + action?: string; + timestamp?: number; + groupId?: string; +}; + +export type JsonAttachmentv0 = { + contentType?: string; + id?: string; + size?: number; + storedFilename?: string; + filename?: string; + customFilename?: string; + caption?: string; + width?: number; + height?: number; + voiceNote?: boolean; + key?: string; + digest?: string; + blurhash?: string; +}; + +export type JsonGroupInfov0 = { + groupId?: string; + members?: JsonAddressv0[]; + name?: string; + type?: string; + avatarId?: number; +}; + +export type JsonGroupV2Infov0 = { + id?: string; + revision?: number; + title?: string; + description?: string; + + /** + path to the group's avatar on local disk, if available. + Example: "/var/lib/signald/avatars/group-EdSqI90cS0UomDpgUXOlCoObWvQOXlH5G3Z2d3f4ayE=" + */ + avatar?: string; + timer?: number; + members?: JsonAddressv0[]; + pendingMembers?: JsonAddressv0[]; + requestingMembers?: JsonAddressv0[]; + + /** + the signal.group link, if applicable. + */ + inviteLink?: string; + + /** + current access control settings for this group. + */ + accessControl?: GroupAccessControlv0; + + /** + detailed member list. + */ + memberDetail?: GroupMemberv0[]; + + /** + detailed pending member list. + */ + pendingMemberDetail?: GroupMemberv0[]; +}; + +/** + A quote is a reply to a previous message. ID is the sent time of the message being replied to + */ +export type JsonQuotev0 = { + /** + the client timestamp of the message being quoted. + Example: 1615576442475 + */ + id?: number; + + /** + the author of the message being quoted. + */ + author?: JsonAddressv0; + + /** + the body of the message being quoted. + Example: "hey  what's up?" + */ + text?: string; + + /** + list of files attached to the quoted message. + */ + attachments?: JsonQuotedAttachmentv0[]; + + /** + list of mentions in the quoted message. + */ + mentions?: JsonMentionv0[]; +}; + +export type SharedContactv0 = { + name?: Namev0; + avatar?: Optionalv0; + phone?: Optionalv0; + email?: Optionalv0; + address?: Optionalv0; + organization?: Optionalv0; +}; + +export type JsonPreviewv0 = { + url?: string; + title?: string; + attachment?: JsonAttachmentv0; +}; + +export type JsonStickerv0 = { + packID?: string; + packKey?: string; + stickerID?: number; + attachment?: JsonAttachmentv0; + image?: string; +}; + +export type JsonReactionv0 = { + /** + the emoji to react with. + Example: "👍" + */ + emoji?: string; + + /** + set to true to remove the reaction. requires emoji be set to previously reacted emoji. + */ + remove?: boolean; + + /** + the author of the message being reacted to. + */ + targetAuthor?: JsonAddressv0; + + /** + the client timestamp of the message being reacted to. + Example: 1615576442475 + */ + targetSentTimestamp?: number; +}; + +export type RemoteDeletev0 = { + targetSentTimestamp?: number; +}; + +export type JsonMentionv0 = { + /** + The UUID of the account being mentioned. + Example: "aeed01f0-a234-478e-8cf7-261c283151e7" + */ + uuid?: string; + + /** + The number of characters in that the mention starts at. Note that due to a quirk of how signald encodes JSON, if this value is 0 (for example if the first character in the message is the mention) the field won't show up.. + Example: 4 + */ + start?: number; + + /** + The length of the mention represented in the message. Seems to always be 1 but included here in case that changes.. + Example: 1 + */ + length?: number; +}; + +export type JsonSentTranscriptMessagev0 = { + destination?: JsonAddressv0; + timestamp?: number; + expirationStartTimestamp?: number; + message?: JsonDataMessagev0; + unidentifiedStatus?: Record; + isRecipientUpdate?: boolean; +}; + +export type JsonBlockedListMessagev0 = { + addresses?: JsonAddressv0[]; + groupIds?: string[]; +}; + +export type JsonReadMessagev0 = { + sender?: JsonAddressv0; + timestamp?: number; +}; + +export type JsonViewOnceOpenMessagev0 = { + sender?: JsonAddressv0; + timestamp?: number; +}; + +export type JsonVerifiedMessagev0 = { + destination?: JsonAddressv0; + identityKey?: string; + verified?: string; + timestamp?: number; +}; + +export type ConfigurationMessagev0 = { + readReceipts?: Optionalv0; + unidentifiedDeliveryIndicators?: Optionalv0; + typingIndicators?: Optionalv0; + linkPreviews?: Optionalv0; +}; + +export type JsonStickerPackOperationMessagev0 = { + packID?: string; + packKey?: string; + type?: string; +}; + +export type JsonMessageRequestResponseMessagev0 = { + person?: JsonAddressv0; + groupId?: string; + type?: string; +}; + +export type OfferMessagev0 = { + id?: number; + sdp?: string; + type?: Typev0; + opaque?: string; +}; + +export type AnswerMessagev0 = { + id?: number; + sdp?: string; + opaque?: string; +}; + +export type BusyMessagev0 = { + id?: number; +}; + +export type HangupMessagev0 = { + id?: number; + type?: Typev0; + deviceId?: number; + legacy?: boolean; +}; + +export type IceUpdateMessagev0 = { + id?: number; + opaque?: string; + sdp?: string; +}; + +export type JsonQuotedAttachmentv0 = { + contentType?: string; + fileName?: string; + thumbnail?: JsonAttachmentv0; +}; + +/** + group access control settings. Options for each controlled action are: UNKNOWN, ANY, MEMBER, ADMINISTRATOR, UNSATISFIABLE and UNRECOGNIZED + */ +export type GroupAccessControlv0 = { + /** + UNSATISFIABLE when the group link is disabled, ADMINISTRATOR when the group link is enabled but an administrator must approve new members, ANY when the group link is enabled and no approval is required. + Example: "ANY" + */ + link?: string; + + /** + who can edit group info. + */ + attributes?: string; + + /** + who can add members. + */ + members?: string; +}; + +export type GroupMemberv0 = { + uuid?: string; + + /** + possible values are: UNKNOWN, DEFAULT, ADMINISTRATOR and UNRECOGNIZED. + Example: "DEFAULT" + */ + role?: string; + joined_revision?: number; +}; + +export type Namev0 = { + display?: Optionalv0; + given?: Optionalv0; + family?: Optionalv0; + prefix?: Optionalv0; + suffix?: Optionalv0; + middle?: Optionalv0; +}; + +export type Optionalv0 = { + present?: boolean; +}; + +export type Typev0 = {}; + +/** + prior attempt to indicate signald connectivity state. WebSocketConnectionState messages will be delivered at the same time as well as in other parts of the websocket lifecycle. + */ +export type ListenerStatev1 = { + connected?: boolean; +}; + +export type IncomingMessagev1 = { + account?: string; + source?: JsonAddressv1; + type?: string; + timestamp?: number; + source_device?: number; + server_receiver_timestamp?: number; + server_deliver_timestamp?: number; + has_legacy_message?: boolean; + has_content?: boolean; + unidentified_sender?: boolean; + data_message?: JsonDataMessagev1; + sync_message?: JsonSyncMessagev1; + call_message?: CallMessagev1; + receipt_message?: ReceiptMessagev1; + typing_message?: TypingMessagev1; + server_guid?: string; +}; + +/** + indicates when the websocket connection state to the signal server has changed + */ +export type WebSocketConnectionStatev1 = { + /** + One of: DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING, DISCONNECTING, AUTHENTICATION_FAILED, FAILED. + */ + state?: string; + + /** + One of: UNIDENTIFIED, IDENTIFIED. + */ + socket?: string; +}; + +export type JsonMessageEnvelopev1 = { + username?: string; + uuid?: string; + source?: JsonAddressv1; + sourceDevice?: number; + type?: string; + relay?: string; + timestamp?: number; + timestampISO?: string; + serverTimestamp?: number; + serverDeliveredTimestamp?: number; + hasLegacyMessage?: boolean; + hasContent?: boolean; + isUnidentifiedSender?: boolean; + dataMessage?: JsonDataMessagev1; + syncMessage?: JsonSyncMessagev1; + callMessage?: JsonCallMessagev0; + receipt?: JsonReceiptMessagev0; + typing?: JsonTypingMessagev0; +}; + +/** + Wraps all incoming messages sent to the client after a v1 subscribe request is issued + */ +export type ClientMessageWrapperv1 = { + /** + the type of object to expect in the `data` field. + */ + type?: string; + + /** + the version of the object in the `data` field. + */ + version?: string; + + /** + the incoming object. The structure will vary from message to message, see `type` and `version` fields. + */ + data?: Object; + + /** + true if the incoming message represents an error. + */ + error?: boolean; + + /** + the account this message is from. + */ + account?: string; +}; + +export type ProtocolInvalidMessageErrorv1 = { + sender?: string; + message?: string; + sender_device?: number; + content_hint?: number; + group_id?: string; +}; + +export type UntrustedIdentityErrorv1 = { + identifier?: string; + message?: string; + identity_key?: IdentityKeyv1; +}; + +export type DuplicateMessageErrorv1 = { + message?: string; +}; + +export type SendRequestv1 = { + username: string; + recipientAddress?: JsonAddressv1; + recipientGroupId?: string; + messageBody?: string; + attachments?: JsonAttachmentv0[]; + quote?: JsonQuotev1; + timestamp?: number; + mentions?: JsonMentionv1[]; + previews?: JsonPreviewv1[]; + + /** + Optionally set to a sub-set of group members. Ignored if recipientGroupId isn't specified. + */ + members?: JsonAddressv1[]; +}; + +export type SendResponsev1 = { + results?: JsonSendMessageResultv1[]; + timestamp?: number; +}; + +export type NoSuchAccountErrorv1 = { + account?: string; + message?: string; +}; + +export type ServerNotFoundErrorv1 = { + uuid?: string; + message?: string; +}; + +export type InvalidProxyErrorv1 = { + message?: string; +}; + +export type NoSendPermissionErrorv1 = { + message?: string; +}; + +export type InvalidAttachmentErrorv1 = { + filename?: string; + message?: string; +}; + +/** + an internal error in signald has occurred. typically these are things that "should never happen" such as issues saving to the local disk, but it is also the default error type and may catch some things that should have their own error type. If you find tht your code is depending on the exception list for any particular behavior, please file an issue so we can pull those errors out to a separate error type: https://gitlab.com/signald/signald/-/issues/new + */ +export type InternalErrorv1 = { + exceptions?: string[]; + message?: string; +}; + +export type InvalidRequestErrorv1 = { + message?: string; +}; + +export type UnknownGroupErrorv1 = { + message?: string; +}; + +export type RateLimitErrorv1 = { + message?: string; +}; + +export type InvalidRecipientErrorv1 = { + message?: string; +}; + +/** + react to a previous message + */ +export type ReactRequestv1 = { + username: string; + recipientAddress?: JsonAddressv1; + recipientGroupId?: string; + reaction: JsonReactionv1; + timestamp?: number; + + /** + Optionally set to a sub-set of group members. Ignored if recipientGroupId isn't specified. + */ + members?: JsonAddressv1[]; +}; + +export type VersionRequestv1 = {}; + +export type JsonVersionMessagev1 = { + name?: string; + version?: string; + branch?: string; + commit?: string; +}; + +/** + Accept a v2 group invitation. Note that you must have a profile name set to join groups. + */ +export type AcceptInvitationRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + groupID: string; +}; + +/** + Information about a Signal group + */ +export type JsonGroupV2Infov1 = { + id?: string; + revision?: number; + title?: string; + description?: string; + + /** + path to the group's avatar on local disk, if available. + Example: "/var/lib/signald/avatars/group-EdSqI90cS0UomDpgUXOlCoObWvQOXlH5G3Z2d3f4ayE=" + */ + avatar?: string; + timer?: number; + members?: JsonAddressv1[]; + pendingMembers?: JsonAddressv1[]; + requestingMembers?: JsonAddressv1[]; + + /** + the signal.group link, if applicable. + */ + inviteLink?: string; + + /** + current access control settings for this group. + */ + accessControl?: GroupAccessControlv1; + + /** + detailed member list. + */ + memberDetail?: GroupMemberv1[]; + + /** + detailed pending member list. + */ + pendingMemberDetail?: GroupMemberv1[]; + + /** + indicates if the group is an announcements group. Only admins are allowed to send messages to announcements groups. Options are UNKNOWN, ENABLED or DISABLED. + */ + announcements?: string; +}; + +export type OwnProfileKeyDoesNotExistErrorv1 = { + message?: string; +}; + +/** + approve a request to join a group + */ +export type ApproveMembershipRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + groupID: string; + + /** + list of requesting members to approve. + */ + members: JsonAddressv1[]; +}; + +export type GroupVerificationErrorv1 = { + message?: string; +}; + +/** + Query the server for the latest state of a known group. If no account in signald is a member of the group (anymore), an error with error_type: 'UnknownGroupError' is returned. + */ +export type GetGroupRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + groupID: string; + + /** + the latest known revision, default value (-1) forces fetch from server. + */ + revision?: number; +}; + +export type InvalidGroupStateErrorv1 = { + message?: string; +}; + +/** + list all linked devices on a Signal account + */ +export type GetLinkedDevicesRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; +}; + +export type LinkedDevicesv1 = { + devices?: DeviceInfov1[]; +}; + +/** + Join a group using the a signal.group URL. Note that you must have a profile name set to join groups. + */ +export type JoinGroupRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + + /** + The signal.group URL. + Example: "https://signal.group/#CjQKINH_GZhXhfifTcnBkaKTNRxW-hHKnGSq-cJNyPVqHRp8EhDUB7zjKNEl0NaULhsqJCX3" + */ + uri: string; +}; + +export type JsonGroupJoinInfov1 = { + groupID?: string; + title?: string; + description?: string; + memberCount?: number; + + /** + The access level required in order to join the group from the invite link, as an AccessControl.AccessRequired enum from the upstream Signal groups.proto file. This is UNSATISFIABLE (4) when the group link is disabled; ADMINISTRATOR (3) when the group link is enabled, but an administrator must approve new members; and ANY (1) when the group link is enabled and no approval is required. See theGroupAccessControl structure and the upstream enum ordinals.. + */ + addFromInviteLink?: number; + + /** + The Group V2 revision. This is incremented by clients whenever they update group information, and it is often used by clients to determine if the local group state is out-of-date with the server's revision.. + Example: 5 + */ + revision?: number; + + /** + Whether the account is waiting for admin approval in order to be added to the group.. + */ + pendingAdminApproval?: boolean; +}; + +export type InvalidInviteURIErrorv1 = { + message?: string; +}; + +export type GroupNotActiveErrorv1 = { + message?: string; +}; + +/** + Remove a linked device from the Signal account. Only allowed when the local device id is 1 + */ +export type RemoveLinkedDeviceRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + + /** + the ID of the device to unlink. + Example: 3 + */ + deviceId: number; +}; + +/** + modify a group. Note that only one modification action may be performed at once + */ +export type UpdateGroupRequestv1 = { + /** + The identifier of the account to interact with. + Example: "+12024561414" + */ + account: string; + + /** + the ID of the group to update. + Example: "EdSqI90cS0UomDpgUXOlCoObWvQOXlH5G3Z2d3f4ayE=" + */ + groupID: string; + title?: string; + + /** + A new group description. Set to empty string to remove an existing description.. + Example: "A club for running in Parkdale" + */ + description?: string; + avatar?: string; + + /** + update the group timer.. + */ + updateTimer?: number; + addMembers?: JsonAddressv1[]; + removeMembers?: JsonAddressv1[]; + updateRole?: GroupMemberv1; + + /** + note that only one of the access controls may be updated per request. + */ + updateAccessControl?: GroupAccessControlv1; + + /** + regenerate the group link password, invalidating the old one. + */ + resetLink?: boolean; + + /** + ENABLED to only allow admins to post messages, DISABLED to allow anyone to post. + */ + announcements?: string; +}; + +/** + A generic type that is used when the group version is not known + */ +export type GroupInfov1 = { + v1?: JsonGroupInfov1; + v2?: JsonGroupV2Infov1; +}; + +export type SetProfilev1 = { + /** + The phone number of the account to use. + Example: "+12024561414" + */ + account: string; + + /** + Change the profile name. + Example: "signald user" + */ + name?: string; + + /** + Path to new profile avatar file. If unset or null, unset the profile avatar. + Example: "/tmp/image.jpg" + */ + avatarFile?: string; + + /** + Change the 'about' profile field. + */ + about?: string; + + /** + Change the profile emoji. + */ + emoji?: string; + + /** + Change the profile payment address. Payment address must be a *base64-encoded* MobileCoin address. Note that this is not the traditional MobileCoin address encoding, which is custom. Clients are responsible for converting between MobileCoin's custom base58 on the user-facing side and base64 encoding on the signald side.. + */ + mobilecoin_address?: string; + + /** + configure visible badge IDs. + */ + visible_badge_ids?: string[]; +}; + +export type InvalidBase64Errorv1 = { + message?: string; +}; + +/** + Resolve a partial JsonAddress with only a number or UUID to one with both. Anywhere that signald accepts a JsonAddress will except a partial, this is a convenience function for client authors, mostly because signald doesn't resolve all the partials it returns. + */ +export type ResolveAddressRequestv1 = { + /** + The signal account to use. + Example: "+12024561414" + */ + account: string; + + /** + The partial address, missing fields. + */ + partial: JsonAddressv1; +}; + +export type JsonAddressv1 = { + /** + An e164 phone number, starting with +. Currently the only available user-facing Signal identifier.. + Example: "+13215551234" + */ + number?: string; + + /** + A UUID, the unique identifier for a particular Signal account.. + */ + uuid?: string; + relay?: string; +}; + +export type MarkReadRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + + /** + The address that sent the message being marked as read. + */ + to: JsonAddressv1; + + /** + List of messages to mark as read. + Example: 1615576442475 + */ + timestamps: number[]; + when?: number; +}; + +/** + Get all information available about a user + */ +export type GetProfileRequestv1 = { + /** + the signald account to use. + */ + account: string; + + /** + if true, return results from local store immediately, refreshing from server in the background if needed. if false (default), block until profile can be retrieved from server. + */ + async?: boolean; + + /** + the address to look up. + */ + address: JsonAddressv1; +}; + +/** + Information about a Signal user + */ +export type Profilev1 = { + /** + The user's name from local contact names if available, or if not in contact list their Signal profile name. + */ + name?: string; + + /** + path to avatar on local disk. + */ + avatar?: string; + address?: JsonAddressv1; + capabilities?: Capabilitiesv1; + + /** + color of the chat with this user. + */ + color?: string; + about?: string; + emoji?: string; + + /** + The user's Signal profile name. + */ + profile_name?: string; + inbox_position?: number; + expiration_time?: number; + + /** + *base64-encoded* mobilecoin address. Note that this is not the traditional MobileCoin address encoding. Clients are responsible for converting between MobileCoin's custom base58 on the user-facing side and base64 encoding on the signald side. If unset, null or an empty string, will empty the profile payment address. + */ + mobilecoin_address?: string; + + /** + currently unclear how these work, as they are not available in the production Signal apps. + */ + visible_badge_ids?: string[]; +}; + +export type ProfileUnavailableErrorv1 = { + message?: string; +}; + +export type ListGroupsRequestv1 = { + account: string; +}; + +export type GroupListv1 = { + groups?: JsonGroupV2Infov1[]; + legacyGroups?: JsonGroupInfov1[]; +}; + +export type ListContactsRequestv1 = { + account: string; + + /** + return results from local store immediately, refreshing from server afterward if needed. If false (default), block until all pending profiles have been retrieved.. + */ + async?: boolean; +}; + +export type ProfileListv1 = { + profiles?: Profilev1[]; +}; + +export type CreateGroupRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + title: string; + avatar?: string; + members: JsonAddressv1[]; + + /** + the message expiration timer. + */ + timer?: number; + + /** + The role of all members other than the group creator. Options are ADMINISTRATOR or DEFAULT (case insensitive). + Example: "ADMINISTRATOR" + */ + member_role?: string; +}; + +export type NoKnownUUIDErrorv1 = { + message?: string; +}; + +export type LeaveGroupRequestv1 = { + /** + The account to use. + Example: "+12024561414" + */ + account: string; + + /** + The group to leave. + Example: "EdSqI90cS0UomDpgUXOlCoObWvQOXlH5G3Z2d3f4ayE=" + */ + groupID: string; +}; + +/** + Generate a linking URI. Typically this is QR encoded and scanned by the primary device. Submit the returned session_id with a finish_link request. + */ +export type GenerateLinkingURIRequestv1 = { + /** + The identifier of the server to use. Leave blank for default (usually Signal production servers but configurable at build time). + */ + server?: string; +}; + +export type LinkingURIv1 = { + uri?: string; + session_id?: string; +}; + +/** + After a linking URI has been requested, finish_link must be called with the session_id provided with the URI. it will return information about the new account once the linking process is completed by the other device. + */ +export type FinishLinkRequestv1 = { + device_name?: string; + session_id?: string; +}; + +/** + A local account in signald + */ +export type Accountv1 = { + /** + The address of this account. + */ + address?: JsonAddressv1; + + /** + indicates the account has not completed registration. + */ + pending?: boolean; + + /** + The Signal device ID. Official Signal mobile clients (iPhone and Android) have device ID = 1, while linked devices such as Signal Desktop or Signal iPad have higher device IDs.. + */ + device_id?: number; + + /** + The primary identifier on the account, included with all requests to signald for this account. Previously called 'username'. + */ + account_id?: string; +}; + +export type NoSuchSessionErrorv1 = { + message?: string; +}; + +export type UserAlreadyExistsErrorv1 = { + uuid?: string; + message?: string; +}; + +/** + Link a new device to a local Signal account + */ +export type AddLinkedDeviceRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + + /** + the sgnl://linkdevice uri provided (typically in qr code form) by the new device. + Example: "sgnl://linkdevice?uuid=jAaZ5lxLfh7zVw5WELd6-Q&pub_key=BfFbjSwmAgpVJBXUdfmSgf61eX3a%2Bq9AoxAVpl1HUap9" + */ + uri: string; +}; + +/** + begin the account registration process by requesting a phone number verification code. when the code is received, submit it with a verify request + */ +export type RegisterRequestv1 = { + /** + the e164 phone number to register with. + Example: "+12024561414" + */ + account: string; + + /** + set to true to request a voice call instead of an SMS for verification. + */ + voice?: boolean; + + /** + See https://signald.org/articles/captcha/. + */ + captcha?: string; + + /** + The identifier of the server to use. Leave blank for default (usually Signal production servers but configurable at build time). + */ + server?: string; +}; + +export type CaptchaRequiredErrorv1 = { + more?: string; + message?: string; +}; + +/** + verify an account's phone number with a code after registering, completing the account creation process + */ +export type VerifyRequestv1 = { + /** + the e164 phone number being verified. + Example: "+12024561414" + */ + account: string; + + /** + the verification code, dash (-) optional. + Example: "555555" + */ + code: string; +}; + +export type AccountHasNoKeysErrorv1 = { + message?: string; +}; + +export type AccountAlreadyVerifiedErrorv1 = { + message?: string; +}; + +export type AccountLockedErrorv1 = { + more?: string; + message?: string; +}; + +/** + Get information about a known keys for a particular address + */ +export type GetIdentitiesRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + + /** + address to get keys for. + */ + address: JsonAddressv1; +}; + +/** + a list of identity keys associated with a particular address + */ +export type IdentityKeyListv1 = { + address?: JsonAddressv1; + identities?: IdentityKeyv1[]; +}; + +/** + Trust another user's safety number using either the QR code data or the safety number text + */ +export type TrustRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + + /** + The user to query identity keys for. + */ + address: JsonAddressv1; + + /** + required if qr_code_data is absent. + Example: "373453558586758076680580548714989751943247272727416091564451" + */ + safety_number?: string; + + /** + base64-encoded QR code data. required if safety_number is absent. + */ + qr_code_data?: string; + + /** + One of TRUSTED_UNVERIFIED, TRUSTED_VERIFIED or UNTRUSTED. Default is TRUSTED_VERIFIED. + Example: "TRUSTED_VERIFIED" + */ + trust_level?: string; +}; + +export type FingerprintVersionMismatchErrorv1 = { + message?: string; +}; + +export type UnknownIdentityKeyErrorv1 = { + message?: string; +}; + +export type InvalidFingerprintErrorv1 = { + message?: string; +}; + +/** + delete all account data signald has on disk, and optionally delete the account from the server as well. Note that this is not "unlink" and will delete the entire account, even from a linked device. + */ +export type DeleteAccountRequestv1 = { + /** + The account to delete. + Example: "+12024561414" + */ + account: string; + + /** + delete account information from the server as well (default false). + */ + server?: boolean; +}; + +/** + send a typing started or stopped message + */ +export type TypingRequestv1 = { + /** + The account to use. + Example: "+12024561414" + */ + account: string; + address?: JsonAddressv1; + group?: string; + typing: boolean; + when?: number; +}; + +export type InvalidGroupErrorv1 = { + message?: string; +}; + +/** + reset a session with a particular user + */ +export type ResetSessionRequestv1 = { + /** + The account to use. + Example: "+12024561414" + */ + account: string; + + /** + the user to reset session with. + */ + address: JsonAddressv1; + timestamp?: number; +}; + +/** + Request other devices on the account send us their group list, syncable config and contact list. + */ +export type RequestSyncRequestv1 = { + /** + The account to use. + Example: "+12024561414" + */ + account: string; + + /** + request group sync (default true). + */ + groups?: boolean; + + /** + request configuration sync (default true). + */ + configuration?: boolean; + + /** + request contact sync (default true). + */ + contacts?: boolean; + + /** + request block list sync (default true). + */ + blocked?: boolean; +}; + +/** + return all local accounts + */ +export type ListAccountsRequestv1 = {}; + +export type AccountListv1 = { + accounts?: Accountv1[]; +}; + +/** + Get information about a group from a signal.group link + */ +export type GroupLinkInfoRequestv1 = { + /** + The account to use. + Example: "+12024561414" + */ + account: string; + + /** + the signald.group link. + Example: "https://signal.group/#CjQKINH_GZhXhfifTcnBkaKTNRxW-hHKnGSq-cJNyPVqHRp8EhDUB7zjKNEl0NaULhsqJCX3" + */ + uri: string; +}; + +export type GroupLinkNotActiveErrorv1 = { + message?: string; +}; + +/** + update information about a local contact + */ +export type UpdateContactRequestv1 = { + account: string; + address: JsonAddressv1; + name?: string; + color?: string; + inbox_position?: number; +}; + +/** + Set the message expiration timer for a thread. Expiration must be specified in seconds, set to 0 to disable timer + */ +export type SetExpirationRequestv1 = { + /** + The account to use. + Example: "+12024561414" + */ + account: string; + address?: JsonAddressv1; + group?: string; + expiration: number; +}; + +/** + set this device's name. This will show up on the mobile device on the same account under settings -> linked devices + */ +export type SetDeviceNameRequestv1 = { + /** + The account to set the device name of. + Example: "+12024561414" + */ + account: string; + + /** + The device name. + */ + device_name?: string; +}; + +/** + get all known identity keys + */ +export type GetAllIdentitiesv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; +}; + +export type AllIdentityKeyListv1 = { + identity_keys?: IdentityKeyListv1[]; +}; + +/** + receive incoming messages. After making a subscribe request, incoming messages will be sent to the client encoded as ClientMessageWrapper. Send an unsubscribe request or disconnect from the socket to stop receiving messages. + */ +export type SubscribeRequestv1 = { + /** + The account to subscribe to incoming message for. + Example: "+12024561414" + */ + account: string; +}; + +/** + See subscribe for more info + */ +export type UnsubscribeRequestv1 = { + /** + The account to unsubscribe from. + Example: "+12024561414" + */ + account: string; +}; + +/** + delete a message previously sent + */ +export type RemoteDeleteRequestv1 = { + /** + the account to use. + Example: "+12024561414" + */ + account: string; + + /** + the address to send the delete message to. should match address the message to be deleted was sent to. required if group is not set.. + */ + address?: JsonAddressv1; + + /** + the group to send the delete message to. should match group the message to be deleted was sent to. required if address is not set.. + Example: "EdSqI90cS0UomDpgUXOlCoObWvQOXlH5G3Z2d3f4ayE=" + */ + group?: string; + timestamp: number; + + /** + Optionally set to a sub-set of group members. Ignored if group isn't specified. + */ + members?: JsonAddressv1[]; +}; + +/** + add a new server to connect to. Returns the new server's UUID. + */ +export type AddServerRequestv1 = { + server: Serverv1; +}; + +export type GetServersRequestv1 = {}; + +export type ServerListv1 = { + servers?: Serverv1[]; +}; + +export type RemoveServerRequestv1 = { + uuid?: string; +}; + +/** + send a mobilecoin payment + */ +export type SendPaymentRequestv1 = { + /** + the account to use. + Example: "+12024561414" + */ + account: string; + + /** + the address to send the payment message to. + */ + address: JsonAddressv1; + payment: Paymentv1; + when?: number; +}; + +/** + Retrieves the remote config (feature flags) from the server. + */ +export type RemoteConfigRequestv1 = { + /** + The account to use to retrieve the remote config. + Example: "+12024561414" + */ + account: string; +}; + +export type RemoteConfigListv1 = { + config?: RemoteConfigv1[]; +}; + +/** + deny a request to join a group + */ +export type RefuseMembershipRequestv1 = { + /** + The account to interact with. + Example: "+12024561414" + */ + account: string; + + /** + list of requesting members to refuse. + */ + members: JsonAddressv1[]; + group_id: string; +}; + +export type SubmitChallengeRequestv1 = { + account: string; + challenge: string; + captcha_token?: string; +}; + +export type JsonDataMessagev1 = { + /** + the timestamp that the message was sent at, according to the sender's device. This is used to uniquely identify this message for things like reactions and quotes.. + Example: 1615576442475 + */ + timestamp?: number; + + /** + files attached to the incoming message. + */ + attachments?: JsonAttachmentv1[]; + + /** + the text body of the incoming message.. + Example: "hello" + */ + body?: string; + + /** + if the incoming message was sent to a v1 group, information about that group will be here. + */ + group?: JsonGroupInfov1; + + /** + if the incoming message was sent to a v2 group, basic identifying information about that group will be here. If group information changes, JsonGroupV2Info.revision is incremented. If the group revision is higher than previously seen, a client can retrieve the group information by calling get_group.. + */ + groupV2?: JsonGroupV2Infov1; + endSession?: boolean; + + /** + the expiry timer on the incoming message. Clients should delete records of the message within this number of seconds. + */ + expiresInSeconds?: number; + profileKeyUpdate?: boolean; + + /** + if the incoming message is a quote or reply to another message, this will contain information about that message. + */ + quote?: JsonQuotev1; + + /** + if the incoming message has a shared contact, the contact's information will be here. + */ + contacts?: SharedContactv0[]; + + /** + if the incoming message has a link preview, information about that preview will be here. + */ + previews?: JsonPreviewv1[]; + + /** + if the incoming message is a sticker, information about the sicker will be here. + */ + sticker?: JsonStickerv0; + + /** + indicates the message is a view once message. View once messages typically include no body and a single image attachment. Official Signal clients will prevent the user from saving the image, and once the user has viewed the image once they will destroy the image.. + */ + viewOnce?: boolean; + + /** + if the message adds or removes a reaction to another message, this will indicate what change is being made. + */ + reaction?: JsonReactionv1; + + /** + if the inbound message is deleting a previously sent message, indicates which message should be deleted. + */ + remoteDelete?: RemoteDeletev1; + + /** + list of mentions in the message. + */ + mentions?: JsonMentionv1[]; + + /** + details about the MobileCoin payment attached to the message, if present. + */ + payment?: Paymentv1; + + /** + the eraId string from a group call message update. + */ + group_call_update?: string; +}; + +export type JsonSyncMessagev1 = { + sent?: JsonSentTranscriptMessagev1; + contacts?: JsonAttachmentv1; + contactsComplete?: boolean; + groups?: JsonAttachmentv1; + blockedList?: JsonBlockedListMessagev1; + request?: string; + readMessages?: JsonReadMessagev1[]; + viewOnceOpen?: JsonViewOnceOpenMessagev1; + verified?: JsonVerifiedMessagev1; + configuration?: ConfigurationMessagev0; + stickerPackOperations?: JsonStickerPackOperationMessagev0[]; + fetchType?: string; + messageRequestResponse?: JsonMessageRequestResponseMessagev1; +}; + +export type CallMessagev1 = { + offer_message?: OfferMessagev1; + answer_message?: AnswerMessagev1; + busy_message?: BusyMessagev1; + hangup_message?: HangupMessagev1; + ice_update_message?: IceUpdateMessagev1[]; + destination_device_id?: number; + multi_ring?: boolean; +}; + +export type ReceiptMessagev1 = { + /** + options: UNKNOWN, DELIVERY, READ, VIEWED. + */ + type?: string; + timestamps?: number[]; + when?: number; +}; + +export type TypingMessagev1 = { + action?: string; + timestamp?: number; + group_id?: string; +}; + +export type IdentityKeyv1 = { + /** + the first time this identity key was seen. + */ + added?: number; + safety_number?: string; + + /** + base64-encoded QR code data. + */ + qr_code_data?: string; + + /** + One of TRUSTED_UNVERIFIED, TRUSTED_VERIFIED or UNTRUSTED. + */ + trust_level?: string; +}; + +/** + A quote is a reply to a previous message. ID is the sent time of the message being replied to + */ +export type JsonQuotev1 = { + /** + the client timestamp of the message being quoted. + Example: 1615576442475 + */ + id?: number; + + /** + the author of the message being quoted. + */ + author?: JsonAddressv1; + + /** + the body of the message being quoted. + Example: "hey  what's up?" + */ + text?: string; + + /** + list of files attached to the quoted message. + */ + attachments?: JsonQuotedAttachmentv0[]; + + /** + list of mentions in the quoted message. + */ + mentions?: JsonMentionv1[]; +}; + +export type JsonMentionv1 = { + /** + The UUID of the account being mentioned. + Example: "aeed01f0-a234-478e-8cf7-261c283151e7" + */ + uuid?: string; + + /** + The number of characters in that the mention starts at. Note that due to a quirk of how signald encodes JSON, if this value is 0 (for example if the first character in the message is the mention) the field won't show up.. + Example: 4 + */ + start?: number; + + /** + The length of the mention represented in the message. Seems to always be 1 but included here in case that changes.. + Example: 1 + */ + length?: number; +}; + +/** + metadata about one of the links in a message + */ +export type JsonPreviewv1 = { + url?: string; + title?: string; + description?: string; + date?: number; + + /** + an optional image file attached to the preview. + */ + attachment?: JsonAttachmentv1; +}; + +export type JsonSendMessageResultv1 = { + address?: JsonAddressv1; + success?: SendSuccessv1; + networkFailure?: boolean; + unregisteredFailure?: boolean; + identityFailure?: string; + proof_required_failure?: ProofRequiredErrorv1; +}; + +export type JsonReactionv1 = { + /** + the emoji to react with. + Example: "👍" + */ + emoji?: string; + + /** + set to true to remove the reaction. requires emoji be set to previously reacted emoji. + */ + remove?: boolean; + + /** + the author of the message being reacted to. + */ + targetAuthor?: JsonAddressv1; + + /** + the client timestamp of the message being reacted to. + Example: 1615576442475 + */ + targetSentTimestamp?: number; +}; + +/** + group access control settings. Options for each controlled action are: UNKNOWN, ANY, MEMBER, ADMINISTRATOR, UNSATISFIABLE and UNRECOGNIZED + */ +export type GroupAccessControlv1 = { + /** + UNSATISFIABLE when the group link is disabled, ADMINISTRATOR when the group link is enabled but an administrator must approve new members, ANY when the group link is enabled and no approval is required. + Example: "ANY" + */ + link?: string; + + /** + who can edit group info. + */ + attributes?: string; + + /** + who can add members. + */ + members?: string; +}; + +export type GroupMemberv1 = { + uuid?: string; + + /** + possible values are: UNKNOWN, DEFAULT, ADMINISTRATOR and UNRECOGNIZED. + Example: "DEFAULT" + */ + role?: string; + joined_revision?: number; +}; + +export type DeviceInfov1 = { + id?: number; + name?: string; + created?: number; + lastSeen?: number; +}; + +/** + information about a legacy group + */ +export type JsonGroupInfov1 = { + groupId?: string; + members?: JsonAddressv1[]; + name?: string; + type?: string; + avatarId?: number; +}; + +export type Capabilitiesv1 = { + gv2?: boolean; + storage?: boolean; + gv1_migration?: boolean; + sender_key?: boolean; + announcement_group?: boolean; + change_number?: boolean; +}; + +/** + a Signal server + */ +export type Serverv1 = { + /** + A unique identifier for the server, referenced when adding accounts. Must be a valid UUID. Will be generated if not specified when creating.. + */ + uuid?: string; + proxy?: string; + + /** + base64 encoded trust store, password must be 'whisper'. + */ + ca?: string; + service_url?: string; + cdn_urls?: ServerCDNv1[]; + contact_discovery_url?: string; + key_backup_url?: string; + storage_url?: string; + + /** + base64 encoded ZKGROUP_SERVER_PUBLIC_PARAMS value. + */ + zk_param?: string; + + /** + base64 encoded. + */ + unidentified_sender_root?: string; + key_backup_service_name?: string; + + /** + base64 encoded. + */ + key_backup_service_id?: string; + key_backup_mrenclave?: string; + cds_mrenclave?: string; + + /** + base64 encoded trust store, password must be 'whisper'. + */ + ias_ca?: string; +}; + +/** + details about a MobileCoin payment + */ +export type Paymentv1 = { + /** + base64 encoded payment receipt data. This is a protobuf value which can be decoded as the Receipt object described in https://github.com/mobilecoinfoundation/mobilecoin/blob/master/api/proto/external.proto. + */ + receipt?: string; + + /** + note attached to the payment. + */ + note?: string; +}; + +/** + A remote config (feature flag) entry. + */ +export type RemoteConfigv1 = { + /** + The name of this remote config entry. These names may be prefixed with the platform type ("android.", "ios.", "desktop.", etc.) Typically, clients only handle the relevant configs for its platform, hardcoding the names it cares about handling and ignoring the rest.. + Example: desktop.mediaQuality.levels + */ + name?: string; + + /** + The value for this remote config entry. Even though this is a string, it could be a boolean as a string, an integer/long value, a comma-delimited list, etc. Clients usually consume this by hardcoding the feature flagsit should track in the app and assuming that the server will send the type that the client expects. If an unexpected type occurs, it falls back to a default value.. + Example: 1:2,61:2,81:2,82:2,65:2,31:2,47:2,41:2,32:2,385:2,971:2,974:2,49:2,33:2,*:1 + */ + value?: string; +}; + +/** + represents a file attached to a message. When seding, only `filename` is required. + */ +export type JsonAttachmentv1 = { + contentType?: string; + id?: string; + size?: number; + + /** + when receiving, the path that file has been downloaded to. + */ + storedFilename?: string; + + /** + when sending, the path to the local file to upload. + */ + filename?: string; + + /** + the original name of the file. + */ + customFilename?: string; + caption?: string; + width?: number; + height?: number; + voiceNote?: boolean; + key?: string; + digest?: string; + blurhash?: string; +}; + +export type RemoteDeletev1 = { + target_sent_timestamp?: number; +}; + +export type JsonSentTranscriptMessagev1 = { + destination?: JsonAddressv1; + timestamp?: number; + expirationStartTimestamp?: number; + message?: JsonDataMessagev1; + unidentifiedStatus?: Record; + isRecipientUpdate?: boolean; +}; + +export type JsonBlockedListMessagev1 = { + addresses?: JsonAddressv1[]; + groupIds?: string[]; +}; + +export type JsonReadMessagev1 = { + sender?: JsonAddressv1; + timestamp?: number; +}; + +export type JsonViewOnceOpenMessagev1 = { + sender?: JsonAddressv1; + timestamp?: number; +}; + +export type JsonVerifiedMessagev1 = { + destination?: JsonAddressv1; + identityKey?: string; + verified?: string; + timestamp?: number; +}; + +export type JsonMessageRequestResponseMessagev1 = { + person?: JsonAddressv1; + groupId?: string; + type?: string; +}; + +export type OfferMessagev1 = { + id?: number; + sdp?: string; + type?: string; + opaque?: string; +}; + +export type AnswerMessagev1 = { + id?: number; + sdp?: string; + opaque?: string; +}; + +export type BusyMessagev1 = { + id?: number; +}; + +export type HangupMessagev1 = { + id?: number; + type?: string; + legacy?: boolean; + device_id?: number; +}; + +export type IceUpdateMessagev1 = { + id?: number; + opaque?: string; + sdp?: string; +}; + +export type SendSuccessv1 = { + unidentified?: boolean; + needsSync?: boolean; + duration?: number; + devices?: number[]; +}; + +export type ProofRequiredErrorv1 = { + token?: string; + + /** + possible list values are RECAPTCHA and PUSH_CHALLENGE. + */ + options?: string[]; + message?: string; + + /** + value in seconds. + */ + retry_after?: number; +}; + +export type ServerCDNv1 = { + number?: number; + url?: string; +}; + +declare module "./util" { + interface EventTypes { + /** + * The v0 event emitted when a signal message is received + * @event + */ + messagev0(message: JsonMessageEnvelopev1): void; + /** + * @event + */ + subscribev0(): void; + /** + * @event + */ + unsubscribev0(): void; + } +} +export class SignaldGeneratedApi extends JSONTransport { + async send( + username: string, + recipientAddress?: JsonAddressv1, + recipientGroupId?: string, + messageBody?: string, + attachments?: JsonAttachmentv0[], + quote?: JsonQuotev1, + timestamp?: number, + mentions?: JsonMentionv1[], + previews?: JsonPreviewv1[], + members?: JsonAddressv1[] + ): Promise { + return this.getResponse({ + type: "send", + version: "v1", + username: username, + recipientAddress: recipientAddress, + recipientGroupId: recipientGroupId, + messageBody: messageBody, + attachments: attachments, + quote: quote, + timestamp: timestamp, + mentions: mentions, + previews: previews, + members: members, + }) as Promise; + } + async react( + username: string, + reaction: JsonReactionv1, + recipientAddress?: JsonAddressv1, + recipientGroupId?: string, + timestamp?: number, + members?: JsonAddressv1[] + ): Promise { + return this.getResponse({ + type: "react", + version: "v1", + username: username, + reaction: reaction, + recipientAddress: recipientAddress, + recipientGroupId: recipientGroupId, + timestamp: timestamp, + members: members, + }) as Promise; + } + async version(): Promise { + return this.getResponse({ + type: "version", + version: "v1", + }) as Promise; + } + async acceptInvitation( + account: string, + groupID: string + ): Promise { + return this.getResponse({ + type: "accept_invitation", + version: "v1", + account: account, + groupID: groupID, + }) as Promise; + } + async approveMembership( + account: string, + groupID: string, + members: JsonAddressv1[] + ): Promise { + return this.getResponse({ + type: "approve_membership", + version: "v1", + account: account, + groupID: groupID, + members: members, + }) as Promise; + } + async getGroup( + account: string, + groupID: string, + revision?: number + ): Promise { + return this.getResponse({ + type: "get_group", + version: "v1", + account: account, + groupID: groupID, + revision: revision, + }) as Promise; + } + async getLinkedDevices(account: string): Promise { + return this.getResponse({ + type: "get_linked_devices", + version: "v1", + account: account, + }) as Promise; + } + async joinGroup(account: string, uri: string): Promise { + return this.getResponse({ + type: "join_group", + version: "v1", + account: account, + uri: uri, + }) as Promise; + } + async removeLinkedDevice(account: string, deviceId: number): Promise { + await this.getResponse({ + type: "remove_linked_device", + version: "v1", + account: account, + deviceId: deviceId, + }); + return; + } + async updateGroup( + account: string, + groupID: string, + title?: string, + description?: string, + avatar?: string, + updateTimer?: number, + addMembers?: JsonAddressv1[], + removeMembers?: JsonAddressv1[], + updateRole?: GroupMemberv1, + updateAccessControl?: GroupAccessControlv1, + resetLink?: boolean, + announcements?: string + ): Promise { + return this.getResponse({ + type: "update_group", + version: "v1", + account: account, + groupID: groupID, + title: title, + description: description, + avatar: avatar, + updateTimer: updateTimer, + addMembers: addMembers, + removeMembers: removeMembers, + updateRole: updateRole, + updateAccessControl: updateAccessControl, + resetLink: resetLink, + announcements: announcements, + }) as Promise; + } + async setProfile( + account: string, + name?: string, + avatarFile?: string, + about?: string, + emoji?: string, + mobilecoin_address?: string, + visible_badge_ids?: string[] + ): Promise { + await this.getResponse({ + type: "set_profile", + version: "v1", + account: account, + name: name, + avatarFile: avatarFile, + about: about, + emoji: emoji, + mobilecoin_address: mobilecoin_address, + visible_badge_ids: visible_badge_ids, + }); + return; + } + async resolveAddress( + account: string, + partial: JsonAddressv1 + ): Promise { + return this.getResponse({ + type: "resolve_address", + version: "v1", + account: account, + partial: partial, + }) as Promise; + } + async markRead( + account: string, + to: JsonAddressv1, + timestamps: number[], + when?: number + ): Promise { + await this.getResponse({ + type: "mark_read", + version: "v1", + account: account, + to: to, + timestamps: timestamps, + when: when, + }); + return; + } + async getProfile( + account: string, + address: JsonAddressv1, + async?: boolean + ): Promise { + return this.getResponse({ + type: "get_profile", + version: "v1", + account: account, + address: address, + async: async, + }) as Promise; + } + async listGroups(account: string): Promise { + return this.getResponse({ + type: "list_groups", + version: "v1", + account: account, + }) as Promise; + } + async listContacts(account: string, async?: boolean): Promise { + return this.getResponse({ + type: "list_contacts", + version: "v1", + account: account, + async: async, + }) as Promise; + } + async createGroup( + account: string, + title: string, + members: JsonAddressv1[], + avatar?: string, + timer?: number, + member_role?: string + ): Promise { + return this.getResponse({ + type: "create_group", + version: "v1", + account: account, + title: title, + members: members, + avatar: avatar, + timer: timer, + member_role: member_role, + }) as Promise; + } + async leaveGroup(account: string, groupID: string): Promise { + return this.getResponse({ + type: "leave_group", + version: "v1", + account: account, + groupID: groupID, + }) as Promise; + } + async generateLinkingUri(server?: string): Promise { + return this.getResponse({ + type: "generate_linking_uri", + version: "v1", + server: server, + }) as Promise; + } + async finishLink( + device_name?: string, + session_id?: string + ): Promise { + return this.getResponse({ + type: "finish_link", + version: "v1", + device_name: device_name, + session_id: session_id, + }) as Promise; + } + async addDevice(account: string, uri: string): Promise { + await this.getResponse({ + type: "add_device", + version: "v1", + account: account, + uri: uri, + }); + return; + } + async register( + account: string, + voice?: boolean, + captcha?: string, + server?: string + ): Promise { + return this.getResponse({ + type: "register", + version: "v1", + account: account, + voice: voice, + captcha: captcha, + server: server, + }) as Promise; + } + async verify(account: string, code: string): Promise { + return this.getResponse({ + type: "verify", + version: "v1", + account: account, + code: code, + }) as Promise; + } + async getIdentities( + account: string, + address: JsonAddressv1 + ): Promise { + return this.getResponse({ + type: "get_identities", + version: "v1", + account: account, + address: address, + }) as Promise; + } + async trust( + account: string, + address: JsonAddressv1, + safety_number?: string, + qr_code_data?: string, + trust_level?: string + ): Promise { + await this.getResponse({ + type: "trust", + version: "v1", + account: account, + address: address, + safety_number: safety_number, + qr_code_data: qr_code_data, + trust_level: trust_level, + }); + return; + } + async deleteAccount(account: string, server?: boolean): Promise { + await this.getResponse({ + type: "delete_account", + version: "v1", + account: account, + server: server, + }); + return; + } + async typing( + account: string, + typing: boolean, + address?: JsonAddressv1, + group?: string, + when?: number + ): Promise { + await this.getResponse({ + type: "typing", + version: "v1", + account: account, + typing: typing, + address: address, + group: group, + when: when, + }); + return; + } + async resetSession( + account: string, + address: JsonAddressv1, + timestamp?: number + ): Promise { + return this.getResponse({ + type: "reset_session", + version: "v1", + account: account, + address: address, + timestamp: timestamp, + }) as Promise; + } + async requestSync( + account: string, + groups?: boolean, + configuration?: boolean, + contacts?: boolean, + blocked?: boolean + ): Promise { + await this.getResponse({ + type: "request_sync", + version: "v1", + account: account, + groups: groups, + configuration: configuration, + contacts: contacts, + blocked: blocked, + }); + return; + } + async listAccounts(): Promise { + return this.getResponse({ + type: "list_accounts", + version: "v1", + }) as Promise; + } + async groupLinkInfo( + account: string, + uri: string + ): Promise { + return this.getResponse({ + type: "group_link_info", + version: "v1", + account: account, + uri: uri, + }) as Promise; + } + async updateContact( + account: string, + address: JsonAddressv1, + name?: string, + color?: string, + inbox_position?: number + ): Promise { + return this.getResponse({ + type: "update_contact", + version: "v1", + account: account, + address: address, + name: name, + color: color, + inbox_position: inbox_position, + }) as Promise; + } + async setExpiration( + account: string, + expiration: number, + address?: JsonAddressv1, + group?: string + ): Promise { + return this.getResponse({ + type: "set_expiration", + version: "v1", + account: account, + expiration: expiration, + address: address, + group: group, + }) as Promise; + } + async setDeviceName(account: string, device_name?: string): Promise { + await this.getResponse({ + type: "set_device_name", + version: "v1", + account: account, + device_name: device_name, + }); + return; + } + async getAllIdentities(account: string): Promise { + return this.getResponse({ + type: "get_all_identities", + version: "v1", + account: account, + }) as Promise; + } + async subscribe(account: string): Promise { + await this.getResponse({ + type: "subscribe", + version: "v1", + account: account, + }); + return; + } + async unsubscribe(account: string): Promise { + await this.getResponse({ + type: "unsubscribe", + version: "v1", + account: account, + }); + return; + } + async remoteDelete( + account: string, + timestamp: number, + address?: JsonAddressv1, + group?: string, + members?: JsonAddressv1[] + ): Promise { + return this.getResponse({ + type: "remote_delete", + version: "v1", + account: account, + timestamp: timestamp, + address: address, + group: group, + members: members, + }) as Promise; + } + async addServer(server: Serverv1): Promise { + return this.getResponse({ + type: "add_server", + version: "v1", + server: server, + }) as Promise; + } + async getServers(): Promise { + return this.getResponse({ + type: "get_servers", + version: "v1", + }) as Promise; + } + async deleteServer(uuid?: string): Promise { + await this.getResponse({ + type: "delete_server", + version: "v1", + uuid: uuid, + }); + return; + } + async sendPayment( + account: string, + address: JsonAddressv1, + payment: Paymentv1, + when?: number + ): Promise { + return this.getResponse({ + type: "send_payment", + version: "v1", + account: account, + address: address, + payment: payment, + when: when, + }) as Promise; + } + async getRemoteConfig(account: string): Promise { + return this.getResponse({ + type: "get_remote_config", + version: "v1", + account: account, + }) as Promise; + } + async refuseMembership( + account: string, + members: JsonAddressv1[], + group_id: string + ): Promise { + return this.getResponse({ + type: "refuse_membership", + version: "v1", + account: account, + members: members, + group_id: group_id, + }) as Promise; + } + async submitChallenge( + account: string, + challenge: string, + captcha_token?: string + ): Promise { + await this.getResponse({ + type: "submit_challenge", + version: "v1", + account: account, + challenge: challenge, + captcha_token: captcha_token, + }); + return; + } +} diff --git a/packages/node-signald/src/index.ts b/packages/node-signald/src/index.ts new file mode 100644 index 0000000..3b9640b --- /dev/null +++ b/packages/node-signald/src/index.ts @@ -0,0 +1,5 @@ +export { SignaldAPI } from "./api"; +export { JSONTransport, EventTypes } from "./util"; +export { SignaldError, CaptchaRequiredException } from "./error"; +import "./util"; +export * from "./generated"; diff --git a/packages/node-signald/src/util.ts b/packages/node-signald/src/util.ts new file mode 100644 index 0000000..5ac0031 --- /dev/null +++ b/packages/node-signald/src/util.ts @@ -0,0 +1,277 @@ +import EventEmitter from "eventemitter3"; +import { v4 as uuid } from "uuid"; +import * as backoff from "backoff"; +import * as net from "net"; +import { throwOnError } from "./error"; + +export interface EventTypes { + /** + * Emitted when the signald connection closes. + * @event + */ + transport_disconnected(): void; + /** + * Emitted when a new connection is established. + * @event + */ + transport_connected(): void; + /** + * Emitted when a transport level error occurs. This is errors around the socket and connection, errors from signald itself will not be emitted here. + * @event + */ + transport_error(error: any): void; + /** + * Every full JSON payload sent to signald will be emitted on this event (after sending). + * @event + */ + transport_sent_payload(payload: object): void; + /** + * Every full JSON payload received from signald will be emitted on this event (before processing) + * @event + */ + transport_received_payload(payload: object): void; +} + +/** + * JSONTransport handles the low level connection and over-the-wire handling. + * + * You probably do not need to instantiate this class directly. + */ +export class JSONTransport extends EventEmitter { + #socketfile: string; + #client; + #connected: boolean = false; + #buffer: string; + #callbacks: Record; + #logger: Logger = consoleLogger; + #backoff: backoff.Backoff; + + constructor() { + super(); + this.#buffer = ""; + this.#callbacks = {}; + } + + /** + * Connect to signald via the unix domain socket, with a fibonacci backoff + * @param socketfile the path to the signald socket + * @param maxDelay maximum delay (in ms) of the backoff + * @param initialDelay how long (in ms) the initial delay before the first retry is + * @return a promise that resolves on a successful connection + */ + public connectWithBackoff( + socketfile: string, + maxDelay: number = 90000, + initialDelay: number = 10 + ) { + this.#backoff = backoff.fibonacci({ + initialDelay: 10, + maxDelay: 90000, + }); + + let lastLogNotify = 0; + let backoffInProgress = true; + this.#backoff.on("backoff", (number, delay) => {}); + + this.#backoff.on("ready", async (number, delay) => { + const now = +new Date(); + if (backoffInProgress && now - lastLogNotify > 10000) { + lastLogNotify = +new Date(); + this.log(`reconnecting. attempt=${number} delay=${delay}`); + } + if (!this.isConnected()) { + this.connect(socketfile); + } + }); + + this.on("transport_disconnected", async () => { + if (!backoffInProgress) this.log("disconnected. attemping to reconnect."); + this.#backoff.backoff(); + backoffInProgress = true; + }); + + this.on("transport_connected", async () => { + this.log("connected"); + this.#backoff.reset(); + backoffInProgress = false; + lastLogNotify = 0; + }); + this.connect(socketfile); + } + + /** + * Connect to signald via the unix domain socket. + * @param socketfile the path to the signald socket + * @return a promise that resolves on a successful connection + */ + public connect(socketfile: string) { + let connectedOnce = false; + this.#socketfile = socketfile; + this.#client = net.createConnection(socketfile); + this.#client.setEncoding("utf8"); + + this.#client.on("connect", () => { + this.#connected = true; + connectedOnce = true; + this.#buffer = ""; + this.emit("transport_connected"); + }); + + this.#client.on("close", () => { + this.#connected = false; + this.emit("transport_disconnected"); + }); + + this.#client.on("data", (frame) => { + this.#buffer += frame; + if (!this.#buffer.endsWith("\n")) return; + const data = this.#buffer; + this.#buffer = ""; + data.split("\n").forEach((line) => { + if (!line) return; + const payload = JSON.parse(line); + this.emit("transport_received_payload", payload); + this.receivePayload(payload); + }); + }); + + this.#client.on("error", (error) => { + this.emit("transport_error", error); + }); + } + + /** + * Connect to signald asynchronously. Does not include backoff and auto-reconnect. + */ + public async connectAsync(socketfile: string): Promise { + return new Promise((resolve, reject) => { + const onconnected = () => { + this.removeListener("transport_connected", onconnected); + this.removeListener("transport_error", onerror); + resolve(); + }; + const onerror = (error) => { + this.removeListener("transport_error", onerror); + this.removeListener("transport_connected", onconnected); + reject(error); + }; + this.on("transport_connected", onconnected); + this.on("transport_error", onerror); + this.connect(socketfile); + }); + } + + /** + * Disconnect from the signald socket. + */ + public disconnect() { + this.#client.end(); + } + + /** + * @returns true if the socket is connected + */ + public isConnected(): boolean { + return this.#connected; + } + + private receivePayload(payload: any) { + const { id, type, version = "v0" } = payload; + if (!type) { + this.debug("no type in payload."); + this.debug( + "found following keys: ", + JSON.stringify(Object.keys(payload)) + ); + } else { + this.emit(`${type}${version}` as keyof EventTypes, payload.data); + } + if (id) { + const callback = this.#callbacks[id]; + if (!callback) { + this.error( + `Payload received for an id but no callbacks were registered. id=${id}` + ); + } else callback(payload); + } + } + + protected async sendRequest( + payload: any, + id: string = uuid(), + callback? + ): Promise { + return new Promise((resolve, reject) => { + if (!this.#connected) { + reject(new Error("send failed. not connected")); + } + if (callback) { + this.#callbacks[id] = callback; + } + payload["id"] = id; + const serialized = JSON.stringify(payload) + "\n"; + this.#client.write(serialized, "utf8", () => { + this.emit("transport_sent_payload", payload); + resolve(); + }); + }); + } + + protected async getResponse(payload: any): Promise { + return new Promise((resolve, reject) => { + const callback = (responsePayload: any) => { + try { + throwOnError(responsePayload); + } catch (e) { + reject(e); + } + resolve(responsePayload.data); + }; + + this.sendRequest(payload, uuid(), callback); + }); + } + + /** + * Sets the logger used to log events, debug, and error messages + * + * @param logger the logger implementation to use + */ + public setLogger(logger: Logger) { + this.#logger = logger; + } + + _log(level: LogLevel, message?: any, ...extra: any[]) { + if (!this.#logger) return; + this.#logger(level, `[signald] ${message}`); + } + + protected log(message?: any, ...extra: any[]) { + this._log("info", message, ...extra); + } + + protected debug(message?: any, ...extra: any[]) { + this._log("debug", message, ...extra); + } + + protected warn(message?: any, ...extra: any[]) { + this._log("warn", message, ...extra); + } + + protected error(message?: any, ...extra: any[]) { + this._log("error", message, ...extra); + } +} + +export type LogLevel = "debug" | "info" | "warn" | "error"; +export type Logger = (level: LogLevel, message?: any, ...extra: any[]) => void; +export const nullLogger = ( + level: LogLevel, + message?: any, + ...extra: any[] +) => {}; +export const consoleLogger = ( + level: LogLevel, + message?: any, + ...extra: any[] +) => console[level](message, ...extra); diff --git a/packages/node-signald/tsconfig.json b/packages/node-signald/tsconfig.json new file mode 100644 index 0000000..77496bb --- /dev/null +++ b/packages/node-signald/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "tsconfig-link", + "compilerOptions": { + "incremental": true, + "outDir": "build/main", + "rootDir": "src", + "baseUrl": "./", + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules/**"] +} diff --git a/packages/node-signald/util/generate.js b/packages/node-signald/util/generate.js new file mode 100644 index 0000000..bab35a3 --- /dev/null +++ b/packages/node-signald/util/generate.js @@ -0,0 +1,229 @@ +const net = require("net"); +const fs = require("fs"); +const process = require("process"); +const camelCase = require("camelcase"); + +const SOCKETFILE = + process.env.SIGNALD_SOCKET || "/run/user/1000/signald/signald.sock"; +const GENERATEDFILE = "./src/generated.ts"; +const VERSION = "v1"; +let globalVersion; + +const toJs = (name) => name.replace("-", "_"); + +const TYPES = { + String: "string", + long: "number", + int: "number", + Integer: "number", + Map: "Record", + boolean: "boolean", + Boolean: "boolean", + Long: "number", + UUID: "string", +}; + +const convertType = (field) => { + let name = ""; + if (field.version) { + name = `${field.type}${field.version}`; + } else { + if (!TYPES[field.type]) { + throw new Error( + `unknown type ${field.type} inside ${JSON.stringify(field)}` + ); + } + name = TYPES[field.type]; + } + + if (field.list) { + return `${name}[]`; + } + return name; +}; +const fieldToData = ([rawName, field]) => { + const name = toJs(rawName); + const type = convertType(field); + const requiredStr = field.required ? "" : "?"; + const example = field.example ? `\n Example: ${field.example}\n` : "\n"; + const doc = field.doc ? `\n/**\n ${field.doc}.${example} */\n` : ""; + let code = `${doc}${name}${requiredStr}: ${type};`; + return { + name, + rawName, + type, + code, + doc: field.doc, + example: field.example, + required: field.required, + }; +}; + +const typeToData = (name, version, data) => { + const typeName = `${name}${version}`; + const doc = data.doc + ? `\n/** + ${data.doc} + */\n` + : "\n"; + + const header = `export type ${typeName} = { +`; + const dataFields = Object.entries(data.fields).map(fieldToData); + const codeFields = dataFields.map((field) => field.code); + const body = codeFields.join("\n"); + const footer = "\n}\n"; + // ensure required fields are sorted before non-required fields + dataFields.sort((a, b) => + a.required === b.required ? 0 : a.required ? -1 : 1 + ); + return { + name, + version, + typeName, + fields: dataFields, + code: doc + header + body + footer, + }; +}; + +const actionToCode = (structures, rawName, version, data) => { + const name = camelCase(rawName); + const { request, response } = data; + const requestFields = structures[request].fields; + const returnTypeStr = response + ? `Promise<${response}${version}>` + : "Promise"; + let code = ` async ${name}(`; + requestFields.map((field) => { + const requiredStr = field.required ? "" : "?"; + code += `${field.name}${requiredStr}: ${field.type},\n`; + }); + + code += `): ${returnTypeStr} {\n`; + const params = requestFields + .map((field) => { + return `"${field.rawName}": ${field.name}`; + }) + .join(",\n"); + const requestStr = `{"type": "${rawName}","version": "${version}", ${params}}`; + const responseStr = `this.getResponse(${requestStr})`; + if (response) { + code += `return ${responseStr} as Promise<${response}${version}>`; + } else { + code += `await ${responseStr}; return;`; + } + code += "\n}\n"; + return code; +}; + +const saveFile = (code) => { + fs.writeFileSync(GENERATEDFILE, code); + console.log("generated code at ", GENERATEDFILE); +}; + +const processProtocolDefinition = (client, def, version) => { + console.log("processing protocol definition"); + const when = new Date(new Date().toUTCString()); + let contents = ` +/* Generated by the output of the 'protocol' command of signald + Date: ${when} + Version: ${globalVersion} + */ +import {JSONTransport} from "./util"; +`; + + const structures = {}; + // generate type definitions + for (version in def.types) { + Object.entries(def.types[version]).map(([name, data]) => { + structures[name] = typeToData(name, version, data); + contents += structures[name].code; + }); + } + + // TODO this should be improved once v0 is totally deprecated + const typeNames = ["version", "message", "subscribe", "unsubscribe"] + .map((name) => `"${name}"`) + .join(" | "); + contents += `\ndeclare module "./util" { + interface EventTypes { + /** + * The v0 event emitted when a signal message is received + * @event + */ + messagev0(message: JsonMessageEnvelopev1): void; + /** + * @event + */ + subscribev0(): void; + /** + * @event + */ + unsubscribev0(): void; + } +}\n`; + + // generate action functions + for (version in def.actions) { + if (version != VERSION) continue; + contents += "export class SignaldGeneratedApi extends JSONTransport{\n"; + Object.entries(def.actions[version]).map(([name, data]) => { + contents += actionToCode(structures, name, version, data); + }); + contents += "\n}\n"; + } + + saveFile(contents); + cleanup(); +}; + +// := Driver + +const getProtocolDefinition = (client) => { + const msg = { + type: "protocol", + }; + console.log("requesting protocol definition"); + client.write(JSON.stringify(msg) + "\n"); +}; +const client = net.createConnection(SOCKETFILE); +let buffer = ""; +client.setEncoding("utf8"); + +function cleanup() { + client.end(); + process.exit(0); +} +client.on("connect", () => { + console.log("connected"); + buffer = ""; +}); + +client.on("data", (payload) => { + buffer += payload; + if (!buffer.endsWith("\n")) { + return; + } else { + const data = buffer; + buffer = ""; + data.split("\n").forEach((line) => { + if (!line) return; + + const msg = JSON.parse(line); + if (msg.type === "version") { + getProtocolDefinition(client); + globalVersion = msg.data.version; + } + if (msg.type === "protocol") { + // console.log(JSON.stringify(msg.data, null, 2)); + processProtocolDefinition(client, msg.data, globalVersion); + } + }); + } +}); + +client.on("error", (err) => { + console.error(err); +}); + +process.on("SIGINT", cleanup);