@stage-labs/metro 0.1.0-beta.263 → 0.1.0-beta.264

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/dist/runtime-install.js +22 -5
  2. package/package.json +1 -1
  3. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  4. package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
  5. package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
  7. package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
  8. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
  9. package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
  10. package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
  11. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
  12. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
  13. package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
  14. package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
  15. package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
  16. package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
  17. package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
  18. package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
  19. package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
  20. package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
  21. package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
  22. package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
  23. package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
  24. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
  25. package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
  26. package/runtime/runtime.json +1 -1
  27. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
  28. package/runtime/sdk-runner/runner.json +1 -1
  29. package/runtime/stations.json +3 -0
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
5
  import { localStations } from './local.js';
6
6
  import { STORE_ENTRY, daemonEntry, findBun, runtimeDir } from './runtime.js';
7
7
  const MANIFEST_FILE = 'stations.json';
@@ -24,6 +24,7 @@ export function readManifest(path) {
24
24
  return {
25
25
  core: ranges(raw.core),
26
26
  stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
27
+ ...(raw.patchedDependencies === undefined ? {} : { patchedDependencies: ranges(raw.patchedDependencies) }),
27
28
  };
28
29
  }
29
30
  export function dependenciesFor(manifest, stations) {
@@ -32,7 +33,19 @@ export function dependenciesFor(manifest, stations) {
32
33
  Object.assign(out, manifest.stations[station] ?? {});
33
34
  return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
34
35
  }
35
- const packageText = (deps) => `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
36
+ function packageText(deps, patches) {
37
+ const patchedDependencies = Object.fromEntries(Object.entries(deps).flatMap(([name, version]) => {
38
+ const key = `${name}@${version}`;
39
+ const path = patches[key];
40
+ return path === undefined ? [] : [[key, path]];
41
+ }));
42
+ return `${JSON.stringify({
43
+ name: 'metro-runtime',
44
+ private: true,
45
+ dependencies: deps,
46
+ ...(Object.keys(patchedDependencies).length === 0 ? {} : { patchedDependencies }),
47
+ }, null, 2)}\n`;
48
+ }
36
49
  export function readOrNull(path) {
37
50
  try {
38
51
  return readFileSync(path, 'utf8');
@@ -63,8 +76,8 @@ function syncSources(sources, store) {
63
76
  writeFileSync(join(store, 'runtime.json'), stamp);
64
77
  return true;
65
78
  }
66
- export function installDependencies(store, deps, bun, log) {
67
- const wanted = packageText(deps);
79
+ export function installDependencies(store, deps, bun, log, patches = {}) {
80
+ const wanted = packageText(deps, patches);
68
81
  const marker = join(store, 'node_modules', '.metro-installed');
69
82
  if (existsSync(marker) && readOrNull(join(store, 'package.json')) === wanted)
70
83
  return false;
@@ -92,6 +105,10 @@ export function prepareRuntime(opts = {}) {
92
105
  });
93
106
  mkdirSync(join(store, 'trains'), { recursive: true });
94
107
  syncSources(sources, store);
95
- installDependencies(store, dependenciesFor(manifest, localStations(opts.agents)), opts.bun ?? findBun(), log);
108
+ for (const path of Object.values(manifest.patchedDependencies ?? {})) {
109
+ mkdirSync(dirname(join(store, path)), { recursive: true });
110
+ copyFileSync(join(sources, path), join(store, path));
111
+ }
112
+ installDependencies(store, dependenciesFor(manifest, localStations(opts.agents)), opts.bun ?? findBun(), log, manifest.patchedDependencies);
96
113
  return { dir: store, entry: daemonEntry(store), trains: join(store, 'trains'), manifest: manifestPath };
97
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.263",
3
+ "version": "0.1.0-beta.264",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.263",
3
+ "version": "0.1.0-beta.264",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -36,7 +36,7 @@ function targets(snap, name, input) {
36
36
  if (Array.isArray(snap.ungated) && snap.ungated.includes(name)) return [];
37
37
  const account = text(input.account);
38
38
  const line = text(input[LINE_FIELD[name] ?? 'line']);
39
- if (line) return fromLine(line, name in LINE_FIELD ? '' : account);
39
+ if (line) return fromLine(line, name in LINE_FIELD || name === 'list_members' ? '' : account);
40
40
  const owners = isRecord(snap.owners) ? snap.owners : {};
41
41
  const station = (STATION_ARG.has(name) ? text(input.station) : text(owners[name])) || (account ? stationOfAccount(snap, account) : undefined);
42
42
  if (!station) return [];
@@ -1,4 +1,4 @@
1
- import { errMsg } from '../log.js';
1
+ import { errMsg, logFatalSync } from '../log.js';
2
2
  import { readCalls } from '../protocol.js';
3
3
  import { TrainError } from '../train-error.js';
4
4
  import type { CallMsg } from './station-runtime.js';
@@ -18,12 +18,33 @@ export interface ClientTrain<A extends { id: string }, C> {
18
18
  loadAccounts: () => A[];
19
19
  createClient: (account: A) => C;
20
20
  startInbound: (client: C) => Promise<void>;
21
+ disconnectClient?: (client: C) => Promise<void>;
21
22
  makeHandleCall: (clientFor: (accountId: string) => C) => (msg: CallMsg) => Promise<void>;
22
23
  }
23
24
 
24
25
  export function runClientTrain<A extends { id: string }, C>(train: ClientTrain<A, C>): void {
25
26
  const clients = new Map<string, C>();
27
+ let stopping = false;
28
+ if (train.disconnectClient) {
29
+ const disconnect = train.disconnectClient;
30
+ const shutdown = (): void => {
31
+ if (stopping) return;
32
+ stopping = true;
33
+ process.stdin.pause();
34
+ Promise.allSettled([...clients.values()].map(async (client) => { await disconnect(client); })).then((results) => {
35
+ const errors = results.flatMap((result) => result.status === 'rejected' ? [errMsg(result.reason)] : []);
36
+ if (errors.length) logFatalSync({ station: train.station, errors }, 'train: shutdown failed');
37
+ process.exit(errors.length ? 1 : 0);
38
+ }).catch((err: unknown) => {
39
+ logFatalSync({ station: train.station, err: errMsg(err) }, 'train: shutdown failed');
40
+ process.exit(1);
41
+ });
42
+ };
43
+ process.on('SIGTERM', shutdown);
44
+ process.on('SIGINT', shutdown);
45
+ }
26
46
  const clientFor = (accountId: string): C => {
47
+ if (stopping) throw new TrainError('train_stopping', `${train.station} is shutting down`);
27
48
  const known = clients.get(accountId);
28
49
  if (known !== undefined) return known;
29
50
  const account = train.accounts.get(accountId);
@@ -13,9 +13,9 @@ type ChannelTarget = Extract<PolicyTarget, { kind: 'channel' }>;
13
13
 
14
14
  export const UNGATED = new Set<string>(['list_accounts', 'create_upload']);
15
15
 
16
- function fromLine(line: string, override: string | undefined): ChannelTarget[] {
16
+ function fromLine(line: string, override = ''): ChannelTarget[] {
17
17
  const acct = accountFromLine(line);
18
- return acct === undefined ? [] : [{ kind: 'channel', station: acct.station, account: override ?? acct.accountId }];
18
+ return acct === undefined ? [] : [{ kind: 'channel', station: acct.station, account: override || acct.accountId }];
19
19
  }
20
20
 
21
21
  function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget[] {
@@ -26,7 +26,7 @@ function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget
26
26
  }
27
27
  const account = str(a.account);
28
28
  const line = str(a.line);
29
- if (line) return fromLine(line, account || undefined);
29
+ if (line) return fromLine(line, name === 'list_members' ? undefined : account);
30
30
  const station = stationForTool(name, a) ?? (account ? stationOfAccount(account) : undefined);
31
31
  if (station === undefined) return [];
32
32
  if (account) return [{ kind: 'channel', station, account }];
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { log } from '@metro-labs/core/log';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
@@ -11,6 +11,7 @@ const INSTALL_TIMEOUT_MS = 15 * 60_000;
11
11
  export interface RuntimeManifest {
12
12
  core: Record<string, string>;
13
13
  stations: Record<string, Record<string, string>>;
14
+ patchedDependencies?: Record<string, string>;
14
15
  }
15
16
 
16
17
  export interface RuntimeStore {
@@ -30,6 +31,7 @@ export function readManifest(path: string): RuntimeManifest {
30
31
  return {
31
32
  core: ranges(raw.core),
32
33
  stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
34
+ ...(raw.patchedDependencies === undefined ? {} : { patchedDependencies: ranges(raw.patchedDependencies) }),
33
35
  };
34
36
  }
35
37
 
@@ -46,8 +48,21 @@ export function dependenciesFor(manifest: RuntimeManifest, stations: Iterable<st
46
48
  return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
47
49
  }
48
50
 
49
- const packageText = (deps: Record<string, string>): string =>
50
- `${JSON.stringify({ name: 'metro-runtime', private: true, dependencies: deps }, null, 2)}\n`;
51
+ function packageText(deps: Record<string, string>, patches: Record<string, string>): string {
52
+ const patchedDependencies = Object.fromEntries(
53
+ Object.entries(deps).flatMap(([name, version]) => {
54
+ const key = `${name}@${version}`;
55
+ const path = patches[key];
56
+ return path === undefined ? [] : [[key, path]];
57
+ }),
58
+ );
59
+ return `${JSON.stringify({
60
+ name: 'metro-runtime',
61
+ private: true,
62
+ dependencies: deps,
63
+ ...(Object.keys(patchedDependencies).length === 0 ? {} : { patchedDependencies }),
64
+ }, null, 2)}\n`;
65
+ }
51
66
 
52
67
  function current(dir: string): string | null {
53
68
  try {
@@ -58,9 +73,10 @@ function current(dir: string): string | null {
58
73
  }
59
74
 
60
75
  export function installRuntime(store: RuntimeStore, stations: Iterable<string>): boolean {
61
- const wanted = packageText(dependenciesFor(store.manifest, stations));
62
- const installed = existsSync(join(store.dir, 'node_modules', '.metro-installed'));
63
- if (installed && current(store.dir) === wanted) return false;
76
+ const wanted = packageText(dependenciesFor(store.manifest, stations), store.manifest.patchedDependencies ?? {});
77
+ const marker = join(store.dir, 'node_modules', '.metro-installed');
78
+ if (existsSync(marker) && current(store.dir) === wanted) return false;
79
+ rmSync(marker, { force: true });
64
80
  mkdirSync(store.dir, { recursive: true });
65
81
  writeFileSync(join(store.dir, 'package.json'), wanted);
66
82
  log.info({ dir: store.dir }, 'runtime: installing the channel SDKs this machine needs');
@@ -71,7 +87,7 @@ export function installRuntime(store: RuntimeStore, stations: Iterable<string>):
71
87
  });
72
88
  if (run.error !== undefined || run.status !== 0)
73
89
  throw new Error(`bun install failed in ${store.dir}: ${run.error?.message ?? `exit ${String(run.status)}`}`);
74
- writeFileSync(join(store.dir, 'node_modules', '.metro-installed'), `${new Date().toISOString()}\n`);
90
+ writeFileSync(marker, `${new Date().toISOString()}\n`);
75
91
  return true;
76
92
  }
77
93
 
@@ -8,7 +8,7 @@ import {
8
8
  type StationHandler,
9
9
  } from '@metro-labs/core/stations/station-runtime';
10
10
  import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
11
- import { accountFor, accounts, targetOf } from './accounts.js';
11
+ import { accountFor, accounts, lineOf, targetOf } from './accounts.js';
12
12
  import { phoneNumberOf, senderLookup } from './resolve.js';
13
13
  import { assertImage, fieldsOf, parseProfileChange, type ProfileApplied } from '@metro-labs/core/stations/profile';
14
14
  import type { WAClient } from './client.js';
@@ -117,6 +117,35 @@ function makeSend(clientFor: ClientFor): StationHandler {
117
117
  };
118
118
  }
119
119
 
120
+ function makeRead(clientFor: ClientFor): StationHandler {
121
+ return async (id, args) => {
122
+ const { accountId, client, jid } = resolve(args, clientFor);
123
+ const page = await guard(() => client.read(jid, {
124
+ limit: typeof args.limit === 'number' ? args.limit : undefined,
125
+ before: str(args.before),
126
+ since: str(args.since),
127
+ }));
128
+ const messages = page.messages.map((row) => ({
129
+ id: row.messageId,
130
+ ts: row.timestamp,
131
+ ...(row.senderJid ? { from: lineOf(accountId, `user/${row.senderJid}`) } : {}),
132
+ self: row.fromMe,
133
+ text: row.text,
134
+ ...(row.truncated ? { truncated: true } : {}),
135
+ ...(row.attachments ? { attachments: row.attachments } : {}),
136
+ }));
137
+ respond(id, { result: { ...page, line: lineOf(accountId, jid), account: accountId, messages } });
138
+ };
139
+ }
140
+
141
+ function makeListMembers(clientFor: ClientFor): StationHandler {
142
+ return async (id, args) => {
143
+ const { client, jid } = resolve({ line: args.line }, clientFor);
144
+ const result = await guard(() => client.listMembers(jid, typeof args.limit === 'number' ? args.limit : undefined));
145
+ respond(id, { result });
146
+ };
147
+ }
148
+
120
149
  function makeReact(clientFor: ClientFor): StationHandler {
121
150
  return async (id, args) => {
122
151
  const { accountId, client, jid } = resolve(args, clientFor);
@@ -208,6 +237,8 @@ export function makeHandleCall(
208
237
  handlers: {
209
238
  accounts: makeAccounts(clientFor),
210
239
  send: makeSend(clientFor),
240
+ read: makeRead(clientFor),
241
+ listMembers: makeListMembers(clientFor),
211
242
  react: makeReact(clientFor),
212
243
  typing: makeTyping(clientFor),
213
244
  edit: makeEdit(clientFor),
@@ -0,0 +1,49 @@
1
+ import { errMsg, log } from '@metro-labs/core/log';
2
+ import { isPnUser, isLidUser, makeEventBuffer, processSyncAction, type BaileysEventEmitter, type Contact } from 'baileys';
3
+ import type { History } from './history.js';
4
+ import { baileysLogger } from './logger.js';
5
+
6
+ interface AppStateHooks {
7
+ history: History;
8
+ current(): boolean;
9
+ }
10
+
11
+ export function supportsHistoryDeletion(processAction = processSyncAction): boolean {
12
+ const ev = makeEventBuffer(baileysLogger('history-support'));
13
+ const id = 'history-support@g.us';
14
+ const messageRange = { lastMessageTimestamp: 1 };
15
+ let received = 0;
16
+ ev.on('chats.clear', (event) => {
17
+ if (event.id === id && event.messageRange === messageRange) received++;
18
+ });
19
+ try {
20
+ for (const initial of [false, true]) {
21
+ for (const type of ['clearChat', 'deleteChat']) {
22
+ const value = type === 'clearChat' ? { clearChatAction: { messageRange } } : { deleteChatAction: { messageRange } };
23
+ const before = received;
24
+ processAction({ index: [type, id], syncAction: { value } }, ev, { id }, initial ? { accountSettings: { unarchiveChats: false } } : undefined);
25
+ if (received !== before + 1) return false;
26
+ }
27
+ }
28
+ return true;
29
+ } catch (err) {
30
+ log.warn({ err: errMsg(err) }, 'whatsapp: local history deletion support check failed');
31
+ return false;
32
+ }
33
+ }
34
+
35
+ export function noteHistoryContact(history: History, contact: Partial<Contact>): void {
36
+ const pn = contact.phoneNumber ?? (isPnUser(contact.id) ? contact.id : undefined);
37
+ const lid = contact.lid ?? (isLidUser(contact.id) ? contact.id : undefined);
38
+ if (pn && lid) history.alias(pn, lid);
39
+ }
40
+
41
+ export function bindAppState(ev: BaileysEventEmitter, hooks: AppStateHooks): void {
42
+ ev.on('lid-mapping.update', ({ pn, lid }) => {
43
+ if (hooks.current()) hooks.history.alias(pn, lid);
44
+ });
45
+ ev.on('chats.clear', ({ id, messageRange }) => {
46
+ if (!hooks.current()) return;
47
+ hooks.history.clearRange(id, messageRange ?? {});
48
+ });
49
+ }
@@ -0,0 +1,31 @@
1
+ import { log } from '@metro-labs/core/log';
2
+ import { TrainError } from '@metro-labs/core/train-error';
3
+ import { supportsHistoryDeletion } from './app-state.js';
4
+ import { createHistory, type History } from './history.js';
5
+
6
+ const UNAVAILABLE = 'WhatsApp local history is unavailable because this runtime lacks the deletion patch. Restart the Metro service parent to install the patched runtime; a page Restart is not enough. Prior history was invalidated to avoid stale deleted messages.';
7
+ const ignore = (): void => undefined;
8
+
9
+ export function createClientHistory(accountId: string, supported = supportsHistoryDeletion()): History {
10
+ const history = createHistory(accountId);
11
+ if (supported) return history;
12
+ try {
13
+ history.invalidate();
14
+ } finally {
15
+ history.close();
16
+ }
17
+ log.warn({ accountId }, UNAVAILABLE);
18
+ return {
19
+ read() { throw new TrainError('whatsapp_history_unavailable', UNAVAILABLE); },
20
+ ingest: ignore,
21
+ ingestSent: ignore,
22
+ alias: ignore,
23
+ clearRange: ignore,
24
+ invalidate: ignore,
25
+ update: ignore,
26
+ deleteMessages: ignore,
27
+ edit: ignore,
28
+ flush: ignore,
29
+ close: ignore,
30
+ };
31
+ }
@@ -0,0 +1,46 @@
1
+ import type makeWASocket from 'baileys';
2
+ import type { fetchLatestWaWebVersion, WAMessage } from 'baileys';
3
+ import type { MemberList } from '@metro-labs/core/stations/types';
4
+ import type { ProfileChange } from '@metro-labs/core/stations/profile';
5
+ import type { SenderProfile } from '@metro-labs/core/stations/sender-profile';
6
+ import type { WhatsAppAccount } from './types.js';
7
+ import type { InboundMessage, ReactionInput } from './format.js';
8
+ import type { SenderFound } from './resolve.js';
9
+ import type { HistoryOptions, HistoryPage } from './history.js';
10
+
11
+ export interface InboundHandlers {
12
+ onMessage(m: InboundMessage, raw: WAMessage): void;
13
+ onReaction(r: ReactionInput): void;
14
+ }
15
+
16
+ export interface WAMedia {
17
+ kind: string;
18
+ path: string;
19
+ mime: string;
20
+ name: string;
21
+ caption?: string;
22
+ }
23
+
24
+ export interface WAClient {
25
+ account: WhatsAppAccount;
26
+ self(): string | null;
27
+ start(handlers: InboundHandlers): Promise<void>;
28
+ read(jid: string, options?: HistoryOptions): Promise<HistoryPage>;
29
+ listMembers(jid: string, limit?: number): Promise<MemberList>;
30
+ sendText(jid: string, text: string, quotedId?: string): Promise<string>;
31
+ sendMedia(jid: string, media: WAMedia, quotedId?: string): Promise<string>;
32
+ sendReaction(jid: string, messageId: string, emoji: string): Promise<void>;
33
+ showTyping(jid: string): Promise<void>;
34
+ editMessage(jid: string, messageId: string, text: string): Promise<void>;
35
+ deleteMessage(jid: string, messageId: string): Promise<void>;
36
+ reuploadMedia(m: WAMessage): Promise<WAMessage>;
37
+ lookupSender(number: string): Promise<SenderFound>;
38
+ setProfile(change: ProfileChange): Promise<void>;
39
+ senderProfile(jid: string): Promise<SenderProfile | null>;
40
+ disconnect(): Promise<void>;
41
+ }
42
+
43
+ export interface ClientRuntime {
44
+ makeSocket: typeof makeWASocket;
45
+ fetchVersion: typeof fetchLatestWaWebVersion;
46
+ }
@@ -11,13 +11,17 @@ import makeWASocket, {
11
11
  import { TrainError } from '@metro-labs/core/train-error';
12
12
  import { errMsg } from '@metro-labs/core/log';
13
13
  import type { WhatsAppAccount } from './types.js';
14
- import type { InboundMessage, ReactionInput } from './format.js';
15
- import type { SenderFound } from './resolve.js';
16
- import type { ProfileChange } from '@metro-labs/core/stations/profile';
14
+ import type { InboundHandlers, WAClient, WAMedia, ClientRuntime } from './client-types.js';
15
+ export type { InboundHandlers, WAClient, WAMedia } from './client-types.js';
17
16
  import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
18
- import { toInbound, toReaction, type ReactionEvent, type SelfRef } from './parse.js';
17
+ import type { SelfRef } from './parse.js';
18
+ import type { History } from './history.js';
19
+ import { createClientHistory } from './client-history.js';
20
+ import { bindMessages } from './messages.js';
21
+ import { listMembers } from './members.js';
22
+ import { SendExpiry } from './send-expiry.js';
19
23
  import { baileysLogger } from './logger.js';
20
- import { makeNameBook, nameFiles, noteContact, phoneOf, type NameBook } from './names.js';
24
+ import { makeNameBook, nameFiles, phoneOf, type NameBook } from './names.js';
21
25
  import { useAccountAuthState } from './auth-state.js';
22
26
  import { knownKey, makeKeyCache, targetKey, type KeyCache } from './keys.js';
23
27
  import { makeOutbox, type Outbox } from './outbox.js';
@@ -30,41 +34,15 @@ import {
30
34
  type AckWatch,
31
35
  } from './ack.js';
32
36
 
33
- export interface InboundHandlers {
34
- onMessage(m: InboundMessage, raw: WAMessage): void;
35
- onReaction(r: ReactionInput): void;
36
- }
37
-
38
- export interface WAMedia {
39
- kind: string;
40
- path: string;
41
- mime: string;
42
- name: string;
43
- caption?: string;
44
- }
45
-
46
- export interface WAClient {
47
- account: WhatsAppAccount;
48
- self(): string | null;
49
- start(handlers: InboundHandlers): Promise<void>;
50
- sendText(jid: string, text: string, quotedId?: string): Promise<string>;
51
- sendMedia(jid: string, media: WAMedia, quotedId?: string): Promise<string>;
52
- sendReaction(jid: string, messageId: string, emoji: string): Promise<void>;
53
- showTyping(jid: string): Promise<void>;
54
- editMessage(jid: string, messageId: string, text: string): Promise<void>;
55
- deleteMessage(jid: string, messageId: string): Promise<void>;
56
- reuploadMedia(m: WAMessage): Promise<WAMessage>;
57
- lookupSender(number: string): Promise<SenderFound>;
58
- setProfile(change: ProfileChange): Promise<void>;
59
- senderProfile(jid: string): Promise<SenderProfile | null>;
60
- disconnect(): Promise<void>;
61
- }
62
-
63
37
  interface State {
64
38
  account: WhatsAppAccount;
39
+ runtime: ClientRuntime;
40
+ history: History;
41
+ expiry: SendExpiry;
65
42
  handlers?: InboundHandlers;
66
43
  sock?: WASocket;
67
44
  closed: boolean;
45
+ closing?: Promise<void>;
68
46
  openResolve?: () => void;
69
47
  openPromise: Promise<void>;
70
48
  keys: KeyCache;
@@ -91,32 +69,14 @@ function selfRef(st: State, sock: WASocket): SelfRef {
91
69
  }
92
70
 
93
71
  function bindInbound(st: State, sock: WASocket): void {
94
- sock.ev.on('messages.upsert', ({ messages, type }) => {
95
- for (const m of messages) st.keys.remember(m.key);
96
- if (type !== 'notify' || !st.handlers) return;
97
- for (const m of messages) {
98
- if (m.key.fromMe) continue;
99
- const inbound = toInbound(st.account.id, m, selfRef(st, sock));
100
- if (inbound) st.names.note(inbound.senderJid, inbound.pushName);
101
- if (inbound) st.handlers.onMessage(inbound, m);
102
- }
103
- });
104
- sock.ev.on('contacts.upsert', (contacts) => {
105
- for (const c of contacts) noteContact(st.names, c);
106
- });
107
- sock.ev.on('contacts.update', (contacts) => {
108
- for (const c of contacts) noteContact(st.names, c);
109
- });
110
- sock.ev.on('messaging-history.set', ({ contacts, messages }) => {
111
- for (const c of contacts) noteContact(st.names, c);
112
- for (const m of messages) if (m.key.fromMe !== true) st.names.note(m.key.participant ?? m.key.remoteJid, m.pushName);
113
- });
114
- sock.ev.on('messages.reaction', (events: ReactionEvent[]) => {
115
- if (!st.handlers) return;
116
- for (const event of events) {
117
- const reaction = toReaction(st.account.id, event);
118
- if (reaction) st.handlers.onReaction(reaction);
119
- }
72
+ bindMessages(sock, {
73
+ accountId: st.account.id,
74
+ keys: st.keys,
75
+ names: st.names,
76
+ history: st.history,
77
+ current: () => !st.closed && st.sock === sock,
78
+ handlers: () => st.handlers,
79
+ self: () => selfRef(st, sock),
120
80
  });
121
81
  }
122
82
 
@@ -197,19 +157,22 @@ async function connect(st: State): Promise<void> {
197
157
  st.account.credentials,
198
158
  st.account.id,
199
159
  );
200
- const { version, error } = await fetchLatestWaWebVersion({});
160
+ if (st.closed) return;
161
+ const { version, error } = await st.runtime.fetchVersion({});
162
+ if (st.closed) return;
201
163
  if (error) {
202
164
  throw new TrainError(
203
165
  'whatsapp_connect',
204
166
  `failed to fetch WhatsApp web version: ${errMsg(error)}`,
205
167
  );
206
168
  }
207
- const sock = makeWASocket({
169
+ const sock = st.runtime.makeSocket({
208
170
  version,
209
171
  auth: state,
210
172
  browser: Browsers.macOS('Safari'),
211
173
  markOnlineOnConnect: false,
212
174
  syncFullHistory: false,
175
+ emitOwnEvents: false,
213
176
  logger: baileysLogger(st.account.id),
214
177
  getMessage: (key) => Promise.resolve(servedFromOutbox(st, key)),
215
178
  });
@@ -217,6 +180,7 @@ async function connect(st: State): Promise<void> {
217
180
  sock.ev.on('creds.update', () => void saveCreds());
218
181
  bindConnection(st, sock);
219
182
  bindInbound(st, sock);
183
+ st.expiry.bind(sock, () => !st.closed && st.sock === sock);
220
184
  bindDelivery(st, sock);
221
185
  }
222
186
 
@@ -233,7 +197,7 @@ async function lidFor(st: State, sock: WASocket, jid: string): Promise<string |
233
197
 
234
198
  async function ready(st: State): Promise<WASocket> {
235
199
  await st.openPromise;
236
- if (!st.sock) throw new TrainError('whatsapp_call', 'socket not connected');
200
+ if (st.closed || !st.sock) throw new TrainError('whatsapp_call', 'socket not connected');
237
201
  return st.sock;
238
202
  }
239
203
 
@@ -244,10 +208,13 @@ async function send(
244
208
  opts?: SendOpts,
245
209
  ): Promise<string> {
246
210
  const sock = await ready(st);
247
- const sent = await sock.sendMessage(jid, content, opts);
248
- st.keys.remember(sent?.key);
249
- st.outbox.remember(sent?.key, sent?.message);
250
- const messageId = sent?.key.id;
211
+ const expiration = await st.expiry.forSend(sock, jid, content);
212
+ if (st.closed || st.sock !== sock) throw new TrainError('whatsapp_call', 'socket not connected');
213
+ const sent = await sock.sendMessage(jid, content, expiration === undefined ? opts : { ...opts, ephemeralExpiration: expiration });
214
+ const key = sent?.key;
215
+ st.keys.remember(key);
216
+ st.outbox.remember(key, sent?.message);
217
+ const messageId = key?.id;
251
218
  if (!messageId)
252
219
  throw new TrainError(
253
220
  'whatsapp_call',
@@ -256,9 +223,15 @@ async function send(
256
223
  const ack = await st.acks.wait(messageId, ACK_WAIT_MS);
257
224
  const refused = ack ? rejection(ack) : undefined;
258
225
  if (refused) throw refused;
226
+ recordSent(st, sock, sent, expiration);
259
227
  return messageId;
260
228
  }
261
229
 
230
+ function recordSent(st: State, sock: WASocket, sent: WAMessage | undefined, expiration?: number): void {
231
+ if (!sent || st.closed) return;
232
+ st.history.ingestSent([sent], sock.user?.id ? jidNormalizedUser(sock.user.id) : undefined, expiration);
233
+ }
234
+
262
235
  function quotedOpts(st: State, jid: string, quotedId: string): SendOpts {
263
236
  return {
264
237
  quoted: {
@@ -300,9 +273,24 @@ async function readProfile(st: State, jid: string): Promise<SenderProfile> {
300
273
  };
301
274
  }
302
275
 
303
- export function createClient(account: WhatsAppAccount): WAClient {
276
+ async function disconnect(st: State): Promise<void> {
277
+ st.closed = true;
278
+ st.openResolve?.();
279
+ try {
280
+ st.history.close();
281
+ } finally {
282
+ const sock = st.sock;
283
+ st.sock = undefined;
284
+ await sock?.end(undefined);
285
+ }
286
+ }
287
+
288
+ function initialState(account: WhatsAppAccount, runtime: ClientRuntime): State {
304
289
  const st: State = {
305
290
  account,
291
+ runtime,
292
+ history: createClientHistory(account.id),
293
+ expiry: new SendExpiry(account.id),
306
294
  closed: false,
307
295
  openPromise: Promise.resolve(),
308
296
  keys: makeKeyCache(),
@@ -311,6 +299,14 @@ export function createClient(account: WhatsAppAccount): WAClient {
311
299
  names: makeNameBook(nameFiles.path(account.id)),
312
300
  };
313
301
  resetGate(st);
302
+ return st;
303
+ }
304
+
305
+ export function createClient(
306
+ account: WhatsAppAccount,
307
+ runtime: ClientRuntime = { makeSocket: makeWASocket, fetchVersion: fetchLatestWaWebVersion },
308
+ ): WAClient {
309
+ const st = initialState(account, runtime);
314
310
  const senders = makeProfileCache<SenderProfile>((jid) => readProfile(st, jid), {
315
311
  onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`),
