@stage-labs/metro 0.1.0-beta.194 → 0.1.0-beta.196

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.194",
3
+ "version": "0.1.0-beta.196",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.194",
3
+ "version": "0.1.0-beta.196",
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",
@@ -0,0 +1,12 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { sessionRoute } from '@metro-labs/http/api-http';
3
+ import { readAgentPath } from './files.js';
4
+ import { agentUser } from './user.js';
5
+
6
+ const PATH = '/api/files';
7
+
8
+ const queryPath = (req: IncomingMessage): string => new URLSearchParams((req.url ?? '').split('?')[1] ?? '').get('path') ?? '';
9
+
10
+ export function handleFilesRequest(req: IncomingMessage, res: ServerResponse): boolean {
11
+ return sessionRoute(req, res, { methods: { [PATH]: ['GET'] }, admin: true, label: 'files-api' }, () => Promise.resolve(readAgentPath(agentUser(), queryPath(req))));
12
+ }
@@ -0,0 +1,117 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ApiError } from '@metro-labs/http/api-error';
6
+ import { asUser, type AgentUser } from './user.js';
7
+
8
+ export type EntryKind = 'folder' | 'file' | 'other';
9
+
10
+ export interface FileEntry {
11
+ name: string;
12
+ kind: EntryKind;
13
+ bytes: number;
14
+ modifiedAt: string;
15
+ }
16
+
17
+ export type FilesAnswer =
18
+ | { root: string; path: string; kind: 'folder'; entries: FileEntry[]; more: boolean }
19
+ | { root: string; path: string; kind: 'file'; bytes: number; modifiedAt: string; text: string | null; truncated: boolean };
20
+
21
+ const MAX_ENTRIES = 2000;
22
+ const PREVIEW_BYTES = 256 * 1024;
23
+ const SNIFF_BYTES = 8192;
24
+
25
+ const SCRIPT = [
26
+ 'if [ -d "$1" ]; then',
27
+ ' printf "D\\0"; find -H "$1" -mindepth 1 -maxdepth 1 -printf "%y\\0%s\\0%T@\\0%f\\0"',
28
+ 'elif [ -f "$1" ]; then',
29
+ ' printf "F\\0"; stat -L -c "%s %Y" -- "$1" | tr "\\n" "\\0"; head -c "$2" -- "$1"',
30
+ 'else',
31
+ ' echo "no such file or folder" >&2; exit 2',
32
+ 'fi',
33
+ ].join('\n');
34
+
35
+ export function segmentsOf(path: string): string[] {
36
+ const parts = path.split('/').filter((part) => part !== '');
37
+ if (parts.some((part) => part === '.' || part === '..' || part.includes('\0')))
38
+ throw new ApiError('that path is not inside the home folder', 400);
39
+ return parts;
40
+ }
41
+
42
+ const stamp = (seconds: number): string => (Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : '');
43
+
44
+ const kindOf = (letter: string): EntryKind => (letter === 'd' ? 'folder' : letter === 'f' ? 'file' : 'other');
45
+
46
+ const byKindThenName = (a: FileEntry, b: FileEntry): number =>
47
+ a.kind === 'folder' && b.kind !== 'folder' ? -1 : b.kind === 'folder' && a.kind !== 'folder' ? 1 : a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
48
+
49
+ function preview(bytes: Buffer, size: number): { text: string | null; truncated: boolean } {
50
+ const binary = bytes.subarray(0, SNIFF_BYTES).includes(0);
51
+ return { text: binary ? null : bytes.subarray(0, PREVIEW_BYTES).toString('utf8'), truncated: size > PREVIEW_BYTES };
52
+ }
53
+
54
+ function folderAnswer(root: string, path: string, entries: FileEntry[]): FilesAnswer {
55
+ const sorted = entries.sort(byKindThenName);
56
+ return { root, path, kind: 'folder', entries: sorted.slice(0, MAX_ENTRIES), more: sorted.length > MAX_ENTRIES };
57
+ }
58
+
59
+ function parseListing(out: Buffer): FileEntry[] {
60
+ const fields = out.toString('utf8').split('\0');
61
+ const entries: FileEntry[] = [];
62
+ for (let at = 0; at + 3 < fields.length; at += 4)
63
+ entries.push({ kind: kindOf(fields[at] ?? ''), bytes: Number(fields[at + 1]), modifiedAt: stamp(Number(fields[at + 2])), name: fields[at + 3] ?? '' });
64
+ return entries;
65
+ }
66
+
67
+ function parseFile(root: string, path: string, out: Buffer): FilesAnswer {
68
+ const end = out.indexOf(0);
69
+ const [size = '0', mtime = '0'] = out.subarray(0, end).toString('utf8').trim().split(' ');
70
+ const bytes = Number(size);
71
+ return { root, path, kind: 'file', bytes, modifiedAt: stamp(Number(mtime)), ...preview(out.subarray(end + 1), bytes) };
72
+ }
73
+
74
+ function readAsAgent(user: AgentUser, path: string): FilesAnswer {
75
+ const target = join(user.home, ...segmentsOf(path));
76
+ const [file, argv] = asUser(user, 'sh', ['-c', SCRIPT, 'metro', target, String(PREVIEW_BYTES + 1)]);
77
+ const run = spawnSync(file, argv, { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, timeout: 20_000 });
78
+ if (run.error !== undefined) throw new ApiError(`could not read that path: ${run.error.message}`, 500);
79
+ if (run.status !== 0) throw new ApiError(run.stderr.toString('utf8').trim() || 'the agent cannot read that path', 404);
80
+ const out = run.stdout;
81
+ const body = out.subarray(2);
82
+ return out.subarray(0, 1).toString() === 'D' ? folderAnswer(user.home, path, parseListing(body)) : parseFile(user.home, path, body);
83
+ }
84
+
85
+ function readHead(target: string): Buffer {
86
+ const fd = openSync(target, 'r');
87
+ try {
88
+ const buffer = Buffer.alloc(PREVIEW_BYTES + 1);
89
+ return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0));
90
+ } finally {
91
+ closeSync(fd);
92
+ }
93
+ }
94
+
95
+ function readLocal(root: string, path: string): FilesAnswer {
96
+ const target = join(root, ...segmentsOf(path));
97
+ let stat;
98
+ try {
99
+ stat = statSync(target);
100
+ } catch {
101
+ throw new ApiError('no such file or folder', 404);
102
+ }
103
+ if (stat.isFile()) return { root, path, kind: 'file', bytes: stat.size, modifiedAt: stat.mtime.toISOString(), ...preview(readHead(target), stat.size) };
104
+ const entries = readdirSync(target, { withFileTypes: true }).map((entry): FileEntry => {
105
+ const inner = join(target, entry.name);
106
+ try {
107
+ const s = statSync(inner);
108
+ return { name: entry.name, kind: s.isDirectory() ? 'folder' : s.isFile() ? 'file' : 'other', bytes: s.size, modifiedAt: s.mtime.toISOString() };
109
+ } catch {
110
+ return { name: entry.name, kind: 'other', bytes: 0, modifiedAt: '' };
111
+ }
112
+ });
113
+ return folderAnswer(root, path, entries);
114
+ }
115
+
116
+ export const readAgentPath = (user: AgentUser | null, path: string, localRoot = homedir()): FilesAnswer =>
117
+ user === null ? readLocal(localRoot, path) : readAsAgent(user, path);
@@ -0,0 +1,55 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { StationName } from '@metro-labs/core/station-names';
3
+ import { ApiError } from '@metro-labs/http/api-error';
4
+ import { bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
5
+ import { stationByName } from '../stations/registry.js';
6
+ import type { AccountRoute } from './account-routes.js';
7
+ import { handleSenderCards, type SenderCardDeps } from './sender-cards.js';
8
+
9
+ interface Target {
10
+ station: StationName;
11
+ accountId: string;
12
+ }
13
+
14
+ export interface LookupDeps extends SenderCardDeps {
15
+ resolveSender: (station: StationName, accountId: string, query: string) => Promise<unknown>;
16
+ }
17
+
18
+ type Lookup = (req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target) => Promise<void>;
19
+
20
+ function lookupQuery(req: IncomingMessage): string {
21
+ const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
22
+ return (params.get('q') ?? '').trim();
23
+ }
24
+
25
+ async function handleResolve(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
26
+ if (stationByName(target.station)?.resolvesSenders !== true)
27
+ throw new ApiError(`metro cannot look a sender up on ${target.station}`, 400);
28
+ const query = lookupQuery(req);
29
+ if (query === '') throw new ApiError('a number to look up is required', 400);
30
+ sendJson(req, res, 200, await deps.resolveSender(target.station, target.accountId, query));
31
+ }
32
+
33
+ async function handleName(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
34
+ if (stationByName(target.station)?.claimsName !== true)
35
+ throw new ApiError(`${target.station} accounts have no name to claim`, 400);
36
+ if (req.method === 'GET') {
37
+ sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
38
+ return;
39
+ }
40
+ const label = bodyField(await readJsonBody(req), 'label');
41
+ if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
42
+ sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
43
+ }
44
+
45
+ function handleRecent(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
46
+ sendJson(req, res, 200, { senders: deps.recentSenders(target.station, target.accountId) });
47
+ return Promise.resolve();
48
+ }
49
+
50
+ export const LOOKUPS: Partial<Record<AccountRoute['kind'], Lookup>> = {
51
+ profiles: handleSenderCards,
52
+ resolve: handleResolve,
53
+ name: handleName,
54
+ senders: handleRecent,
55
+ };
@@ -12,6 +12,7 @@ export type AccountRoute =
12
12
  | { kind: 'resolve'; station: StationName; accountId: string }
13
13
  | { kind: 'enabled'; station: StationName; accountId: string }
14
14
  | { kind: 'policy'; station: StationName; accountId: string }
15
+ | { kind: 'profiles'; station: StationName; accountId: string }
15
16
  | { kind: 'name'; station: StationName; accountId: string };
16
17
 
17
18
  const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
@@ -24,10 +25,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
24
25
  resolve: ['GET'],
25
26
  enabled: ['PUT'],
26
27
  policy: ['PUT'],
28
+ profiles: ['GET'],
27
29
  name: ['GET', 'POST'],
28
30
  };
