@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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  3. package/runtime/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
  4. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +23 -2
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +30 -3
  10. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +9 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/routes/pair-again.ts +23 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +1 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +9 -2
  14. package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +6 -0
  15. package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +13 -4
  16. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +3 -2
  17. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
  18. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +35 -40
  19. package/runtime/node_modules/@metro-labs/whatsapp/src/connection.ts +139 -0
  20. package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +36 -3
  21. package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +24 -3
  22. package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +46 -15
  23. package/runtime/node_modules/@metro-labs/whatsapp/src/profile.ts +158 -0
  24. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
  25. package/runtime/runtime.json +1 -1
  26. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  27. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
  28. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
  29. package/runtime/sdk-runner/runner.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.316",
3
+ "version": "0.1.0-beta.317",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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: Extract<AccountRoute, { kind: 'start' | 'session' | 'step' }>,
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.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route, session);
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
- const decorate = (station: string, rows: unknown[]): unknown[] =>
89
- station === 'threema' ? rows.map(withCallbackUrl) : rows;
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 resp = await forwardTrainCall(station, 'accounts', {});
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) => station === 'gmail' ? startGmailAttach(station, input, hooks, owner) : startInteractiveAttach(station, input, hooks),
48
- complete: async (owner, station, config, active) => {
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 = (station === 'gmail' ? finishGmailUpgrade(owner, config) : null) ?? await localAttachAccount(owner.agentId, station, config);
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
+ }
@@ -97,7 +97,7 @@ async function startWhatsapp(
97
97
  },
98
98
  onPaired: hooks.done,
99
99
  onFailed: hooks.fail,
100
- });
100
+ }, normalizePhone(input.expect));
101
101
  await login.start();
102
102
  } catch (err) {
103
103
  await login?.cancel();
@@ -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
- const body: unknown = await res.json().catch(() => null);
147
- if (form.grant_type === 'refresh_token' && res.status === 400 && isRecord(body) && body.error === 'invalid_grant') throw new OAuthInvalidGrant('That server requires a new sign-in.', 400);
148
- if (!res.ok || !isRecord(body))
149
- throw refused('That server refused to complete the sign-in.');
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 phone = clientFor(a.id).self();
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
- connected: phone !== null,
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, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
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, phoneOf, type NameBook } from './names.js';
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
- const TERMINAL: Record<number, string> = {
111
- [DisconnectReason.loggedOut]: ' — re-pair required',
112
- [DisconnectReason.connectionReplaced]:
113
- ' — another metro took this device; not reconnecting (two sockets on one ' +
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
- const code = (
148
- lastDisconnect?.error as { output?: { statusCode?: number } } | undefined
149
- )?.output?.statusCode;
150
- onClose(st, code);
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
- async function readProfile(st: State, jid: string): Promise<SenderProfile> {
258
- const sock = await ready(st);
259
- const [statuses, avatar] = await Promise.all([sock.fetchStatus(jid).catch(() => undefined), sock.profilePictureUrl(jid, 'image').catch(() => undefined)]);
260
- const about = nonEmpty((statuses?.[0]?.status as { status?: unknown } | undefined)?.status);
261
- const picture = nonEmpty(avatar);
262
- const phone = phoneOf(jid.endsWith('@lid') ? await sock.signalRepository.lidMapping.getPNForLID(jid).catch(() => null) : jid);
263
- const name = st.names.get(jid);
264
- return {
265
- id: jid,
266
- ...(name === undefined ? {} : { display_name: name }),
267
- ...(phone === undefined ? {} : { name: phone, address: phone }),
268
- ...(about === undefined ? {} : { about }),
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 senders = makeProfileCache<SenderProfile>((jid) => readProfile(st, jid), {
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
- `whatsapp[${account.id}] connect failed: ${errMsg(e)}\n`,
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: (jid) => senders.get(jid),
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 fetchLatestWaWebVersion({});
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 = makeWASocket({
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 ?? []) hooks.history.alias(pn, lid);
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
- if (m.key.fromMe !== true) hooks.names.note(m.key.participant ?? m.key.remoteJid, m.pushName);
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(jid, name) {
42
- const clean = name?.trim() ?? '';
43
- if (!jid || clean === '' || names.get(jid) === clean) return;
44
- names.delete(jid);
45
- names.set(jid, clean);
46
- if (names.size > MAX_NAMES) {
47
- const oldest = names.keys().next().value;
48
- if (oldest !== undefined) names.delete(oldest);
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);
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.316"
2
+ "version": "0.1.0-beta.317"
3
3
  }
@@ -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;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.316",
2
+ "version": "0.1.0-beta.317",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",