@north-light/comms 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +257 -2
- package/bin/comms.js +17 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.js +307 -0
- package/dist/fake/cli.d.ts +14 -0
- package/dist/fake/cli.js +107 -0
- package/dist/fake/server.d.ts +33 -0
- package/dist/fake/server.js +213 -0
- package/dist/fake/store.d.ts +59 -0
- package/dist/fake/store.js +356 -0
- package/dist/helpers/agent-bridge.d.ts +72 -0
- package/dist/helpers/agent-bridge.js +48 -0
- package/dist/helpers/batching.d.ts +23 -0
- package/dist/helpers/batching.js +66 -0
- package/dist/helpers/format.d.ts +38 -0
- package/dist/helpers/format.js +164 -0
- package/dist/helpers/signup.d.ts +34 -0
- package/dist/helpers/signup.js +36 -0
- package/dist/helpers/typing.d.ts +14 -0
- package/dist/helpers/typing.js +31 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/types.d.ts +175 -0
- package/dist/types.js +3 -0
- package/dist/webhooks.d.ts +30 -0
- package/dist/webhooks.js +111 -0
- package/package.json +49 -4
package/dist/fake/cli.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `comms fake` and `comms fake send`. `run` takes its arguments and output as parameters so
|
|
2
|
+
// tests can drive it; bin/comms.js passes the process's.
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { DEFAULT_FAKE_PORT, startFakeServer, } from "./server.js";
|
|
5
|
+
const USAGE = `Usage:
|
|
6
|
+
comms fake --webhook <url> [--port ${DEFAULT_FAKE_PORT}] [--secret whsec_…]
|
|
7
|
+
Start a local Comms. Point CommsClient's baseUrl at it (any bearer token works) and
|
|
8
|
+
receive signed webhooks at <url>. Sends and reactions are logged here, never delivered.
|
|
9
|
+
comms fake send --from <+1555…> --text <text> [--user-id <id>] [--port ${DEFAULT_FAKE_PORT}]
|
|
10
|
+
Simulate a person texting your app. STOP, START and HELP work as they do in production.
|
|
11
|
+
`;
|
|
12
|
+
export class UsageError extends Error {
|
|
13
|
+
name = "UsageError";
|
|
14
|
+
}
|
|
15
|
+
function port(value) {
|
|
16
|
+
const parsed = value === undefined ? DEFAULT_FAKE_PORT : Number(value);
|
|
17
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535)
|
|
18
|
+
throw new UsageError(`--port must be a port number, got "${value}"`);
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Runs the CLI. `comms fake` resolves with the running server (the caller keeps the process
|
|
23
|
+
* alive); `comms fake send` resolves with `undefined` once the fake has answered.
|
|
24
|
+
*/
|
|
25
|
+
export async function run(argv, io) {
|
|
26
|
+
const [command, subcommand, ...rest] = argv;
|
|
27
|
+
if (command === undefined || command === "-h" || command === "--help") {
|
|
28
|
+
io.out(USAGE);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (command !== "fake")
|
|
32
|
+
throw new UsageError(`Unknown command "${command}"\n\n${USAGE}`);
|
|
33
|
+
if (subcommand === "send") {
|
|
34
|
+
const { values } = parseArgs({
|
|
35
|
+
args: rest,
|
|
36
|
+
options: {
|
|
37
|
+
from: { type: "string" },
|
|
38
|
+
text: { type: "string" },
|
|
39
|
+
"user-id": { type: "string" },
|
|
40
|
+
port: { type: "string" },
|
|
41
|
+
},
|
|
42
|
+
strict: true,
|
|
43
|
+
});
|
|
44
|
+
if (!values.from || values.text === undefined)
|
|
45
|
+
throw new UsageError(`\`comms fake send\` needs --from and --text\n\n${USAGE}`);
|
|
46
|
+
const target = `http://127.0.0.1:${port(values.port)}`;
|
|
47
|
+
let response;
|
|
48
|
+
try {
|
|
49
|
+
response = await fetch(`${target}/_fake/inbound`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
from: values.from,
|
|
54
|
+
text: values.text,
|
|
55
|
+
...(values["user-id"] ? { user_id: values["user-id"] } : {}),
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (cause) {
|
|
60
|
+
throw new UsageError(`No fake Comms answered at ${target}. Start one with \`comms fake --webhook <url>\`.`, { cause });
|
|
61
|
+
}
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
if (!response.ok)
|
|
64
|
+
throw new UsageError(`The fake refused the text: ${text}`);
|
|
65
|
+
io.out(text);
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (subcommand !== undefined && !subcommand.startsWith("-"))
|
|
69
|
+
throw new UsageError(`Unknown command "fake ${subcommand}"\n\n${USAGE}`);
|
|
70
|
+
const args = subcommand === undefined ? [] : [subcommand, ...rest];
|
|
71
|
+
const { values } = parseArgs({
|
|
72
|
+
args,
|
|
73
|
+
options: {
|
|
74
|
+
webhook: { type: "string" },
|
|
75
|
+
port: { type: "string" },
|
|
76
|
+
secret: { type: "string" },
|
|
77
|
+
help: { type: "boolean", short: "h" },
|
|
78
|
+
},
|
|
79
|
+
strict: true,
|
|
80
|
+
});
|
|
81
|
+
if (values.help) {
|
|
82
|
+
io.out(USAGE);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (!values.webhook)
|
|
86
|
+
throw new UsageError(`\`comms fake\` needs --webhook <url>\n\n${USAGE}`);
|
|
87
|
+
let server;
|
|
88
|
+
try {
|
|
89
|
+
server = await startFakeServer({
|
|
90
|
+
webhookUrl: values.webhook,
|
|
91
|
+
port: port(values.port),
|
|
92
|
+
log: io.out,
|
|
93
|
+
...(values.secret === undefined ? {} : { secret: values.secret }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (cause) {
|
|
97
|
+
if (cause instanceof UsageError)
|
|
98
|
+
throw cause;
|
|
99
|
+
throw new UsageError(`Could not start the fake: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
100
|
+
}
|
|
101
|
+
io.out(`Fake Comms listening on ${server.url}`);
|
|
102
|
+
io.out(` CommsClient baseUrl: ${server.url} (any bearer token works)`);
|
|
103
|
+
io.out(` Webhooks go to: ${values.webhook}`);
|
|
104
|
+
io.out(` Signing secret: ${server.secret}`);
|
|
105
|
+
io.out(` Simulate a text: comms fake send --from +15550001 --text "hi" --port ${server.port}`);
|
|
106
|
+
return server;
|
|
107
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { WebhookEvent } from "../types.js";
|
|
2
|
+
import { type FakeStoreOptions } from "./store.js";
|
|
3
|
+
export declare const DEFAULT_FAKE_PORT = 7481;
|
|
4
|
+
export interface FakeServerOptions {
|
|
5
|
+
/** Every webhook is POSTed here. */
|
|
6
|
+
webhookUrl: string;
|
|
7
|
+
/** The `whsec_…` signing secret. Default: a fresh random one. */
|
|
8
|
+
secret?: string;
|
|
9
|
+
/** Default 7481; `0` picks a free port. */
|
|
10
|
+
port?: number;
|
|
11
|
+
/** Default `127.0.0.1`: the fake is for the developer's own machine. */
|
|
12
|
+
host?: string;
|
|
13
|
+
/** Where log lines go. Default `console.log`. */
|
|
14
|
+
log?: (line: string) => void;
|
|
15
|
+
store?: FakeStoreOptions;
|
|
16
|
+
}
|
|
17
|
+
export interface FakeServer {
|
|
18
|
+
/** `http://127.0.0.1:<port>`: the `baseUrl` for `CommsClient`. */
|
|
19
|
+
url: string;
|
|
20
|
+
port: number;
|
|
21
|
+
secret: string;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** What happened to one webhook the fake tried to deliver. */
|
|
25
|
+
export interface Delivery {
|
|
26
|
+
event_id: string;
|
|
27
|
+
type: WebhookEvent["type"];
|
|
28
|
+
/** The HTTP status the app answered, or `null` when the request failed. */
|
|
29
|
+
status: number | null;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function newFakeSecret(): string;
|
|
33
|
+
export declare function startFakeServer(options: FakeServerOptions): Promise<FakeServer>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The local fake Comms: the send API's paths and bodies over HTTP, plus a control endpoint that
|
|
2
|
+
// simulates a person texting the app. Webhooks are signed with the shared `sign`, so they are
|
|
3
|
+
// byte-for-byte what a real delivery looks like. The only outbound request this server ever
|
|
4
|
+
// makes is the POST to the webhook URL given at start.
|
|
5
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
|
+
import { createServer, } from "node:http";
|
|
7
|
+
import { sign } from "../webhooks.js";
|
|
8
|
+
import { FakeApiError, FakeStore, } from "./store.js";
|
|
9
|
+
export const DEFAULT_FAKE_PORT = 7481;
|
|
10
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
11
|
+
const WEBHOOK_TIMEOUT_MS = 15_000;
|
|
12
|
+
export function newFakeSecret() {
|
|
13
|
+
return `whsec_${randomBytes(32).toString("base64")}`;
|
|
14
|
+
}
|
|
15
|
+
export async function startFakeServer(options) {
|
|
16
|
+
const webhook = new URL(options.webhookUrl);
|
|
17
|
+
if (webhook.protocol !== "http:" && webhook.protocol !== "https:")
|
|
18
|
+
throw new TypeError("The webhook URL must be http or https");
|
|
19
|
+
const secret = options.secret ?? newFakeSecret();
|
|
20
|
+
sign(secret, "check", 0, ""); // refuses a malformed secret before listening
|
|
21
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
22
|
+
const store = new FakeStore(options.store);
|
|
23
|
+
let origin = "";
|
|
24
|
+
const logSend = (send) => {
|
|
25
|
+
const target = `${send.handle} (${send.contactId})`;
|
|
26
|
+
if (send.kind === "reaction")
|
|
27
|
+
log(`→ reaction ${send.reaction} on ${send.targetMessageId} to ${target} [${send.messageId}]`);
|
|
28
|
+
else
|
|
29
|
+
log(`→ ${send.kind === "system" ? "system text" : "text"} to ${target} [${send.messageId}]: ${send.text ?? ""}${send.fileUrl ? ` (file ${send.fileUrl})` : ""}`);
|
|
30
|
+
};
|
|
31
|
+
const deliver = async (event) => {
|
|
32
|
+
const body = JSON.stringify(event);
|
|
33
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
34
|
+
const delivery = {
|
|
35
|
+
event_id: event.id,
|
|
36
|
+
type: event.type,
|
|
37
|
+
status: null,
|
|
38
|
+
};
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(webhook, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
"webhook-id": event.id,
|
|
45
|
+
"webhook-timestamp": String(timestamp),
|
|
46
|
+
"webhook-signature": sign(secret, event.id, timestamp, body),
|
|
47
|
+
},
|
|
48
|
+
body,
|
|
49
|
+
redirect: "manual",
|
|
50
|
+
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
|
51
|
+
});
|
|
52
|
+
await response.body?.cancel();
|
|
53
|
+
delivery.status = response.status;
|
|
54
|
+
log(`⇢ webhook ${event.type} ${event.id} → ${webhook.href}: ${response.status}`);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
delivery.error = error instanceof Error ? error.message : String(error);
|
|
58
|
+
log(`⇢ webhook ${event.type} ${event.id} → ${webhook.href}: failed (${delivery.error})`);
|
|
59
|
+
}
|
|
60
|
+
return delivery;
|
|
61
|
+
};
|
|
62
|
+
const route = async (request, response) => {
|
|
63
|
+
const url = new URL(request.url ?? "/", "http://fake");
|
|
64
|
+
const path = url.pathname;
|
|
65
|
+
const method = request.method ?? "GET";
|
|
66
|
+
const send = (status, payload) => {
|
|
67
|
+
if (payload === undefined)
|
|
68
|
+
response.writeHead(status).end();
|
|
69
|
+
else
|
|
70
|
+
response
|
|
71
|
+
.writeHead(status, { "content-type": "application/json" })
|
|
72
|
+
.end(JSON.stringify(payload));
|
|
73
|
+
};
|
|
74
|
+
if (method === "GET" && path.startsWith("/_fake/sign-in/")) {
|
|
75
|
+
const { event, returnTo } = store.consumeSignInLink(decodeURIComponent(path.slice("/_fake/sign-in/".length)));
|
|
76
|
+
log("← sign-in link pressed (fake)");
|
|
77
|
+
await deliver(event);
|
|
78
|
+
if (returnTo)
|
|
79
|
+
response.writeHead(303, { location: returnTo }).end();
|
|
80
|
+
else
|
|
81
|
+
response
|
|
82
|
+
.writeHead(200, { "content-type": "text/plain" })
|
|
83
|
+
.end("Signed in (fake). You can close this page.");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const input = method === "POST" ? await readJson(request) : undefined;
|
|
87
|
+
if (method === "POST" && path === "/_fake/inbound") {
|
|
88
|
+
const result = store.inbound(input);
|
|
89
|
+
const source = input;
|
|
90
|
+
log(`← inbound from ${source.from} (${result.contactId}) [${result.messageId}]: ${source.text}`);
|
|
91
|
+
for (const logged of result.sends)
|
|
92
|
+
logSend(logged);
|
|
93
|
+
const deliveries = [];
|
|
94
|
+
for (const event of result.events)
|
|
95
|
+
deliveries.push(await deliver(event));
|
|
96
|
+
send(200, {
|
|
97
|
+
message_id: result.messageId,
|
|
98
|
+
contact_id: result.contactId,
|
|
99
|
+
disposition: result.disposition,
|
|
100
|
+
deliveries,
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (path.startsWith("/v1/")) {
|
|
105
|
+
if (!/^Bearer \S+$/.test(request.headers.authorization ?? ""))
|
|
106
|
+
throw new FakeApiError(401, "unauthorized", "Send `Authorization: Bearer <any token>`");
|
|
107
|
+
const segments = path.slice(1).split("/").map(decodeURIComponent);
|
|
108
|
+
const [, resource, resourceId, action] = segments;
|
|
109
|
+
if (method === "POST" && path === "/v1/messages") {
|
|
110
|
+
const sent = store.sendMessage(input);
|
|
111
|
+
if (sent.send)
|
|
112
|
+
logSend(sent.send);
|
|
113
|
+
return send(202, sent.answer);
|
|
114
|
+
}
|
|
115
|
+
if (method === "POST" &&
|
|
116
|
+
resource === "messages" &&
|
|
117
|
+
resourceId &&
|
|
118
|
+
action === "reactions" &&
|
|
119
|
+
segments.length === 4) {
|
|
120
|
+
const sent = store.sendReaction(resourceId, input);
|
|
121
|
+
if (sent.send)
|
|
122
|
+
logSend(sent.send);
|
|
123
|
+
return send(202, sent.answer);
|
|
124
|
+
}
|
|
125
|
+
if (method === "GET" &&
|
|
126
|
+
resource === "messages" &&
|
|
127
|
+
resourceId &&
|
|
128
|
+
segments.length === 3)
|
|
129
|
+
return send(200, store.getMessage(resourceId));
|
|
130
|
+
if (method === "GET" &&
|
|
131
|
+
resource === "contacts" &&
|
|
132
|
+
resourceId &&
|
|
133
|
+
segments.length === 3)
|
|
134
|
+
return send(200, store.getContact(resourceId));
|
|
135
|
+
if (method === "POST" &&
|
|
136
|
+
resource === "contacts" &&
|
|
137
|
+
resourceId &&
|
|
138
|
+
action === "typing" &&
|
|
139
|
+
segments.length === 4) {
|
|
140
|
+
store.setTyping(resourceId, input);
|
|
141
|
+
return send(204);
|
|
142
|
+
}
|
|
143
|
+
if (method === "POST" &&
|
|
144
|
+
resource === "contacts" &&
|
|
145
|
+
resourceId &&
|
|
146
|
+
action === "sign-in-link" &&
|
|
147
|
+
segments.length === 4)
|
|
148
|
+
return send(201, store.signInLink(resourceId, input, origin));
|
|
149
|
+
if (method === "GET" && path === "/v1/numbers/public")
|
|
150
|
+
return send(200, { number: store.publicNumber() });
|
|
151
|
+
}
|
|
152
|
+
throw new FakeApiError(404, "not_found", `No route for ${method} ${path}`);
|
|
153
|
+
};
|
|
154
|
+
const server = createServer((request, response) => {
|
|
155
|
+
route(request, response).catch((error) => {
|
|
156
|
+
const failure = error instanceof FakeApiError
|
|
157
|
+
? error
|
|
158
|
+
: new FakeApiError(500, "server_error", error instanceof Error ? error.message : String(error));
|
|
159
|
+
if (response.headersSent) {
|
|
160
|
+
response.end();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
response
|
|
164
|
+
.writeHead(failure.status, { "content-type": "application/json" })
|
|
165
|
+
.end(JSON.stringify({
|
|
166
|
+
error: {
|
|
167
|
+
code: failure.code,
|
|
168
|
+
message: failure.message,
|
|
169
|
+
...(failure.param ? { param: failure.param } : {}),
|
|
170
|
+
origin: "comms",
|
|
171
|
+
retryable: failure.status >= 500,
|
|
172
|
+
request_id: randomUUID(),
|
|
173
|
+
},
|
|
174
|
+
}));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
await new Promise((resolve, reject) => {
|
|
178
|
+
server.once("error", reject);
|
|
179
|
+
server.listen(options.port ?? DEFAULT_FAKE_PORT, options.host ?? "127.0.0.1", () => {
|
|
180
|
+
server.off("error", reject);
|
|
181
|
+
resolve();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
const address = server.address();
|
|
185
|
+
origin = `http://${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`;
|
|
186
|
+
return {
|
|
187
|
+
url: origin,
|
|
188
|
+
port: address.port,
|
|
189
|
+
secret,
|
|
190
|
+
close: () => new Promise((resolve, reject) => {
|
|
191
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
192
|
+
server.closeAllConnections();
|
|
193
|
+
}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
async function readJson(request) {
|
|
197
|
+
const chunks = [];
|
|
198
|
+
let size = 0;
|
|
199
|
+
for await (const chunk of request) {
|
|
200
|
+
size += chunk.length;
|
|
201
|
+
if (size > MAX_BODY_BYTES)
|
|
202
|
+
throw new FakeApiError(413, "invalid_request", "The request body is too large");
|
|
203
|
+
chunks.push(chunk);
|
|
204
|
+
}
|
|
205
|
+
if (size === 0)
|
|
206
|
+
return undefined;
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
throw new FakeApiError(400, "invalid_request", "The request body is not valid JSON");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Contact, MessageRecord, QueuedMessage, SignInLink, WebhookEvent } from "../types.js";
|
|
2
|
+
/** The fictional app number every fake contact texts. */
|
|
3
|
+
export declare const FAKE_NUMBER = "+15555550100";
|
|
4
|
+
/** A refusal the server answers in the shared error envelope. */
|
|
5
|
+
export declare class FakeApiError extends Error {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly code: string;
|
|
8
|
+
readonly param?: string | undefined;
|
|
9
|
+
readonly name = "FakeApiError";
|
|
10
|
+
constructor(status: number, code: string, message: string, param?: string | undefined);
|
|
11
|
+
}
|
|
12
|
+
/** Something the fake "sent": logged by the server, never delivered. */
|
|
13
|
+
export interface LoggedSend {
|
|
14
|
+
messageId: string;
|
|
15
|
+
contactId: string;
|
|
16
|
+
handle: string;
|
|
17
|
+
kind: "message" | "reaction" | "system";
|
|
18
|
+
text?: string;
|
|
19
|
+
fileUrl?: string;
|
|
20
|
+
reaction?: string;
|
|
21
|
+
targetMessageId?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface InboundResult {
|
|
24
|
+
disposition: "forwarded" | "stop" | "start" | "help" | "opted_out_ignored";
|
|
25
|
+
messageId: string;
|
|
26
|
+
contactId: string;
|
|
27
|
+
events: WebhookEvent[];
|
|
28
|
+
sends: LoggedSend[];
|
|
29
|
+
}
|
|
30
|
+
export interface FakeStoreOptions {
|
|
31
|
+
/** The display name in STOP and START replies. */
|
|
32
|
+
displayName?: string;
|
|
33
|
+
helpText?: string;
|
|
34
|
+
now?: () => Date;
|
|
35
|
+
}
|
|
36
|
+
export declare class FakeStore {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(options?: FakeStoreOptions);
|
|
39
|
+
/** A person texts the app's fictional number. `userId` makes the contact a Northlight user. */
|
|
40
|
+
inbound(input: unknown): InboundResult;
|
|
41
|
+
sendMessage(input: unknown): {
|
|
42
|
+
answer: QueuedMessage;
|
|
43
|
+
send?: LoggedSend;
|
|
44
|
+
};
|
|
45
|
+
sendReaction(targetMessageId: string, input: unknown): {
|
|
46
|
+
answer: QueuedMessage;
|
|
47
|
+
send?: LoggedSend;
|
|
48
|
+
};
|
|
49
|
+
getMessage(messageId: string): MessageRecord;
|
|
50
|
+
getContact(contactId: string): Contact;
|
|
51
|
+
setTyping(contactId: string, input: unknown): void;
|
|
52
|
+
publicNumber(): string;
|
|
53
|
+
signInLink(contactId: string, input: unknown, origin: string): SignInLink;
|
|
54
|
+
/** Pressing a fake sign-in link links the contact to a fake user. */
|
|
55
|
+
consumeSignInLink(token: string): {
|
|
56
|
+
event: WebhookEvent;
|
|
57
|
+
returnTo: string | undefined;
|
|
58
|
+
};
|
|
59
|
+
}
|