29
31
 
30
- const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy'] as const;
32
+ const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles'] as const;
31
33
 
32
34
  type SubRoute = (typeof SUB_ROUTES)[number];
33
35
 
@@ -5,15 +5,12 @@ import { approversForAccount } from './map.js';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import type { RecentSender } from './senders.js';
7
7
  import { ApiError } from '@metro-labs/http/api-error';
8
- import {
9
- apiFailure,
10
- bodyField,
11
- readJsonBody,
12
- sendJson,
13
- } from '@metro-labs/http/api-http';
8
+ import { apiFailure, bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
14
9
  import { type AccountRef } from './account-attach.js';
15
10
  import { type AccountRoute } from './account-routes.js';
16
11
  import { handlePolicy, type SetPolicy } from './policy-route.js';
12
+ import { LOOKUPS } from './account-lookups.js';
13
+ import { unconfirmedSenders } from './sender-cards.js';
17
14
  import { stationByName } from '../stations/registry.js';
18
15
  import type { StationName } from '@metro-labs/core/station-names';
19
16
  import {
@@ -92,46 +89,7 @@ export interface AccountApiDeps {
92
89
  ) => Promise<boolean>;
93
90
  }
94
91
 
95
- export const ATTACHABLE: string[] = [
96
- ...ATTACHABLE_STATIONS,
97
- ...INTERACTIVE_STATIONS,
98
- ];
99
-
100
- function lookupQuery(req: IncomingMessage): string {
101
- const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
102
- return (params.get('q') ?? '').trim();
103
- }
104
-
105
- async function handleResolve(
106
- req: IncomingMessage,
107
- res: ServerResponse,
108
- deps: AccountApiDeps,
109
- target: { station: StationName; accountId: string },
110
- ): Promise<void> {
111
- if (stationByName(target.station)?.resolvesSenders !== true)
112
- throw new ApiError(`metro cannot look a sender up on ${target.station}`, 400);
113
- const query = lookupQuery(req);
114
- if (query === '') throw new ApiError('a number to look up is required', 400);
115
- const found = await deps.resolveSender(target.station, target.accountId, query);
116
- sendJson(req, res, 200, found);
117
- }
118
-
119
- async function handleName(
120
- req: IncomingMessage,
121
- res: ServerResponse,
122
- deps: AccountApiDeps,
123
- target: { station: StationName; accountId: string },
124
- ): Promise<void> {
125
- if (stationByName(target.station)?.claimsName !== true)
126
- throw new ApiError(`${target.station} accounts have no name to claim`, 400);
127
- if (req.method === 'GET') {
128
- sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
129
- return;
130
- }
131
- const label = bodyField(await readJsonBody(req), 'label');
132
- if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
133
- sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
134
- }
92
+ export const ATTACHABLE: string[] = [...ATTACHABLE_STATIONS, ...INTERACTIVE_STATIONS];
135
93
 
136
94
  async function activate(
137
95
  deps: AccountApiDeps,
@@ -286,6 +244,9 @@ async function handleAllowlist(
286
244
  const wanted = normalizeAllowlist(bodyField(body, 'allowlist'));
287
245
  const asked = isRecord(body) && 'approvers' in body ? body.approvers : approversForAccount(target.station, target.accountId);
288
246
  const approvers = normalizeApprovers(asked, wanted);
247
+ const before = new Set(approversForAccount(target.station, target.accountId).map((a) => a.toLowerCase()));
248
+ const unknown = await unconfirmedSenders(deps, target.station, target.accountId, approvers.filter((a) => !before.has(a.toLowerCase())));
249
+ if (unknown.length > 0) throw new ApiError(`${target.station} does not know ${unknown.join(', ')}. Check the id, or have them write to the agent once, then try again.`, 400);
289
250
  const allowlist = await deps.setAllowlist(agentId, target.station, target.accountId, wanted, approvers);
290
251
  log.info({ agentId, station: target.station, account: target.accountId, senders: allowlist.length }, 'account-api: allowlist set');
291
252
  sendJson(req, res, 200, {
@@ -353,6 +314,18 @@ async function handleStep(
353
314
  sendJson(req, res, 200, view);
354
315
  }
355
316
 
317
+ async function dispatchAttach(
318
+ req: IncomingMessage,
319
+ res: ServerResponse,
320
+ deps: AccountApiDeps,
321
+ agentId: string,
322
+ route: Extract<AccountRoute, { kind: 'start' | 'session' | 'step' }>,
323
+ ): Promise<void> {
324
+ if (route.kind === 'start') return handleStart(req, res, deps, agentId);
325
+ if (route.kind === 'session') return handleSession(req, res, deps, { agentId }, route.attachId);
326
+ return handleStep(req, res, deps, { agentId }, route.attachId);
327
+ }
328
+
356
329
  async function dispatchRoute(
357
330
  req: IncomingMessage,
358
331
  res: ServerResponse,
@@ -360,27 +333,12 @@ async function dispatchRoute(
360
333
  agentId: string,
361
334
  route: AccountRoute,
362
335
  ): Promise<void> {
363
- if (route.kind === 'start')
364
- return handleStart(req, res, deps, agentId);
365
- if (route.kind === 'session')
366
- return handleSession(
367
- req,
368
- res,
369
- deps,
370
- { agentId },
371
- route.attachId,
372
- );
373
- if (route.kind === 'step')
374
- return handleStep(req, res, deps, { agentId }, route.attachId);
336
+ if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route);
375
337
  if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
376
338
  if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
377
339
  if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
378
- if (route.kind === 'resolve') return handleResolve(req, res, deps, route);
379
- if (route.kind === 'name') return handleName(req, res, deps, route);
380
- if (route.kind === 'senders') {
381
- sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
382
- return;
383
- }
340
+ const lookup = LOOKUPS[route.kind];
341
+ if (lookup !== undefined && 'station' in route) return lookup(req, res, deps, route);
384
342
  return handleDetach(req, res, deps, agentId, route);
385
343
  }
386
344
 
@@ -0,0 +1,86 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { StationName } from '@metro-labs/core/station-names';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
+ import { sendJson } from '@metro-labs/http/api-http';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { stationByName } from '../stations/registry.js';
7
+ import { allowlistForAccount } from './map.js';
8
+ import type { RecentSender } from './senders.js';
9
+
10
+ export interface SenderCard {
11
+ id: string;
12
+ name?: string;
13
+ handle?: string;
14
+ }
15
+
16
+ export interface SenderCardDeps {
17
+ recentSenders: (station: StationName, accountId: string) => RecentSender[];
18
+ accountCall: (station: StationName, action: string, args: Record<string, unknown>) => Promise<unknown>;
19
+ }
20
+
21
+ const MAX_LOOKUPS = 50;
22
+ const LOOKUP_MS = 8_000;
23
+
24
+ const filled = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
25
+
26
+ function cardOf(id: string, profile: unknown, seenName: string | undefined): SenderCard {
27
+ const p = isRecord(profile) ? profile : {};
28
+ const display = filled(p.display_name);
29
+ const handle = filled(p.name);
30
+ const name = display ?? seenName ?? handle;
31
+ return {
32
+ id,
33
+ ...(name === undefined ? {} : { name }),
34
+ ...(handle === undefined || handle === name ? {} : { handle }),
35
+ };
36
+ }
37
+
38
+ async function lookup(deps: SenderCardDeps, station: StationName, accountId: string, user: string): Promise<unknown> {
39
+ let timer: ReturnType<typeof setTimeout> | undefined;
40
+ const late = new Promise<null>((resolve) => {
41
+ timer = setTimeout(() => {
42
+ resolve(null);
43
+ }, LOOKUP_MS);
44
+ });
45
+ try {
46
+ return await Promise.race([deps.accountCall(station, 'profile', { account: accountId, user }), late]);
47
+ } catch (err) {
48
+ log.info({ station, account: accountId, err: errMsg(err) }, 'account-api: could not read the profile of a listed sender');
49
+ return null;
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+
55
+ export async function senderCards(deps: SenderCardDeps, station: StationName, accountId: string): Promise<SenderCard[]> {
56
+ const ids = (allowlistForAccount(station, accountId) ?? []).filter((id) => id !== '*' && !id.startsWith('@'));
57
+ const seen = new Map(deps.recentSenders(station, accountId).map((s) => [s.id.toLowerCase(), filled(s.name)]));
58
+ const reads = stationByName(station)?.readsProfiles === true;
59
+ return Promise.all(
60
+ ids.map(async (id, at) => {
61
+ const profile = reads && at < MAX_LOOKUPS ? await lookup(deps, station, accountId, id) : null;
62
+ return cardOf(id, profile, seen.get(id.toLowerCase()));
63
+ }),
64
+ );
65
+ }
66
+
67
+ const known = (profile: unknown): boolean =>
68
+ isRecord(profile) && ['name', 'display_name', 'about', 'avatar', 'address'].some((key) => filled(profile[key]) !== undefined);
69
+
70
+ export async function unconfirmedSenders(deps: SenderCardDeps, station: StationName, accountId: string, ids: string[]): Promise<string[]> {
71
+ if (stationByName(station)?.readsProfiles !== true || ids.length === 0) return [];
72
+ const seen = new Set(deps.recentSenders(station, accountId).map((s) => s.id.toLowerCase()));
73
+ const checks = await Promise.all(
74
+ ids.map(async (id) => (seen.has(id.toLowerCase()) || known(await lookup(deps, station, accountId, id)) ? null : id)),
75
+ );
76
+ return checks.filter((id): id is string => id !== null);
77
+ }
78
+
79
+ export async function handleSenderCards(
80
+ req: IncomingMessage,
81
+ res: ServerResponse,
82
+ deps: SenderCardDeps,
83
+ target: { station: StationName; accountId: string },
84
+ ): Promise<void> {
85
+ sendJson(req, res, 200, { senders: await senderCards(deps, target.station, target.accountId) });
86
+ }
@@ -13,7 +13,7 @@ export const GET_PROFILE_TOOL: ToolDef = {
13
13
  '(`metro://<station>/<account>/user/<id>`) and get what their network says about them: ' +
14
14
  '{id, name, display_name, about, avatar, address}. XMTP answers the Basename ' +
15
15
  '(<label>.stage.base.eth), its name, description and avatar records, and the wallet address; ' +
16
- 'WhatsApp the about line and the picture; Telegram the handle, the name and the bio; Discord ' +
16
+ 'WhatsApp the phone number, the name they last used, the about line and the picture; Telegram the handle, the name and the bio; Discord ' +
17
17
  'the username, the global name and the avatar. Fields the network does not have are absent. ' +
18
18
  'The answer is cached for ten minutes, so ask when you want to know someone, not on every message.',
19
19
  inputSchema: {
@@ -196,6 +196,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
196
196
  updateApi: { restart: deps.restart },
197
197
  controlApi: { restart: deps.restart, stop: deps.stop },
198
198
  schedulesApi: true,
199
+ filesApi: true,
199
200
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
200
201
  machineApi: {},
201
202
  modelApi: {},
@@ -18,6 +18,7 @@ import { handleModelRequest, type ModelApiDeps } from '../gateway/model-api.js';
18
18
  import type { GatewayDeps } from '../gateway/gateway.js';
19
19
  import { handleTerminalRequest, type TerminalApiDeps } from '../terminal/api.js';
20
20
  import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
21
+ import { handleFilesRequest } from '../agent-user/files-api.js';
21
22
 
22
23
  export interface SessionApis {
23
24
  agentApi?: AgentApiDeps;
@@ -25,6 +26,7 @@ export interface SessionApis {
25
26
  updateApi?: UpdateApiDeps;
26
27
  controlApi?: ControlApiDeps;
27
28
  schedulesApi?: true;
29
+ filesApi?: true;
28
30
  ownerApi?: OwnerApiDeps;
29
31
  machineApi?: MachineApiDeps;
30
32
  modelApi?: ModelApiDeps;
@@ -52,6 +54,7 @@ export function handleSessionApis(
52
54
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
53
55
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
54
56
  ...when(apis.schedulesApi, () => handleSchedulesRequest(req, res)),
57
+ ...when(apis.filesApi, () => handleFilesRequest(req, res)),
55
58
  ...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
56
59
  ...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
57
60
  ...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
@@ -17,6 +17,7 @@ import type { ProfileChange } from '@metro-labs/core/stations/profile';
17
17
  import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
18
18
  import { toInbound, toReaction, type ReactionEvent, type SelfRef } from './parse.js';
19
19
  import { baileysLogger } from './logger.js';
20
+ import { makeNameBook, phoneOf, type NameBook } from './names.js';
20
21
  import { useAccountAuthState } from './auth-state.js';
21
22
  import { knownKey, makeKeyCache, targetKey, type KeyCache } from './keys.js';
22
23
  import { makeOutbox, type Outbox } from './outbox.js';
@@ -68,6 +69,7 @@ interface State {
68
69
  keys: KeyCache;
69
70
  outbox: Outbox;
70
71
  acks: AckWatch;
72
+ names: NameBook;
71
73
  }
72
74
 
73
75
  type SendOpts = Parameters<WASocket['sendMessage']>[2];
@@ -94,9 +96,13 @@ function bindInbound(st: State, sock: WASocket): void {
94
96
  for (const m of messages) {
95
97
  if (m.key.fromMe) continue;
96
98
  const inbound = toInbound(st.account.id, m, selfRef(st, sock));
99
+ if (inbound) st.names.note(inbound.senderJid, inbound.pushName);
97
100
  if (inbound) st.handlers.onMessage(inbound, m);
98
101
  }
99
102
  });
103
+ sock.ev.on('contacts.upsert', (contacts) => {
104
+ for (const c of contacts) st.names.note(c.id, c.name ?? c.notify);
105
+ });
100
106
  sock.ev.on('messages.reaction', (events: ReactionEvent[]) => {
101
107
  if (!st.handlers) return;
102
108
  for (const event of events) {
@@ -270,6 +276,22 @@ function mediaContent(m: WAMedia): SendContent {
270
276
  };
271
277
  }
272
278
 
279
+ async function readProfile(st: State, jid: string): Promise<SenderProfile> {
280
+ const sock = await ready(st);
281
+ const [statuses, avatar] = await Promise.all([sock.fetchStatus(jid).catch(() => undefined), sock.profilePictureUrl(jid, 'image').catch(() => undefined)]);
282
+ const about = nonEmpty((statuses?.[0]?.status as { status?: unknown } | undefined)?.status);
283
+ const picture = nonEmpty(avatar);
284
+ const phone = phoneOf(jid.endsWith('@lid') ? await sock.signalRepository.lidMapping.getPNForLID(jid).catch(() => null) : jid);
285
+ const name = st.names.get(jid);
286
+ return {
287
+ id: jid,
288
+ ...(name === undefined ? {} : { display_name: name }),
289
+ ...(phone === undefined ? {} : { name: phone, address: phone }),
290
+ ...(about === undefined ? {} : { about }),
291
+ ...(picture === undefined ? {} : { avatar: picture }),
292
+ };
293
+ }
294
+
273
295
  export function createClient(account: WhatsAppAccount): WAClient {
274
296
  const st: State = {
275
297
  account,
@@ -278,18 +300,12 @@ export function createClient(account: WhatsAppAccount): WAClient {
278
300
  keys: makeKeyCache(),
279
301
  outbox: makeOutbox(),
280
302
  acks: makeAckWatch(),
303
+ names: makeNameBook(),
281
304
  };
282
305
  resetGate(st);
283
- const senders = makeProfileCache<SenderProfile>(
284
- async (jid) => {
285
- const sock = await ready(st);
286
- const [statuses, avatar] = await Promise.all([sock.fetchStatus(jid).catch(() => undefined), sock.profilePictureUrl(jid, 'image').catch(() => undefined)]);
287
- const about = nonEmpty((statuses?.[0]?.status as { status?: unknown } | undefined)?.status);
288
- const picture = nonEmpty(avatar);
289
- return { id: jid, ...(about === undefined ? {} : { about }), ...(picture === undefined ? {} : { avatar: picture }) };
290
- },
291
- { onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`) },
292
- );
306
+ const senders = makeProfileCache<SenderProfile>((jid) => readProfile(st, jid), {
307
+ onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`),
308
+ });
293
309
  return {
294
310
  account,
295
311
  self() {
@@ -0,0 +1,28 @@
1
+ const MAX_NAMES = 2000;
2
+
3
+ export interface NameBook {
4
+ note(jid: string | null | undefined, name: string | null | undefined): void;
5
+ get(jid: string): string | undefined;
6
+ }
7
+
8
+ export function makeNameBook(): NameBook {
9
+ const names = new Map<string, string>();
10
+ return {
11
+ note(jid, name) {
12
+ const clean = name?.trim() ?? '';
13
+ if (!jid || clean === '') return;
14
+ names.delete(jid);
15
+ names.set(jid, clean);
16
+ if (names.size > MAX_NAMES) {
17
+ const oldest = names.keys().next().value;
18
+ if (oldest !== undefined) names.delete(oldest);
19
+ }
20
+ },
21
+ get: (jid) => names.get(jid),
22
+ };
23
+ }
24
+
25
+ export const phoneOf = (jid: string | null | undefined): string | undefined => {
26
+ const digits = /^(\d{6,})(?::\d+)?@s\.whatsapp\.net$/.exec(jid ?? '')?.[1];
27
+ return digits === undefined ? undefined : `+${digits}`;
28
+ };
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.194"
2
+ "version": "0.1.0-beta.196"
3
3
  }