@stage-labs/metro 0.1.0-beta.316 → 0.1.0-beta.317
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/package.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +23 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +30 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +9 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/pair-again.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +6 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +13 -4
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +3 -2
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +35 -40
- package/runtime/node_modules/@metro-labs/whatsapp/src/connection.ts +139 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +36 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +24 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +46 -15
- package/runtime/node_modules/@metro-labs/whatsapp/src/profile.ts +158 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./lines": "./src/lines.ts",
|
|
27
27
|
"./endpoints": "./src/endpoints.ts",
|
|
28
28
|
"./stations/types": "./src/stations/types.ts",
|
|
29
|
+
"./stations/account-status": "./src/stations/account-status.ts",
|
|
29
30
|
"./stations/channel-directory": "./src/stations/channel-directory.ts",
|
|
30
31
|
"./stations/channel-pages": "./src/stations/channel-pages.ts",
|
|
31
32
|
"./stations/account-files": "./src/stations/account-files.ts",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isRecord } from '../is-record.js';
|
|
2
|
+
|
|
3
|
+
export const ACCOUNT_STATES = ['connected', 'connecting', 'logged_out', 'disconnected'] as const;
|
|
4
|
+
|
|
5
|
+
export type AccountState = (typeof ACCOUNT_STATES)[number];
|
|
6
|
+
|
|
7
|
+
export interface AccountStatus {
|
|
8
|
+
state: AccountState;
|
|
9
|
+
reason?: string;
|
|
10
|
+
since: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const REASON_MAX = 300;
|
|
14
|
+
|
|
15
|
+
export function accountStatus(state: AccountState, reason?: string, since = Date.now()): AccountStatus {
|
|
16
|
+
const text = reason?.trim().slice(0, REASON_MAX) ?? '';
|
|
17
|
+
return text === '' ? { state, since } : { state, reason: text, since };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function accountStatusOf(raw: unknown): AccountStatus | null {
|
|
21
|
+
if (!isRecord(raw)) return null;
|
|
22
|
+
const state = ACCOUNT_STATES.find((s) => s === raw.state);
|
|
23
|
+
const since = raw.since;
|
|
24
|
+
if (state === undefined || typeof since !== 'number' || !Number.isFinite(since)) return null;
|
|
25
|
+
return accountStatus(state, typeof raw.reason === 'string' ? raw.reason : undefined, since);
|
|
26
|
+
}
|
|
@@ -114,6 +114,8 @@ export interface Station {
|
|
|
114
114
|
readFilters?: ReadonlySet<ReadFilter>;
|
|
115
115
|
approvals?: boolean;
|
|
116
116
|
sendsFrames?: boolean;
|
|
117
|
+
reportsStatus?: boolean;
|
|
118
|
+
pairAgain?: { identity: string };
|
|
117
119
|
attachmentMode: AttachmentMode;
|
|
118
120
|
forget?: (accountId: string) => void;
|
|
119
121
|
forgetExcept?: (keptAccountIds: readonly string[]) => void;
|
|
@@ -13,6 +13,7 @@ export type AccountRoute =
|
|
|
13
13
|
| { kind: 'enabled'; station: StationName; accountId: string }
|
|
14
14
|
| { kind: 'policy'; station: StationName; accountId: string }
|
|
15
15
|
| { kind: 'profiles'; station: StationName; accountId: string }
|
|
16
|
+
| { kind: 'repair'; station: StationName; accountId: string }
|
|
16
17
|
| { kind: 'name'; station: StationName; accountId: string };
|
|
17
18
|
|
|
18
19
|
const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
@@ -26,10 +27,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
26
27
|
enabled: ['PUT'],
|
|
27
28
|
policy: ['PUT'],
|
|
28
29
|
profiles: ['GET'],
|
|
30
|
+
repair: ['POST'],
|
|
29
31
|
name: ['GET', 'POST'],
|
|
30
32
|
};
|
|
31
33
|
|
|
32
|
-
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles'] as const;
|
|
34
|
+
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles', 'repair'] as const;
|
|
33
35
|
|
|
34
36
|
type SubRoute = (typeof SUB_ROUTES)[number];
|
|
35
37
|
|
|
@@ -37,6 +37,7 @@ interface AttachSessionApi {
|
|
|
37
37
|
owner: AttachOwner,
|
|
38
38
|
station: InteractiveStation,
|
|
39
39
|
input: Record<string, unknown>,
|
|
40
|
+
repair?: string,
|
|
40
41
|
) => Promise<AttachView>;
|
|
41
42
|
view: (owner: AttachOwner, attachId: string) => AttachView;
|
|
42
43
|
submit: (
|
|
@@ -315,16 +316,36 @@ async function handleStep(
|
|
|
315
316
|
sendJson(req, res, 200, view);
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
async function handleRepair(
|
|
320
|
+
req: IncomingMessage,
|
|
321
|
+
res: ServerResponse,
|
|
322
|
+
deps: AccountApiDeps,
|
|
323
|
+
owner: AttachOwner,
|
|
324
|
+
target: { station: StationName; accountId: string },
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
const { station, accountId } = target;
|
|
327
|
+
if (!isInteractiveStation(station) || stationByName(station)?.pairAgain === undefined)
|
|
328
|
+
throw new ApiError(`${station} cannot pair again`, 400);
|
|
329
|
+
const view = await deps.attachSessions.start(owner, station, asInput(await readJsonBody(req)), accountId);
|
|
330
|
+
sendJson(req, res, 201, view);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
type AttachRoute = Extract<AccountRoute, { kind: 'start' | 'session' | 'step' | 'repair' }>;
|
|
334
|
+
|
|
335
|
+
const isAttachRoute = (route: AccountRoute): route is AttachRoute =>
|
|
336
|
+
route.kind === 'start' || route.kind === 'session' || route.kind === 'step' || route.kind === 'repair';
|
|
337
|
+
|
|
318
338
|
async function dispatchAttach(
|
|
319
339
|
req: IncomingMessage,
|
|
320
340
|
res: ServerResponse,
|
|
321
341
|
deps: AccountApiDeps,
|
|
322
342
|
agentId: string,
|
|
323
|
-
route:
|
|
343
|
+
route: AttachRoute,
|
|
324
344
|
session?: ApiSession,
|
|
325
345
|
): Promise<void> {
|
|
326
346
|
const owner: AttachOwner = { agentId, organization: session?.subject, userId: session?.userId, sessionId: session?.sessionId, authorization: req.headers.authorization };
|
|
327
347
|
if (route.kind === 'start') return handleStart(req, res, deps, agentId, owner);
|
|
348
|
+
if (route.kind === 'repair') return handleRepair(req, res, deps, owner, route);
|
|
328
349
|
if (route.kind === 'session') return handleSession(req, res, deps, owner, route.attachId);
|
|
329
350
|
return handleStep(req, res, deps, owner, route.attachId);
|
|
330
351
|
}
|
|
@@ -337,7 +358,7 @@ async function dispatchRoute(
|
|
|
337
358
|
route: AccountRoute,
|
|
338
359
|
session?: ApiSession,
|
|
339
360
|
): Promise<void> {
|
|
340
|
-
if (route
|
|
361
|
+
if (isAttachRoute(route)) return dispatchAttach(req, res, deps, agentId, route, session);
|
|
341
362
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
|
|
342
363
|
if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
|
|
343
364
|
if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type AccountRoute,
|
|
14
14
|
} from './account-routes.js';
|
|
15
15
|
import type { AgentSummary } from './admin.js';
|
|
16
|
+
import { pairAgainStations, statusStations } from '../stations/registry.js';
|
|
16
17
|
|
|
17
18
|
const PREFIX = '/api/agents';
|
|
18
19
|
|
|
@@ -70,6 +71,8 @@ async function handleList(
|
|
|
70
71
|
capabilities: deps.capabilities(),
|
|
71
72
|
features: deps.features ?? [],
|
|
72
73
|
attachable: deps.attachable ?? ATTACHABLE,
|
|
74
|
+
reportsStatus: statusStations(),
|
|
75
|
+
pairAgain: pairAgainStations(),
|
|
73
76
|
...(deps.toolGroups === undefined ? {} : { tools: deps.toolGroups() }),
|
|
74
77
|
};
|
|
75
78
|
if (!wantsAccounts(req)) {
|
|
@@ -210,6 +210,23 @@ export function localRenewGmail(agentId: string, accountId: string, previous: st
|
|
|
210
210
|
return { agentId, station: 'gmail', accountId };
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
const sameIdentity = (a: unknown, b: unknown): boolean =>
|
|
214
|
+
typeof a === 'string' && typeof b === 'string' && a.replace(/[\s()+-]/g, '').toLowerCase() === b.replace(/[\s()+-]/g, '').toLowerCase();
|
|
215
|
+
|
|
216
|
+
const shownIdentity = (value: unknown): string => (typeof value === 'string' && value !== '' ? value : 'unknown');
|
|
217
|
+
|
|
218
|
+
export function localPairAgain(agentId: string, station: StationName, accountId: string, identity: string, config: Record<string, unknown>, dir = agentsDir()): AccountRef {
|
|
219
|
+
const stored = agentOrThrow(agentId, dir);
|
|
220
|
+
const account = stored.file.stations.find((s) => s.station === station && s.id === accountId);
|
|
221
|
+
if (account === undefined) throw new AgentAdminError('This channel is gone. Nothing was changed.', 404);
|
|
222
|
+
const before = account.config[identity];
|
|
223
|
+
const after = config[identity];
|
|
224
|
+
if (!sameIdentity(before, after)) throw new AgentAdminError(`This channel is ${shownIdentity(before)}, not ${shownIdentity(after)}. Nothing was changed.`, 400);
|
|
225
|
+
account.config = { ...account.config, ...config };
|
|
226
|
+
save(stored);
|
|
227
|
+
return { agentId, station, accountId };
|
|
228
|
+
}
|
|
229
|
+
|
|
213
230
|
export function readLocalAgentFile(agentId: string, dir = agentsDir()): AgentFile {
|
|
214
231
|
const found = storedAgents(dir).find((s) => s.file.id === agentId);
|
|
215
232
|
if (found === undefined) throw missing();
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../stations/registry.js';
|
|
7
7
|
import { listEndpoints } from '../stations/webhook-endpoints.js';
|
|
8
8
|
import { hookUrl, threemaCallbackUrl } from '../stations/attach.js';
|
|
9
|
+
import { accountStatusOf } from '@metro-labs/core/stations/account-status';
|
|
9
10
|
import { accountEnabled, agentIdForAccount, allowlistForAccount, approversForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
10
11
|
|
|
11
12
|
const accountId = (acc: unknown): string | undefined => {
|
|
@@ -85,8 +86,33 @@ function withCallbackUrl(acc: unknown): unknown {
|
|
|
85
86
|
return { ...rest, callback: threemaCallbackUrl(callbackId, callbackToken) };
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
function withStatus(reports: boolean, acc: unknown): unknown {
|
|
90
|
+
const rec = asRecord(acc);
|
|
91
|
+
if (rec === undefined || !('status' in rec)) return acc;
|
|
92
|
+
const { status, ...rest } = rec;
|
|
93
|
+
const live = reports ? accountStatusOf(status) : null;
|
|
94
|
+
return live === null ? rest : { ...rest, status: live };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function decorate(station: string, rows: unknown[]): unknown[] {
|
|
98
|
+
const reports = stationByName(station)?.reportsStatus === true;
|
|
99
|
+
const live = rows.map((row) => withStatus(reports, row));
|
|
100
|
+
return station === 'threema' ? live.map(withCallbackUrl) : live;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const STATUS_WAIT_MS = 5_000;
|
|
104
|
+
|
|
105
|
+
function answerIn<T>(call: Promise<T>, ms: number): Promise<T> {
|
|
106
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
107
|
+
const late = new Promise<never>((_resolve, reject) => {
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
reject(new Error(`no answer within ${String(ms)} ms`));
|
|
110
|
+
}, ms);
|
|
111
|
+
});
|
|
112
|
+
return Promise.race([call, late]).finally(() => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
90
116
|
|
|
91
117
|
export interface ScopedAccounts {
|
|
92
118
|
accounts: Record<string, unknown[]>;
|
|
@@ -99,7 +125,8 @@ async function liveAccounts(
|
|
|
99
125
|
if (!hasTrain(station))
|
|
100
126
|
return { rows: inCoreAccounts(station), reachable: true };
|
|
101
127
|
try {
|
|
102
|
-
const
|
|
128
|
+
const call = forwardTrainCall(station, 'accounts', {});
|
|
129
|
+
const resp = await (stationByName(station)?.reportsStatus === true ? answerIn(call, STATUS_WAIT_MS) : call);
|
|
103
130
|
const list = (resp.result as { accounts?: unknown[] } | undefined)?.accounts;
|
|
104
131
|
return { rows: decorate(station, Array.isArray(list) ? list : []), reachable: true };
|
|
105
132
|
} catch {
|
|
@@ -4,6 +4,7 @@ import { AttachSessions } from '../stations/attach-session.js';
|
|
|
4
4
|
import { startInteractiveAttach } from '../stations/attach-interactive.js';
|
|
5
5
|
import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail-attach.js';
|
|
6
6
|
import { detachWithGmailRevocation } from './gmail-detach.js';
|
|
7
|
+
import { finishPairAgain, pairAgainInput } from './pair-again.js';
|
|
7
8
|
import { recentSenders } from '../agents/senders.js';
|
|
8
9
|
import { forwardTrainCall } from '../stations/train-call.js';
|
|
9
10
|
import type { AgentApiDeps } from '../agents/api.js';
|
|
@@ -44,11 +45,16 @@ function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
|
44
45
|
assertAttachOwner(owner);
|
|
45
46
|
return Promise.resolve();
|
|
46
47
|
},
|
|
47
|
-
start: (station, input, hooks, owner
|
|
48
|
-
|
|
48
|
+
start: (station, input, hooks, owner, repair) => {
|
|
49
|
+
if (repair !== undefined) return startInteractiveAttach(station, pairAgainInput(owner, station, repair, input), hooks);
|
|
50
|
+
return station === 'gmail' ? startGmailAttach(station, input, hooks, owner) : startInteractiveAttach(station, input, hooks);
|
|
51
|
+
},
|
|
52
|
+
complete: async (owner, station, config, active, repair) => {
|
|
49
53
|
assertAttachOwner(owner);
|
|
50
54
|
if (!active()) throw new ApiError('This sign-in was cancelled or expired.', 409);
|
|
51
|
-
const ref =
|
|
55
|
+
const ref = repair !== undefined
|
|
56
|
+
? finishPairAgain(owner, station, repair, config)
|
|
57
|
+
: (station === 'gmail' ? finishGmailUpgrade(owner, config) : null) ?? await localAttachAccount(owner.agentId, station, config);
|
|
52
58
|
const activated = await deps.syncStations(station).then(
|
|
53
59
|
() => true,
|
|
54
60
|
(err: unknown) => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
|
+
import type { StationName } from '@metro-labs/core/station-names';
|
|
3
|
+
import { localPairAgain, readLocalAgentFile } from '../agents/file-admin.js';
|
|
4
|
+
import type { AccountRef } from '../agents/account-attach.js';
|
|
5
|
+
import { stationByName } from '../stations/registry.js';
|
|
6
|
+
import type { AttachOwner } from '../stations/attach-session.js';
|
|
7
|
+
|
|
8
|
+
function identityOf(station: string): string {
|
|
9
|
+
const identity = stationByName(station)?.pairAgain?.identity;
|
|
10
|
+
if (identity === undefined) throw new ApiError(`${station} cannot pair again`, 400);
|
|
11
|
+
return identity;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function pairAgainInput(owner: AttachOwner, station: string, accountId: string, input: Record<string, unknown>): Record<string, unknown> {
|
|
15
|
+
const identity = identityOf(station);
|
|
16
|
+
const account = readLocalAgentFile(owner.agentId).stations.find((s) => s.station === station && s.id === accountId);
|
|
17
|
+
if (account === undefined) throw new ApiError('No such channel on this agent.', 404);
|
|
18
|
+
return { ...input, expect: account.config[identity] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function finishPairAgain(owner: AttachOwner, station: StationName, accountId: string, config: Record<string, unknown>): AccountRef {
|
|
22
|
+
return localPairAgain(owner.agentId, station, accountId, identityOf(station), config);
|
|
23
|
+
}
|
|
@@ -19,6 +19,7 @@ export type StartAttach = (
|
|
|
19
19
|
input: Record<string, unknown>,
|
|
20
20
|
hooks: DriverHooks,
|
|
21
21
|
owner: AttachOwner,
|
|
22
|
+
repair?: string,
|
|
22
23
|
) => Promise<StartedAttach>;
|
|
23
24
|
|
|
24
25
|
export const ATTACH_ID_RE = /^as_[A-Za-z0-9_-]{22}$/;
|
|
@@ -60,6 +61,7 @@ type CompleteAttach = (
|
|
|
60
61
|
station: StationName,
|
|
61
62
|
config: Record<string, unknown>,
|
|
62
63
|
active: () => boolean,
|
|
64
|
+
repair?: string,
|
|
63
65
|
) => Promise<{ accountId: string; activated: boolean }>;
|
|
64
66
|
|
|
65
67
|
type AuthorizeAttach = (owner: AttachOwner) => Promise<void>;
|
|
@@ -72,6 +74,7 @@ export interface AttachSessionDeps {
|
|
|
72
74
|
|
|
73
75
|
interface Session {
|
|
74
76
|
owner: AttachOwner;
|
|
77
|
+
repair?: string;
|
|
75
78
|
driver: AttachDriver | null;
|
|
76
79
|
view: AttachView;
|
|
77
80
|
finishing?: boolean;
|
|
@@ -177,6 +180,7 @@ export class AttachSessions {
|
|
|
177
180
|
session.committing = true;
|
|
178
181
|
return true;
|
|
179
182
|
},
|
|
183
|
+
session.repair,
|
|
180
184
|
);
|
|
181
185
|
this.settle(session, {
|
|
182
186
|
status: 'done',
|
|
@@ -187,12 +191,12 @@ export class AttachSessions {
|
|
|
187
191
|
});
|
|
188
192
|
log.info(
|
|
189
193
|
{ agentId: session.owner.agentId, station, account: landed.accountId },
|
|
190
|
-
'attach-session: signed in and attached a station account',
|
|
194
|
+
session.repair === undefined ? 'attach-session: signed in and attached a station account' : 'attach-session: paired a station account again',
|
|
191
195
|
);
|
|
192
196
|
} catch (err) {
|
|
193
197
|
const reason =
|
|
194
198
|
err instanceof ApiError ? err.message : 'could not store the account';
|
|
195
|
-
log.warn({ station, err: errMsg(err) }, 'attach-session: store failed');
|
|
199
|
+
log.warn({ station, ...(err instanceof ApiError ? { status: err.status } : { err: errMsg(err).replace(/\d{6,}/g, '<number>') }) }, 'attach-session: store failed');
|
|
196
200
|
this.settle(session, { status: 'failed', prompt: '', error: reason });
|
|
197
201
|
}
|
|
198
202
|
}
|
|
@@ -230,6 +234,7 @@ export class AttachSessions {
|
|
|
230
234
|
owner: AttachOwner,
|
|
231
235
|
station: InteractiveStation,
|
|
232
236
|
input: Record<string, unknown>,
|
|
237
|
+
repair?: string,
|
|
233
238
|
): Promise<AttachView> {
|
|
234
239
|
await this.deps.authorize(owner);
|
|
235
240
|
this.admit(owner);
|
|
@@ -237,6 +242,7 @@ export class AttachSessions {
|
|
|
237
242
|
const attachId = newAttachId();
|
|
238
243
|
const session: Session = {
|
|
239
244
|
owner,
|
|
245
|
+
...(repair === undefined ? {} : { repair }),
|
|
240
246
|
driver: null,
|
|
241
247
|
view: blankView(attachId, station),
|
|
242
248
|
};
|
|
@@ -248,6 +254,7 @@ export class AttachSessions {
|
|
|
248
254
|
input,
|
|
249
255
|
this.hooksFor(attachId, station),
|
|
250
256
|
owner,
|
|
257
|
+
repair,
|
|
251
258
|
);
|
|
252
259
|
if (this.sessions.get(attachId) !== session || session.view.expiresAt <= Date.now()) {
|
|
253
260
|
await started.driver.cancel();
|
|
@@ -41,6 +41,12 @@ export const accountStationCapabilities = (): Record<string, string[]> => {
|
|
|
41
41
|
return out;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
export const statusStations = (): string[] =>
|
|
45
|
+
STATIONS.filter((s) => s.reportsStatus === true).map((s) => s.name);
|
|
46
|
+
|
|
47
|
+
export const pairAgainStations = (): string[] =>
|
|
48
|
+
STATIONS.filter((s) => s.pairAgain !== undefined).map((s) => s.name);
|
|
49
|
+
|
|
44
50
|
export function forgetOrphans(known: readonly { station: string; id: string }[]): void {
|
|
45
51
|
for (const s of STATIONS) {
|
|
46
52
|
if (s.forgetExcept === undefined) continue;
|
|
@@ -11,6 +11,8 @@ const CLIENT_NAME = 'Metro';
|
|
|
11
11
|
|
|
12
12
|
export class OAuthInvalidGrant extends ConnectorVerifyError {}
|
|
13
13
|
|
|
14
|
+
const SIGN_IN_AGAIN = new Set(['invalid_grant', 'invalid_client', 'unauthorized_client']);
|
|
15
|
+
|
|
14
16
|
export interface OAuthClient {
|
|
15
17
|
clientId: string;
|
|
16
18
|
clientSecret?: string;
|
|
@@ -143,10 +145,17 @@ async function postToken(
|
|
|
143
145
|
} catch (err) {
|
|
144
146
|
throw refused(`Metro could not reach that server to finish signing in: ${whyUnreachable(err)}`);
|
|
145
147
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
return tokenAnswer(form.grant_type === 'refresh_token', res.status, res.ok, await res.json().catch(() => null));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const errorCode = (body: unknown): string | null => (isRecord(body) && typeof body.error === 'string' && /^[a-z_]{1,64}$/.test(body.error) ? body.error : null);
|
|
152
|
+
|
|
153
|
+
const grantGone = (refreshing: boolean, status: number, code: string | null): boolean => refreshing && (status === 400 || status === 401) && code !== null && SIGN_IN_AGAIN.has(code);
|
|
154
|
+
|
|
155
|
+
function tokenAnswer(refreshing: boolean, status: number, ok: boolean, body: unknown): OAuthTokens {
|
|
156
|
+
const code = errorCode(body);
|
|
157
|
+
if (grantGone(refreshing, status, code)) throw new OAuthInvalidGrant('That server requires a new sign-in.', 400);
|
|
158
|
+
if (!ok || !isRecord(body)) throw refused(`That server refused to complete the sign-in (${String(status)}${code === null ? '' : ` ${code}`}).`);
|
|
150
159
|
return tokensFrom(body);
|
|
151
160
|
}
|
|
152
161
|
|
|
@@ -196,12 +196,13 @@ function makeDelete(clientFor: ClientFor): StationHandler {
|
|
|
196
196
|
function makeAccounts(clientFor: ClientFor): StationHandler {
|
|
197
197
|
return (id) => {
|
|
198
198
|
const list = [...accounts.values()].map((a) => {
|
|
199
|
-
const
|
|
199
|
+
const client = clientFor(a.id);
|
|
200
|
+
const phone = client.self();
|
|
200
201
|
return {
|
|
201
202
|
id: a.id,
|
|
202
203
|
handle: phone === null ? null : `+${phone}`,
|
|
203
204
|
url: phone === null ? null : `https://wa.me/${phone}`,
|
|
204
|
-
|
|
205
|
+
status: client.status(),
|
|
205
206
|
};
|
|
206
207
|
});
|
|
207
208
|
respond(id, { result: { accounts: list } });
|
|
@@ -4,6 +4,7 @@ import type { MemberList } from '@metro-labs/core/stations/types';
|
|
|
4
4
|
import type { ChannelList } from '@metro-labs/core/stations/channel-directory';
|
|
5
5
|
import type { ProfileChange } from '@metro-labs/core/stations/profile';
|
|
6
6
|
import type { SenderProfile } from '@metro-labs/core/stations/sender-profile';
|
|
7
|
+
import type { AccountStatus } from '@metro-labs/core/stations/account-status';
|
|
7
8
|
import type { WhatsAppAccount } from './types.js';
|
|
8
9
|
import type { InboundMessage, ReactionInput } from './format.js';
|
|
9
10
|
import type { SenderFound } from './resolve.js';
|
|
@@ -25,6 +26,7 @@ export interface WAMedia {
|
|
|
25
26
|
export interface WAClient {
|
|
26
27
|
account: WhatsAppAccount;
|
|
27
28
|
self(): string | null;
|
|
29
|
+
status(): AccountStatus;
|
|
28
30
|
start(handlers: InboundHandlers): Promise<void>;
|
|
29
31
|
read(jid: string, options?: HistoryOptions): Promise<HistoryPage>;
|
|
30
32
|
listMembers(jid: string, limit?: number): Promise<MemberList>;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import makeWASocket, {
|
|
2
2
|
jidNormalizedUser,
|
|
3
3
|
Browsers,
|
|
4
|
-
DisconnectReason,
|
|
5
4
|
fetchLatestWaWebVersion,
|
|
6
5
|
type proto,
|
|
7
6
|
type WAMessage,
|
|
@@ -13,7 +12,7 @@ import { errMsg } from '@metro-labs/core/log';
|
|
|
13
12
|
import type { WhatsAppAccount } from './types.js';
|
|
14
13
|
import type { InboundHandlers, WAClient, WAMedia, ClientRuntime } from './client-types.js';
|
|
15
14
|
export type { InboundHandlers, WAClient, WAMedia } from './client-types.js';
|
|
16
|
-
import { makeProfileCache,
|
|
15
|
+
import { makeProfileCache, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
|
|
17
16
|
import type { SelfRef } from './parse.js';
|
|
18
17
|
import type { History } from './history.js';
|
|
19
18
|
import { createClientHistory } from './client-history.js';
|
|
@@ -22,11 +21,13 @@ import { bindMessages } from './messages.js';
|
|
|
22
21
|
import { listMembers } from './members.js';
|
|
23
22
|
import { SendExpiry } from './send-expiry.js';
|
|
24
23
|
import { baileysLogger } from './logger.js';
|
|
25
|
-
import { makeNameBook, nameFiles,
|
|
24
|
+
import { makeNameBook, nameFiles, type NameBook } from './names.js';
|
|
25
|
+
import { limiter, PictureMemory, PROFILE_READS, readPeer, withName, type PeerProfile } from './profile.js';
|
|
26
26
|
import { useAccountAuthState } from './auth-state.js';
|
|
27
27
|
import { knownKey, makeKeyCache, targetKey, type KeyCache } from './keys.js';
|
|
28
28
|
import { makeOutbox, type Outbox } from './outbox.js';
|
|
29
29
|
import { deliveryNotes, describeNote } from './delivery.js';
|
|
30
|
+
import { closeOf, closeReason, ConnectionWatch, failureOf, type Closed } from './connection.js';
|
|
30
31
|
import {
|
|
31
32
|
ACK_WAIT_MS,
|
|
32
33
|
bindAcks,
|
|
@@ -51,6 +52,7 @@ interface State {
|
|
|
51
52
|
acks: AckWatch;
|
|
52
53
|
names: NameBook;
|
|
53
54
|
channels: WhatsAppChannels;
|
|
55
|
+
watch: ConnectionWatch;
|
|
54
56
|
}
|
|
55
57
|
type SendOpts = Parameters<WASocket['sendMessage']>[2];
|
|
56
58
|
type SendContent = Parameters<WASocket['sendMessage']>[1];
|
|
@@ -107,19 +109,10 @@ function servedFromOutbox(
|
|
|
107
109
|
return message;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
'identity evict each other and risk a 463 timelock)',
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
export function terminalReason(code: number | undefined): string | undefined {
|
|
118
|
-
return code === undefined ? undefined : TERMINAL[code];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function onClose(st: State, code: number | undefined): void {
|
|
122
|
-
const terminal = terminalReason(code);
|
|
112
|
+
function onClose(st: State, closed: Closed): void {
|
|
113
|
+
const { code } = closed;
|
|
114
|
+
const terminal = closeReason(closed);
|
|
115
|
+
if (!st.closed) st.watch.closed(closed);
|
|
123
116
|
if (st.closed || terminal !== undefined) {
|
|
124
117
|
process.stderr.write(
|
|
125
118
|
`whatsapp[${st.account.id}] disconnected (code=${code ?? '?'})${terminal ?? ''}\n`,
|
|
@@ -132,6 +125,7 @@ function onClose(st: State, code: number | undefined): void {
|
|
|
132
125
|
process.stderr.write(
|
|
133
126
|
`whatsapp[${st.account.id}] reconnect failed: ${errMsg(e)}\n`,
|
|
134
127
|
);
|
|
128
|
+
st.watch.failed(failureOf(e, 'reconnect'));
|
|
135
129
|
});
|
|
136
130
|
}
|
|
137
131
|
|
|
@@ -140,14 +134,15 @@ function bindConnection(st: State, sock: WASocket): void {
|
|
|
140
134
|
const { connection, lastDisconnect } = update;
|
|
141
135
|
if (connection === 'open') {
|
|
142
136
|
process.stderr.write(`whatsapp[${st.account.id}] connected\n`);
|
|
137
|
+
if (st.sock === sock) st.watch.opened();
|
|
143
138
|
st.openResolve?.();
|
|
144
139
|
return;
|
|
145
140
|
}
|
|
146
141
|
if (connection !== 'close') return;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
142
|
+
onClose(st, closeOf(lastDisconnect?.error));
|
|
143
|
+
});
|
|
144
|
+
sock.ws.on('frame', () => {
|
|
145
|
+
if (st.sock === sock) st.watch.heard();
|
|
151
146
|
});
|
|
152
147
|
}
|
|
153
148
|
|
|
@@ -254,24 +249,24 @@ function mediaContent(m: WAMedia): SendContent {
|
|
|
254
249
|
};
|
|
255
250
|
}
|
|
256
251
|
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
...(picture === undefined ? {} : { avatar: picture }),
|
|
252
|
+
function profileReader(st: State): (jid: string) => Promise<SenderProfile | null> {
|
|
253
|
+
const pictures = new PictureMemory();
|
|
254
|
+
const reads = limiter(PROFILE_READS);
|
|
255
|
+
const senders = makeProfileCache<PeerProfile>(async (jid) => {
|
|
256
|
+
const sock = await ready(st);
|
|
257
|
+
return reads(() => readPeer(sock, jid, pictures));
|
|
258
|
+
}, {
|
|
259
|
+
onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`),
|
|
260
|
+
});
|
|
261
|
+
return async (jid) => {
|
|
262
|
+
const peer = await senders.get(jid);
|
|
263
|
+
return peer === null ? null : withName(peer, st.names);
|
|
270
264
|
};
|
|
271
265
|
}
|
|
272
266
|
|
|
273
267
|
async function disconnect(st: State): Promise<void> {
|
|
274
268
|
st.closed = true;
|
|
269
|
+
st.watch.stop();
|
|
275
270
|
st.openResolve?.();
|
|
276
271
|
try {
|
|
277
272
|
st.history.close();
|
|
@@ -295,6 +290,7 @@ function initialState(account: WhatsAppAccount, runtime: ClientRuntime): State {
|
|
|
295
290
|
acks: makeAckWatch(),
|
|
296
291
|
names: makeNameBook(nameFiles.path(account.id)),
|
|
297
292
|
channels: new WhatsAppChannels(account.id),
|
|
293
|
+
watch: new ConnectionWatch(account.id, () => st.sock?.ws.isOpen !== false),
|
|
298
294
|
};
|
|
299
295
|
resetGate(st);
|
|
300
296
|
return st;
|
|
@@ -305,9 +301,7 @@ export function createClient(
|
|
|
305
301
|
runtime: ClientRuntime = { makeSocket: makeWASocket, fetchVersion: fetchLatestWaWebVersion },
|
|
306
302
|
): WAClient {
|
|
307
303
|
const st = initialState(account, runtime);
|
|
308
|
-
const
|
|
309
|
-
onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`),
|
|
310
|
-
});
|
|
304
|
+
const senderProfile = profileReader(st);
|
|
311
305
|
return {
|
|
312
306
|
account,
|
|
313
307
|
self() {
|
|
@@ -317,14 +311,15 @@ export function createClient(
|
|
|
317
311
|
},
|
|
318
312
|
async start(handlers) {
|
|
319
313
|
st.handlers = handlers;
|
|
314
|
+
st.watch.watch();
|
|
320
315
|
try {
|
|
321
316
|
await connect(st);
|
|
322
317
|
} catch (e) {
|
|
323
|
-
process.stderr.write(
|
|
324
|
-
|
|
325
|
-
);
|
|
318
|
+
process.stderr.write(`whatsapp[${account.id}] connect failed: ${errMsg(e)}\n`);
|
|
319
|
+
st.watch.failed(failureOf(e, 'connect'));
|
|
326
320
|
}
|
|
327
321
|
},
|
|
322
|
+
status: () => st.watch.read(),
|
|
328
323
|
read(jid, options) {
|
|
329
324
|
const page = st.history.read(jid, options);
|
|
330
325
|
for (const message of page.messages) st.keys.remember(message.key);
|
|
@@ -394,7 +389,7 @@ export function createClient(
|
|
|
394
389
|
await sock.updateProfilePicture(me, { url: change.avatar.path });
|
|
395
390
|
}
|
|
396
391
|
},
|
|
397
|
-
senderProfile
|
|
392
|
+
senderProfile,
|
|
398
393
|
disconnect: () => st.closing ??= disconnect(st),
|
|
399
394
|
};
|
|
400
395
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { DisconnectReason } from 'baileys';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { errMsg } from '@metro-labs/core/log';
|
|
4
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
5
|
+
import { accountStatus, type AccountStatus } from '@metro-labs/core/stations/account-status';
|
|
6
|
+
|
|
7
|
+
export const RECONNECT_GRACE_MS = 3 * 60_000;
|
|
8
|
+
export const SILENT_MS = 90_000;
|
|
9
|
+
const CHECK_MS = 30_000;
|
|
10
|
+
|
|
11
|
+
const TERMINAL: Record<number, string> = {
|
|
12
|
+
[DisconnectReason.loggedOut]: ' — re-pair required',
|
|
13
|
+
[DisconnectReason.connectionReplaced]:
|
|
14
|
+
' — another metro took this device; not reconnecting (two sockets on one ' +
|
|
15
|
+
'identity evict each other and risk a 463 timelock)',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function terminalReason(code: number | undefined): string | undefined {
|
|
19
|
+
return code === undefined ? undefined : TERMINAL[code];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const closeReason = (closed: Closed): string | undefined =>
|
|
23
|
+
terminalReason(closed.removed ? DisconnectReason.loggedOut : closed.code);
|
|
24
|
+
|
|
25
|
+
export interface Closed {
|
|
26
|
+
code: number | undefined;
|
|
27
|
+
message: string;
|
|
28
|
+
removed: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const recordOf = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
|
32
|
+
|
|
33
|
+
export function closeOf(error: unknown): Closed {
|
|
34
|
+
const boom = recordOf(error);
|
|
35
|
+
const output = recordOf(boom.output);
|
|
36
|
+
const data = recordOf(boom.data);
|
|
37
|
+
return {
|
|
38
|
+
code: typeof output.statusCode === 'number' ? output.statusCode : undefined,
|
|
39
|
+
message: error instanceof Error ? error.message : '',
|
|
40
|
+
removed: data.tag === 'conflict' && recordOf(data.attrs).type === 'device_removed',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function statusAfterClose(closed: Closed, now: number): AccountStatus {
|
|
45
|
+
if (closed.removed) return accountStatus('logged_out', 'WhatsApp removed this linked device.', now);
|
|
46
|
+
if (closed.code === DisconnectReason.loggedOut) return accountStatus('logged_out', 'WhatsApp logged this device out.', now);
|
|
47
|
+
if (closed.code === DisconnectReason.connectionReplaced)
|
|
48
|
+
return accountStatus('disconnected', 'Another Metro took over this WhatsApp device, so this one stopped.', now);
|
|
49
|
+
const code = `code ${closed.code === undefined ? '?' : String(closed.code)}`;
|
|
50
|
+
return accountStatus('connecting', `The connection dropped (${closed.message === '' ? code : `${closed.message}, ${code}`}).`, now);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function failureOf(e: unknown, step: 'connect' | 'reconnect'): AccountStatus {
|
|
54
|
+
if (e instanceof TrainError && e.code === 'whatsapp_auth') return accountStatus('logged_out', 'No WhatsApp link is stored for this channel.');
|
|
55
|
+
return accountStatus('disconnected', `Metro could not ${step} to WhatsApp: ${errMsg(e)}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Liveness {
|
|
59
|
+
open: boolean;
|
|
60
|
+
socketOpen: boolean;
|
|
61
|
+
lastFrameAt: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function openStatus(current: AccountStatus, live: Liveness, now: number): AccountStatus {
|
|
65
|
+
const trouble = !live.socketOpen ? 'The WhatsApp connection is not open.' : now - live.lastFrameAt > SILENT_MS ? 'WhatsApp stopped answering.' : null;
|
|
66
|
+
if (trouble === null) return current.state === 'connected' ? current : accountStatus('connected', undefined, now);
|
|
67
|
+
return current.state === 'connected' ? accountStatus('connecting', trouble, now) : current;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function liveStatus(current: AccountStatus, live: Liveness, now: number): AccountStatus {
|
|
71
|
+
if (current.state === 'logged_out') return current;
|
|
72
|
+
const next = live.open ? openStatus(current, live, now) : current;
|
|
73
|
+
if (next.state !== 'connecting' || now - next.since < RECONNECT_GRACE_MS) return next;
|
|
74
|
+
return accountStatus('disconnected', `${next.reason ?? 'Metro could not reach WhatsApp.'} It has not come back for 3 minutes.`, next.since + RECONNECT_GRACE_MS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class ConnectionWatch {
|
|
78
|
+
private status: AccountStatus;
|
|
79
|
+
private open = false;
|
|
80
|
+
private lastFrameAt = 0;
|
|
81
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly accountId: string,
|
|
85
|
+
private readonly socketOpen: () => boolean,
|
|
86
|
+
now = Date.now(),
|
|
87
|
+
) {
|
|
88
|
+
this.status = accountStatus('connecting', undefined, now);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
opened(now = Date.now()): void {
|
|
92
|
+
this.open = true;
|
|
93
|
+
this.lastFrameAt = now;
|
|
94
|
+
this.enter(accountStatus('connected', undefined, now));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
heard(now = Date.now()): void {
|
|
98
|
+
this.lastFrameAt = now;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
closed(closed: Closed, now = Date.now()): void {
|
|
102
|
+
this.open = false;
|
|
103
|
+
const next = statusAfterClose(closed, now);
|
|
104
|
+
if (next.state !== 'connecting') this.enter(next);
|
|
105
|
+
else if (this.status.state === 'connecting') this.enter(accountStatus('connecting', next.reason, this.status.since));
|
|
106
|
+
else if (this.status.state !== 'disconnected') this.enter(next);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
failed(status: AccountStatus): void {
|
|
110
|
+
this.open = false;
|
|
111
|
+
this.enter(status);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
read(now = Date.now()): AccountStatus {
|
|
115
|
+
const next = liveStatus(this.status, { open: this.open, socketOpen: this.socketOpen(), lastFrameAt: this.lastFrameAt }, now);
|
|
116
|
+
if (next !== this.status) this.enter(next);
|
|
117
|
+
return this.status;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
watch(): void {
|
|
121
|
+
if (this.timer !== null) return;
|
|
122
|
+
this.timer = setInterval(() => {
|
|
123
|
+
this.read();
|
|
124
|
+
}, CHECK_MS);
|
|
125
|
+
this.timer.unref();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
stop(): void {
|
|
129
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
130
|
+
this.timer = null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private enter(next: AccountStatus): void {
|
|
134
|
+
const before = this.status.state;
|
|
135
|
+
this.status = next;
|
|
136
|
+
if (before === next.state) return;
|
|
137
|
+
process.stderr.write(`whatsapp[${this.accountId}] state ${before} -> ${next.state}${next.reason === undefined ? '' : `: ${next.reason}`}\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -6,6 +6,7 @@ import makeWASocket, {
|
|
|
6
6
|
} from 'baileys';
|
|
7
7
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
8
8
|
import { inMemoryAuthState } from './auth-state.js';
|
|
9
|
+
import type { ClientRuntime } from './client-types.js';
|
|
9
10
|
|
|
10
11
|
export class WhatsappLoginError extends Error {}
|
|
11
12
|
|
|
@@ -32,6 +33,13 @@ export function normalizePhone(raw: unknown): string {
|
|
|
32
33
|
return phone;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
export function assertSamePhone(phone: string, expect: string): void {
|
|
37
|
+
if (expect !== '' && phone !== '' && phone !== expect)
|
|
38
|
+
throw new WhatsappLoginError(
|
|
39
|
+
`This channel is +${expect}. Pair it again with that number, or add a new channel for +${phone}.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
function closeReason(err: unknown): number | undefined {
|
|
36
44
|
return (err as { output?: { statusCode?: number } } | undefined)?.output
|
|
37
45
|
?.statusCode;
|
|
@@ -47,14 +55,18 @@ export class WhatsappLogin {
|
|
|
47
55
|
constructor(
|
|
48
56
|
private phone: string,
|
|
49
57
|
private events: WhatsappLoginEvents,
|
|
50
|
-
|
|
58
|
+
private expect = '',
|
|
59
|
+
private runtime: ClientRuntime = { makeSocket: makeWASocket, fetchVersion: fetchLatestWaWebVersion },
|
|
60
|
+
) {
|
|
61
|
+
assertSamePhone(phone, expect);
|
|
62
|
+
}
|
|
51
63
|
|
|
52
64
|
get mode(): 'pairing-code' | 'qr' {
|
|
53
65
|
return this.phone === '' ? 'qr' : 'pairing-code';
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
async start(): Promise<void> {
|
|
57
|
-
const { version, error } = await
|
|
69
|
+
const { version, error } = await this.runtime.fetchVersion({});
|
|
58
70
|
if (error)
|
|
59
71
|
throw new WhatsappLoginError(
|
|
60
72
|
'could not reach WhatsApp to start the pairing',
|
|
@@ -64,7 +76,7 @@ export class WhatsappLogin {
|
|
|
64
76
|
|
|
65
77
|
private open(version: [number, number, number]): void {
|
|
66
78
|
if (this.settled) return;
|
|
67
|
-
const sock =
|
|
79
|
+
const sock = this.runtime.makeSocket({
|
|
68
80
|
version,
|
|
69
81
|
auth: this.auth.state,
|
|
70
82
|
browser: Browsers.macOS('Safari'),
|
|
@@ -131,6 +143,10 @@ export class WhatsappLogin {
|
|
|
131
143
|
);
|
|
132
144
|
return;
|
|
133
145
|
}
|
|
146
|
+
if (this.expect !== '' && phone !== this.expect) {
|
|
147
|
+
this.refuseOther(phone);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
134
150
|
const credentials = this.auth.serialize();
|
|
135
151
|
this.settled = true;
|
|
136
152
|
this.closeSocket()
|
|
@@ -145,6 +161,23 @@ export class WhatsappLogin {
|
|
|
145
161
|
});
|
|
146
162
|
}
|
|
147
163
|
|
|
164
|
+
private refuseOther(phone: string): void {
|
|
165
|
+
this.settled = true;
|
|
166
|
+
const sock = this.sock;
|
|
167
|
+
const message = `You linked +${phone}, but this channel is +${this.expect}. Nothing was changed. Scan the code with the phone of +${this.expect}.`;
|
|
168
|
+
(sock === null ? Promise.resolve() : sock.logout('Metro: another phone than the channel'))
|
|
169
|
+
.catch((err: unknown) => {
|
|
170
|
+
log.warn({ err: errMsg(err) }, 'whatsapp login: could not unlink the other phone');
|
|
171
|
+
})
|
|
172
|
+
.then(() => this.closeSocket())
|
|
173
|
+
.then(() => {
|
|
174
|
+
this.events.onFailed(message);
|
|
175
|
+
})
|
|
176
|
+
.catch((err: unknown) => {
|
|
177
|
+
log.warn({ err: errMsg(err) }, 'whatsapp login: failure report failed');
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
148
181
|
private fail(message: string): void {
|
|
149
182
|
if (this.settled) return;
|
|
150
183
|
this.settled = true;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jidNormalizedUser, type Contact, type WASocket } from 'baileys';
|
|
1
|
+
import { jidNormalizedUser, type Contact, type WAMessage, type WASocket } from 'baileys';
|
|
2
2
|
import { bindAppState, noteHistoryContact } from './app-state.js';
|
|
3
3
|
import type { InboundHandlers } from './client-types.js';
|
|
4
4
|
import type { History } from './history.js';
|
|
@@ -16,6 +16,20 @@ interface MessageHooks {
|
|
|
16
16
|
self(): SelfRef;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const person = (jid: string | null | undefined): string | undefined =>
|
|
20
|
+
jid && (jid.endsWith('@s.whatsapp.net') || jid.endsWith('@lid')) ? jidNormalizedUser(jid) : undefined;
|
|
21
|
+
|
|
22
|
+
const pair = (a: string, b: string): [string, string] => (a.endsWith('@lid') ? [b, a] : [a, b]);
|
|
23
|
+
|
|
24
|
+
function noteSender(names: NameBook, m: WAMessage): void {
|
|
25
|
+
if (m.key.fromMe === true) return;
|
|
26
|
+
const name = m.pushName ?? m.verifiedBizName;
|
|
27
|
+
const sender = person(m.key.participant ?? m.key.remoteJid);
|
|
28
|
+
const alt = person(m.key.participantAlt ?? m.key.remoteJidAlt);
|
|
29
|
+
for (const jid of [sender, alt]) names.note(jid, name);
|
|
30
|
+
if (sender !== undefined && alt !== undefined) names.alias(...pair(sender, alt));
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export function bindMessages(sock: Pick<WASocket, 'ev' | 'user'>, hooks: MessageHooks): void {
|
|
20
34
|
const selfJid = (): string | undefined => sock.user?.id ? jidNormalizedUser(sock.user.id) : undefined;
|
|
21
35
|
const noteContacts = (contacts: Partial<Contact>[]): void => {
|
|
@@ -37,18 +51,25 @@ export function bindMessages(sock: Pick<WASocket, 'ev' | 'user'>, hooks: Message
|
|
|
37
51
|
const inbound = toInbound(hooks.accountId, m, hooks.self());
|
|
38
52
|
if (!inbound) continue;
|
|
39
53
|
hooks.names.note(inbound.senderJid, inbound.pushName);
|
|
54
|
+
noteSender(hooks.names, m);
|
|
40
55
|
handlers.onMessage(inbound, m);
|
|
41
56
|
}
|
|
42
57
|
});
|
|
58
|
+
sock.ev.on('lid-mapping.update', ({ pn, lid }) => {
|
|
59
|
+
if (hooks.current()) hooks.names.alias(pn, lid);
|
|
60
|
+
});
|
|
43
61
|
sock.ev.on('contacts.upsert', noteContacts);
|
|
44
62
|
sock.ev.on('contacts.update', noteContacts);
|
|
45
63
|
sock.ev.on('messaging-history.set', ({ contacts, messages, lidPnMappings }) => {
|
|
46
64
|
if (!hooks.current()) return;
|
|
47
65
|
noteContacts(contacts);
|
|
48
|
-
for (const { pn, lid } of lidPnMappings ?? [])
|
|
66
|
+
for (const { pn, lid } of lidPnMappings ?? []) {
|
|
67
|
+
hooks.history.alias(pn, lid);
|
|
68
|
+
hooks.names.alias(pn, lid);
|
|
69
|
+
}
|
|
49
70
|
for (const m of messages) {
|
|
50
71
|
hooks.keys.remember(m.key);
|
|
51
|
-
|
|
72
|
+
noteSender(hooks.names, m);
|
|
52
73
|
}
|
|
53
74
|
hooks.history.ingest(messages, selfJid());
|
|
54
75
|
});
|
|
@@ -12,6 +12,7 @@ export const nameFiles = accountFiles('WHATSAPP_TOKEN_DIR', 'whatsapp-names-');
|
|
|
12
12
|
export interface NameBook {
|
|
13
13
|
note(jid: string | null | undefined, name: string | null | undefined): void;
|
|
14
14
|
get(jid: string): string | undefined;
|
|
15
|
+
alias(pn: string | null | undefined, lid: string | null | undefined): void;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
function loaded(file: string | undefined): Map<string, string> {
|
|
@@ -25,6 +26,15 @@ function loaded(file: string | undefined): Map<string, string> {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
const JID_RE = /^([^@:_]+)(?:_\d+)?(?::\d+)?@([^@]+)$/;
|
|
30
|
+
|
|
31
|
+
export function idOf(jid: string | null | undefined): string | undefined {
|
|
32
|
+
const parts = JID_RE.exec(jid ?? '');
|
|
33
|
+
if (parts === null) return undefined;
|
|
34
|
+
const [, user = '', server = ''] = parts;
|
|
35
|
+
return `${user}@${server === 'c.us' ? 's.whatsapp.net' : server}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
export function makeNameBook(file?: string): NameBook {
|
|
29
39
|
const names = loaded(file);
|
|
30
40
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -37,22 +47,35 @@ export function makeNameBook(file?: string): NameBook {
|
|
|
37
47
|
process.stderr.write(`whatsapp: could not save the names: ${errMsg(err)}\n`);
|
|
38
48
|
}
|
|
39
49
|
};
|
|
50
|
+
const schedule = (): void => {
|
|
51
|
+
if (file !== undefined && timer === null) {
|
|
52
|
+
timer = setTimeout(save, SAVE_DELAY_MS);
|
|
53
|
+
timer.unref();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const note = (raw: string | null | undefined, name: string | null | undefined): void => {
|
|
57
|
+
const clean = name?.trim() ?? '';
|
|
58
|
+
const jid = idOf(raw);
|
|
59
|
+
if (jid === undefined || clean === '' || names.get(jid) === clean) return;
|
|
60
|
+
names.delete(jid);
|
|
61
|
+
names.set(jid, clean);
|
|
62
|
+
if (names.size > MAX_NAMES) {
|
|
63
|
+
const oldest = names.keys().next().value;
|
|
64
|
+
if (oldest !== undefined) names.delete(oldest);
|
|
65
|
+
}
|
|
66
|
+
schedule();
|
|
67
|
+
};
|
|
40
68
|
return {
|
|
41
|
-
note
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (file !== undefined && timer === null) {
|
|
51
|
-
timer = setTimeout(save, SAVE_DELAY_MS);
|
|
52
|
-
timer.unref();
|
|
53
|
-
}
|
|
69
|
+
note,
|
|
70
|
+
get: (jid) => names.get(idOf(jid) ?? jid),
|
|
71
|
+
alias(rawPn, rawLid) {
|
|
72
|
+
const pn = idOf(rawPn);
|
|
73
|
+
const lid = idOf(rawLid);
|
|
74
|
+
if (pn === undefined || lid === undefined) return;
|
|
75
|
+
const known = names.get(pn) ?? names.get(lid);
|
|
76
|
+
if (!names.has(pn)) note(pn, known);
|
|
77
|
+
if (!names.has(lid)) note(lid, known);
|
|
54
78
|
},
|
|
55
|
-
get: (jid) => names.get(jid),
|
|
56
79
|
};
|
|
57
80
|
}
|
|
58
81
|
|
|
@@ -62,11 +85,19 @@ export interface ContactNames {
|
|
|
62
85
|
phoneNumber?: string | null;
|
|
63
86
|
name?: string | null;
|
|
64
87
|
notify?: string | null;
|
|
88
|
+
verifiedName?: string | null;
|
|
65
89
|
}
|
|
66
90
|
|
|
91
|
+
const nonBlank = (value: string | null | undefined): string | undefined => (value?.trim() ? value : undefined);
|
|
92
|
+
|
|
93
|
+
const isLid = (jid: string | null | undefined): jid is string => jid?.endsWith('@lid') === true;
|
|
94
|
+
|
|
67
95
|
export function noteContact(book: NameBook, c: ContactNames): void {
|
|
68
|
-
const name = c.name ?? c.notify;
|
|
96
|
+
const name = nonBlank(c.name) ?? nonBlank(c.notify) ?? nonBlank(c.verifiedName);
|
|
69
97
|
for (const jid of [c.id, c.lid, c.phoneNumber]) book.note(jid, name);
|
|
98
|
+
const lid = c.lid ?? (isLid(c.id) ? c.id : undefined);
|
|
99
|
+
const pn = c.phoneNumber ?? (isLid(c.id) ? undefined : c.id);
|
|
100
|
+
book.alias(pn, lid);
|
|
70
101
|
}
|
|
71
102
|
|
|
72
103
|
export const phoneOf = (jid: string | null | undefined): string | undefined => {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { jidNormalizedUser, type WASocket } from 'baileys';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { errMsg } from '@metro-labs/core/log';
|
|
4
|
+
import { nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
|
|
5
|
+
import { phoneOf, type NameBook } from './names.js';
|
|
6
|
+
|
|
7
|
+
export const NO_PICTURE_MS = 6 * 60 * 60_000;
|
|
8
|
+
export const RATE_LIMITED_MS = 30 * 60_000;
|
|
9
|
+
export const LOOKUP_BUDGET_MS = 6_000;
|
|
10
|
+
export const PROFILE_READS = 4;
|
|
11
|
+
const TRY_MS = 1_500;
|
|
12
|
+
const NO_PICTURE_MAX = 2_000;
|
|
13
|
+
const REFUSED = new Set([401, 403, 404]);
|
|
14
|
+
const RATE_LIMIT_RE = /rate.?over.?limit|too many/i;
|
|
15
|
+
const PICTURE_TYPES = ['image', 'preview'] as const;
|
|
16
|
+
|
|
17
|
+
type ProfileSocket = Pick<WASocket, 'fetchStatus' | 'profilePictureUrl' | 'signalRepository'>;
|
|
18
|
+
|
|
19
|
+
export interface PeerProfile {
|
|
20
|
+
profile: SenderProfile;
|
|
21
|
+
other: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class PictureMemory {
|
|
25
|
+
private none = new Map<string, number>();
|
|
26
|
+
private limitedAt: number | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(readonly now: () => number = Date.now) {}
|
|
29
|
+
|
|
30
|
+
known(jid: string): boolean {
|
|
31
|
+
const at = this.none.get(jid);
|
|
32
|
+
if (at === undefined) return false;
|
|
33
|
+
if (this.now() - at < NO_PICTURE_MS) return true;
|
|
34
|
+
this.none.delete(jid);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
remember(jid: string): void {
|
|
39
|
+
this.none.delete(jid);
|
|
40
|
+
this.none.set(jid, this.now());
|
|
41
|
+
if (this.none.size <= NO_PICTURE_MAX) return;
|
|
42
|
+
const oldest = this.none.keys().next();
|
|
43
|
+
if (!oldest.done) this.none.delete(oldest.value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
limited(): boolean {
|
|
47
|
+
return this.limitedAt !== null && this.now() - this.limitedAt < RATE_LIMITED_MS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
limit(): void {
|
|
51
|
+
this.limitedAt = this.now();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Failure = 'refused' | 'limited' | 'other';
|
|
56
|
+
|
|
57
|
+
function failureOf(err: unknown): Failure {
|
|
58
|
+
const output = isRecord(err) && isRecord(err.output) ? err.output : {};
|
|
59
|
+
const code = typeof output.statusCode === 'number' ? output.statusCode : undefined;
|
|
60
|
+
if (code === 429 || RATE_LIMIT_RE.test(errMsg(err))) return 'limited';
|
|
61
|
+
return code !== undefined && REFUSED.has(code) ? 'refused' : 'other';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function within<T, F>(work: Promise<T>, ms: number, fallback: F): Promise<T | F> {
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
66
|
+
const late = new Promise<F>((resolve) => {
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
resolve(fallback);
|
|
69
|
+
}, ms);
|
|
70
|
+
});
|
|
71
|
+
return Promise.race([work.catch(() => fallback), late]).finally(() => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function otherIdOf(sock: ProfileSocket, jid: string): Promise<string | null> {
|
|
77
|
+
const mapping = sock.signalRepository.lidMapping;
|
|
78
|
+
const other = await within(jid.endsWith('@lid') ? mapping.getPNForLID(jid) : mapping.getLIDForPN(jid), TRY_MS, null);
|
|
79
|
+
return other === null ? null : jidNormalizedUser(other);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Tried = { url: string } | 'none' | 'stop';
|
|
83
|
+
|
|
84
|
+
async function pictureFor(sock: ProfileSocket, jid: string, memory: PictureMemory, deadline: number): Promise<Tried> {
|
|
85
|
+
for (const type of PICTURE_TYPES) {
|
|
86
|
+
const left = deadline - memory.now();
|
|
87
|
+
if (left <= 0) return 'stop';
|
|
88
|
+
try {
|
|
89
|
+
const url = nonEmpty(await sock.profilePictureUrl(jid, type, Math.min(TRY_MS, left)));
|
|
90
|
+
if (url !== undefined) return { url };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const failure = failureOf(err);
|
|
93
|
+
if (failure === 'limited') memory.limit();
|
|
94
|
+
if (failure !== 'refused') return 'stop';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
memory.remember(jid);
|
|
98
|
+
return 'none';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function pictureOf(sock: ProfileSocket, jids: string[], memory: PictureMemory, deadline = memory.now() + LOOKUP_BUDGET_MS): Promise<string | undefined> {
|
|
102
|
+
for (const jid of jids) {
|
|
103
|
+
if (memory.limited()) return undefined;
|
|
104
|
+
if (memory.known(jid)) continue;
|
|
105
|
+
const tried = await pictureFor(sock, jid, memory, deadline);
|
|
106
|
+
if (tried === 'stop') return undefined;
|
|
107
|
+
if (tried !== 'none') return tried.url;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readPeer(sock: ProfileSocket, jid: string, memory: PictureMemory): Promise<PeerProfile> {
|
|
113
|
+
const deadline = memory.now() + LOOKUP_BUDGET_MS;
|
|
114
|
+
const other = await otherIdOf(sock, jid);
|
|
115
|
+
const jids = other === null ? [jid] : [jid, other];
|
|
116
|
+
const [statuses, picture] = await Promise.all([within(sock.fetchStatus(jid), LOOKUP_BUDGET_MS, undefined), pictureOf(sock, jids, memory, deadline)]);
|
|
117
|
+
const status: unknown = statuses?.[0]?.status;
|
|
118
|
+
const about = nonEmpty(isRecord(status) ? status.status : undefined);
|
|
119
|
+
const phone = phoneOf(jid.endsWith('@lid') ? other : jid);
|
|
120
|
+
return {
|
|
121
|
+
other,
|
|
122
|
+
profile: {
|
|
123
|
+
id: jid,
|
|
124
|
+
...(phone === undefined ? {} : { name: phone, address: phone }),
|
|
125
|
+
...(about === undefined ? {} : { about }),
|
|
126
|
+
...(picture === undefined ? {} : { avatar: picture }),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function withName(peer: PeerProfile, names: NameBook): SenderProfile {
|
|
132
|
+
const { profile, other } = peer;
|
|
133
|
+
if (other !== null) names.alias(profile.id.endsWith('@lid') ? other : profile.id, profile.id.endsWith('@lid') ? profile.id : other);
|
|
134
|
+
const name = names.get(profile.id) ?? (other === null ? undefined : names.get(other));
|
|
135
|
+
return name === undefined ? profile : { ...profile, display_name: name };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function limiter(max: number): <T>(run: () => Promise<T>) => Promise<T> {
|
|
139
|
+
let active = 0;
|
|
140
|
+
const waiting: (() => void)[] = [];
|
|
141
|
+
const release = (): void => {
|
|
142
|
+
const next = waiting.shift();
|
|
143
|
+
if (next === undefined) active -= 1;
|
|
144
|
+
else next();
|
|
145
|
+
};
|
|
146
|
+
return async <T>(run: () => Promise<T>): Promise<T> => {
|
|
147
|
+
if (active >= max)
|
|
148
|
+
await new Promise<void>((resolve) => {
|
|
149
|
+
waiting.push(resolve);
|
|
150
|
+
});
|
|
151
|
+
else active += 1;
|
|
152
|
+
try {
|
|
153
|
+
return await run();
|
|
154
|
+
} finally {
|
|
155
|
+
release();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -24,6 +24,8 @@ export const whatsappStation: Station = {
|
|
|
24
24
|
readsProfiles: true,
|
|
25
25
|
resolvesSenders: true,
|
|
26
26
|
discoversChannels: true,
|
|
27
|
+
reportsStatus: true,
|
|
28
|
+
pairAgain: { identity: 'phone' },
|
|
27
29
|
forget: (accountId) => {
|
|
28
30
|
tokenFiles.forget(accountId);
|
|
29
31
|
nameFiles.forget(accountId);
|
package/runtime/runtime.json
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./lines": "./src/lines.ts",
|
|
27
27
|
"./endpoints": "./src/endpoints.ts",
|
|
28
28
|
"./stations/types": "./src/stations/types.ts",
|
|
29
|
+
"./stations/account-status": "./src/stations/account-status.ts",
|
|
29
30
|
"./stations/channel-directory": "./src/stations/channel-directory.ts",
|
|
30
31
|
"./stations/channel-pages": "./src/stations/channel-pages.ts",
|
|
31
32
|
"./stations/account-files": "./src/stations/account-files.ts",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isRecord } from '../is-record.js';
|
|
2
|
+
|
|
3
|
+
export const ACCOUNT_STATES = ['connected', 'connecting', 'logged_out', 'disconnected'] as const;
|
|
4
|
+
|
|
5
|
+
export type AccountState = (typeof ACCOUNT_STATES)[number];
|
|
6
|
+
|
|
7
|
+
export interface AccountStatus {
|
|
8
|
+
state: AccountState;
|
|
9
|
+
reason?: string;
|
|
10
|
+
since: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const REASON_MAX = 300;
|
|
14
|
+
|
|
15
|
+
export function accountStatus(state: AccountState, reason?: string, since = Date.now()): AccountStatus {
|
|
16
|
+
const text = reason?.trim().slice(0, REASON_MAX) ?? '';
|
|
17
|
+
return text === '' ? { state, since } : { state, reason: text, since };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function accountStatusOf(raw: unknown): AccountStatus | null {
|
|
21
|
+
if (!isRecord(raw)) return null;
|
|
22
|
+
const state = ACCOUNT_STATES.find((s) => s === raw.state);
|
|
23
|
+
const since = raw.since;
|
|
24
|
+
if (state === undefined || typeof since !== 'number' || !Number.isFinite(since)) return null;
|
|
25
|
+
return accountStatus(state, typeof raw.reason === 'string' ? raw.reason : undefined, since);
|
|
26
|
+
}
|
|
@@ -114,6 +114,8 @@ export interface Station {
|
|
|
114
114
|
readFilters?: ReadonlySet<ReadFilter>;
|
|
115
115
|
approvals?: boolean;
|
|
116
116
|
sendsFrames?: boolean;
|
|
117
|
+
reportsStatus?: boolean;
|
|
118
|
+
pairAgain?: { identity: string };
|
|
117
119
|
attachmentMode: AttachmentMode;
|
|
118
120
|
forget?: (accountId: string) => void;
|
|
119
121
|
forgetExcept?: (keptAccountIds: readonly string[]) => void;
|