@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files-api.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files.ts +117 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-lookups.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +22 -64
- package/runtime/node_modules/@metro-labs/daemon/src/agents/sender-cards.ts +86 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +26 -10
- package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +28 -0
- package/runtime/runtime.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.
|
|
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.
|
|
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
|
-
|
|
379
|
-
if (
|
|
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
|
-
|
|
285
|
-
|
|
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
|
+
};
|
package/runtime/runtime.json
CHANGED