316
312
  });
@@ -331,6 +327,14 @@ export function createClient(account: WhatsAppAccount): WAClient {
331
327
  );
332
328
  }
333
329
  },
330
+ read(jid, options) {
331
+ const page = st.history.read(jid, options);
332
+ for (const message of page.messages) st.keys.remember(message.key);
333
+ return Promise.resolve(page);
334
+ },
335
+ listMembers(jid, limit) {
336
+ return listMembers(jid, async (node): Promise<unknown> => await (await ready(st)).query(node), st.names, limit);
337
+ },
334
338
  sendText(jid, text, quotedId) {
335
339
  return send(
336
340
  st,
@@ -365,6 +369,7 @@ export function createClient(account: WhatsAppAccount): WAClient {
365
369
  await send(st, jid, {
366
370
  delete: knownKey(st.keys, jid, messageId, true),
367
371
  });
372
+ if (!st.closed) st.history.deleteMessages({ keys: [{ remoteJid: jid, id: messageId }] });
368
373
  },
369
374
  async reuploadMedia(m) {
370
375
  const sock = await ready(st);
@@ -388,13 +393,6 @@ export function createClient(account: WhatsAppAccount): WAClient {
388
393
  }
389
394
  },
390
395
  senderProfile: (jid) => senders.get(jid),
391
- async disconnect() {
392
- st.closed = true;
393
- try {
394
- await st.sock?.end(undefined);
395
- } catch {
396
- st.sock = undefined;
397
- }
398
- },
396
+ disconnect: () => st.closing ??= disconnect(st),
399
397
  };
400
398
  }