@stage-labs/metro 0.1.0-beta.316 → 0.1.0-beta.318
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +23 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +30 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +13 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +9 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/pair-again.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +6 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +13 -4
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +14 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +24 -13
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +3 -2
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +35 -40
- package/runtime/node_modules/@metro-labs/whatsapp/src/connection.ts +139 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +36 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +24 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/names.ts +46 -15
- package/runtime/node_modules/@metro-labs/whatsapp/src/profile.ts +158 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-status.ts +26 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +2 -0
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./lines": "./src/lines.ts",
|
|
27
27
|
"./endpoints": "./src/endpoints.ts",
|
|
28
28
|
"./stations/types": "./src/stations/types.ts",
|
|
29
|
+
"./stations/account-status": "./src/stations/account-status.ts",
|
|
29
30
|
"./stations/channel-directory": "./src/stations/channel-directory.ts",
|
|
30
31
|
"./stations/channel-pages": "./src/stations/channel-pages.ts",
|
|
31
32
|
"./stations/account-files": "./src/stations/account-files.ts",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isRecord } from '../is-record.js';
|
|
2
|
+
|
|
3
|
+
export const ACCOUNT_STATES = ['connected', 'connecting', 'logged_out', 'disconnected'] as const;
|
|
4
|
+
|
|
5
|
+
export type AccountState = (typeof ACCOUNT_STATES)[number];
|
|
6
|
+
|
|
7
|
+
export interface AccountStatus {
|
|
8
|
+
state: AccountState;
|
|
9
|
+
reason?: string;
|
|
10
|
+
since: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const REASON_MAX = 300;
|
|
14
|
+
|
|
15
|
+
export function accountStatus(state: AccountState, reason?: string, since = Date.now()): AccountStatus {
|
|
16
|
+
const text = reason?.trim().slice(0, REASON_MAX) ?? '';
|
|
17
|
+
return text === '' ? { state, since } : { state, reason: text, since };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function accountStatusOf(raw: unknown): AccountStatus | null {
|
|
21
|
+
if (!isRecord(raw)) return null;
|
|
22
|
+
const state = ACCOUNT_STATES.find((s) => s === raw.state);
|
|
23
|
+
const since = raw.since;
|
|
24
|
+
if (state === undefined || typeof since !== 'number' || !Number.isFinite(since)) return null;
|
|
25
|
+
return accountStatus(state, typeof raw.reason === 'string' ? raw.reason : undefined, since);
|
|
26
|
+
}
|
|
@@ -114,6 +114,8 @@ export interface Station {
|
|
|
114
114
|
readFilters?: ReadonlySet<ReadFilter>;
|
|
115
115
|
approvals?: boolean;
|
|
116
116
|
sendsFrames?: boolean;
|
|
117
|
+
reportsStatus?: boolean;
|
|
118
|
+
pairAgain?: { identity: string };
|
|
117
119
|
attachmentMode: AttachmentMode;
|
|
118
120
|
forget?: (accountId: string) => void;
|
|
119
121
|
forgetExcept?: (keptAccountIds: readonly string[]) => void;
|
|
@@ -13,6 +13,7 @@ export type AccountRoute =
|
|
|
13
13
|
| { kind: 'enabled'; station: StationName; accountId: string }
|
|
14
14
|
| { kind: 'policy'; station: StationName; accountId: string }
|
|
15
15
|
| { kind: 'profiles'; station: StationName; accountId: string }
|
|
16
|
+
| { kind: 'repair'; station: StationName; accountId: string }
|
|
16
17
|
| { kind: 'name'; station: StationName; accountId: string };
|
|
17
18
|
|
|
18
19
|
const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
@@ -26,10 +27,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
26
27
|
enabled: ['PUT'],
|
|
27
28
|
policy: ['PUT'],
|
|
28
29
|
profiles: ['GET'],
|
|
30
|
+
repair: ['POST'],
|
|
29
31
|
name: ['GET', 'POST'],
|
|
30
32
|
};
|
|
31
33
|
|
|
32
|
-
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles'] as const;
|
|
34
|
+
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles', 'repair'] as const;
|
|
33
35
|
|
|
34
36
|
type SubRoute = (typeof SUB_ROUTES)[number];
|
|
35
37
|
|
|
@@ -37,6 +37,7 @@ interface AttachSessionApi {
|
|
|
37
37
|
owner: AttachOwner,
|
|
38
38
|
station: InteractiveStation,
|
|
39
39
|
input: Record<string, unknown>,
|
|
40
|
+
repair?: string,
|
|
40
41
|
) => Promise<AttachView>;
|
|
41
42
|
view: (owner: AttachOwner, attachId: string) => AttachView;
|
|
42
43
|
submit: (
|
|
@@ -315,16 +316,36 @@ async function handleStep(
|
|
|
315
316
|
sendJson(req, res, 200, view);
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
async function handleRepair(
|
|
320
|
+
req: IncomingMessage,
|
|
321
|
+
res: ServerResponse,
|
|
322
|
+
deps: AccountApiDeps,
|
|
323
|
+
owner: AttachOwner,
|
|
324
|
+
target: { station: StationName; accountId: string },
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
const { station, accountId } = target;
|
|
327
|
+
if (!isInteractiveStation(station) || stationByName(station)?.pairAgain === undefined)
|
|
328
|
+
throw new ApiError(`${station} cannot pair again`, 400);
|
|
329
|
+
const view = await deps.attachSessions.start(owner, station, asInput(await readJsonBody(req)), accountId);
|
|
330
|
+
sendJson(req, res, 201, view);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
type AttachRoute = Extract<AccountRoute, { kind: 'start' | 'session' | 'step' | 'repair' }>;
|
|
334
|
+
|
|
335
|
+
const isAttachRoute = (route: AccountRoute): route is AttachRoute =>
|
|
336
|
+
route.kind === 'start' || route.kind === 'session' || route.kind === 'step' || route.kind === 'repair';
|
|
337
|
+
|
|
318
338
|
async function dispatchAttach(
|
|
319
339
|
req: IncomingMessage,
|
|
320
340
|
res: ServerResponse,
|
|
321
341
|
deps: AccountApiDeps,
|
|
322
342
|
agentId: string,
|
|
323
|
-
route:
|
|
343
|
+
route: AttachRoute,
|
|
324
344
|
session?: ApiSession,
|
|
325
345
|
): Promise<void> {
|
|
326
346
|
const owner: AttachOwner = { agentId, organization: session?.subject, userId: session?.userId, sessionId: session?.sessionId, authorization: req.headers.authorization };
|
|
327
347
|
if (route.kind === 'start') return handleStart(req, res, deps, agentId, owner);
|
|
348
|
+
if (route.kind === 'repair') return handleRepair(req, res, deps, owner, route);
|
|
328
349
|
if (route.kind === 'session') return handleSession(req, res, deps, owner, route.attachId);
|
|
329
350
|
return handleStep(req, res, deps, owner, route.attachId);
|
|
330
351
|
}
|
|
@@ -337,7 +358,7 @@ async function dispatchRoute(
|
|
|
337
358
|
route: AccountRoute,
|
|
338
359
|
session?: ApiSession,
|
|
339
360
|
): Promise<void> {
|
|
340
|
-
if (route
|
|
361
|
+
if (isAttachRoute(route)) return dispatchAttach(req, res, deps, agentId, route, session);
|
|
341
362
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
|
|
342
363
|
if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
|
|
343
364
|
if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type AccountRoute,
|
|
14
14
|
} from './account-routes.js';
|
|
15
15
|
import type { AgentSummary } from './admin.js';
|
|
16
|
+
import { pairAgainStations, statusStations } from '../stations/registry.js';
|
|
16
17
|
|
|
17
18
|
const PREFIX = '/api/agents';
|
|
18
19
|
|
|
@@ -70,6 +71,8 @@ async function handleList(
|
|
|
70
71
|
capabilities: deps.capabilities(),
|
|
71
72
|
features: deps.features ?? [],
|
|
72
73
|
attachable: deps.attachable ?? ATTACHABLE,
|
|
74
|
+
reportsStatus: statusStations(),
|
|
75
|
+
pairAgain: pairAgainStations(),
|
|
73
76
|
...(deps.toolGroups === undefined ? {} : { tools: deps.toolGroups() }),
|
|
74
77
|
};
|
|
75
78
|
if (!wantsAccounts(req)) {
|
|
@@ -210,6 +210,23 @@ export function localRenewGmail(agentId: string, accountId: string, previous: st
|
|
|
210
210
|
return { agentId, station: 'gmail', accountId };
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
const sameIdentity = (a: unknown, b: unknown): boolean =>
|
|
214
|
+
typeof a === 'string' && typeof b === 'string' && a.replace(/[\s()+-]/g, '').toLowerCase() === b.replace(/[\s()+-]/g, '').toLowerCase();
|
|
215
|
+
|
|
216
|
+
const shownIdentity = (value: unknown): string => (typeof value === 'string' && value !== '' ? value : 'unknown');
|
|
217
|
+
|
|
218
|
+
export function localPairAgain(agentId: string, station: StationName, accountId: string, identity: string, config: Record<string, unknown>, dir = agentsDir()): AccountRef {
|
|
219
|
+
const stored = agentOrThrow(agentId, dir);
|
|
220
|
+
const account = stored.file.stations.find((s) => s.station === station && s.id === accountId);
|
|
221
|
+
if (account === undefined) throw new AgentAdminError('This channel is gone. Nothing was changed.', 404);
|
|
222
|
+
const before = account.config[identity];
|
|
223
|
+
const after = config[identity];
|
|
224
|
+
if (!sameIdentity(before, after)) throw new AgentAdminError(`This channel is ${shownIdentity(before)}, not ${shownIdentity(after)}. Nothing was changed.`, 400);
|
|
225
|
+
account.config = { ...account.config, ...config };
|
|
226
|
+
save(stored);
|
|
227
|
+
return { agentId, station, accountId };
|
|
228
|
+
}
|
|
229
|
+
|
|
213
230
|
export function readLocalAgentFile(agentId: string, dir = agentsDir()): AgentFile {
|
|
214
231
|
const found = storedAgents(dir).find((s) => s.file.id === agentId);
|
|
215
232
|
if (found === undefined) throw missing();
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../stations/registry.js';
|
|
7
7
|
import { listEndpoints } from '../stations/webhook-endpoints.js';
|
|
8
8
|
import { hookUrl, threemaCallbackUrl } from '../stations/attach.js';
|
|
9
|
+
import { accountStatusOf } from '@metro-labs/core/stations/account-status';
|
|
9
10
|
import { accountEnabled, agentIdForAccount, allowlistForAccount, approversForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
10
11
|
|
|
11
12
|
const accountId = (acc: unknown): string | undefined => {
|
|
@@ -85,8 +86,33 @@ function withCallbackUrl(acc: unknown): unknown {
|
|
|
85
86
|
return { ...rest, callback: threemaCallbackUrl(callbackId, callbackToken) };
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
function withStatus(reports: boolean, acc: unknown): unknown {
|
|
90
|
+
const rec = asRecord(acc);
|
|
91
|
+
if (rec === undefined || !('status' in rec)) return acc;
|
|
92
|
+
const { status, ...rest } = rec;
|
|
93
|
+
const live = reports ? accountStatusOf(status) : null;
|
|
94
|
+
return live === null ? rest : { ...rest, status: live };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function decorate(station: string, rows: unknown[]): unknown[] {
|
|
98
|
+
const reports = stationByName(station)?.reportsStatus === true;
|
|
99
|
+
const live = rows.map((row) => withStatus(reports, row));
|
|
100
|
+
return station === 'threema' ? live.map(withCallbackUrl) : live;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const STATUS_WAIT_MS = 5_000;
|
|
104
|
+
|
|
105
|
+
function answerIn<T>(call: Promise<T>, ms: number): Promise<T> {
|
|
106
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
107
|
+
const late = new Promise<never>((_resolve, reject) => {
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
reject(new Error(`no answer within ${String(ms)} ms`));
|
|
110
|
+
}, ms);
|
|
111
|
+
});
|
|
112
|
+
return Promise.race([call, late]).finally(() => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
90
116
|
|
|
91
117
|
export interface ScopedAccounts {
|
|
92
118
|
accounts: Record<string, unknown[]>;
|
|
@@ -99,7 +125,8 @@ async function liveAccounts(
|
|
|
99
125
|
if (!hasTrain(station))
|
|
100
126
|
return { rows: inCoreAccounts(station), reachable: true };
|
|
101
127
|
try {
|
|
102
|
-
const
|
|
128
|
+
const call = forwardTrainCall(station, 'accounts', {});
|
|
129
|
+
const resp = await (stationByName(station)?.reportsStatus === true ? answerIn(call, STATUS_WAIT_MS) : call);
|
|
103
130
|
const list = (resp.result as { accounts?: unknown[] } | undefined)?.accounts;
|
|
104
131
|
return { rows: decorate(station, Array.isArray(list) ? list : []), reachable: true };
|
|
105
132
|
} catch {
|
|
@@ -46,10 +46,16 @@ const noticeText = (tool: string, id: string, behavior: Behavior): string =>
|
|
|
46
46
|
|
|
47
47
|
const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
|
|
48
48
|
|
|
49
|
+
const askedBy = (approval: McpApproval): string => {
|
|
50
|
+
if (approval.origin !== 'key') return '';
|
|
51
|
+
return approval.keyLabel === undefined ? ' (from an access key)' : ` (from an access key: ${approval.keyLabel})`;
|
|
52
|
+
};
|
|
53
|
+
|
|
49
54
|
interface Held {
|
|
50
55
|
id: string;
|
|
51
56
|
tool: string;
|
|
52
57
|
line: string | undefined;
|
|
58
|
+
tellAgent: boolean;
|
|
53
59
|
answer: (decision: McpDecision) => Promise<AnswerOutcome>;
|
|
54
60
|
publish: (event: MetroEvent) => void;
|
|
55
61
|
}
|
|
@@ -60,6 +66,10 @@ async function deliver(held: Held, behavior: Behavior): Promise<void> {
|
|
|
60
66
|
log.info({ id: held.id, outcome }, 'mcp-server: the answer was not taken, so the agent is not told');
|
|
61
67
|
return;
|
|
62
68
|
}
|
|
69
|
+
if (!held.tellAgent) {
|
|
70
|
+
log.info({ id: held.id }, 'mcp-server: the call came with an access key, not from this agent\'s session, so the agent is not told');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
63
73
|
const line = noticeLineFor(held.line);
|
|
64
74
|
if (line === undefined) {
|
|
65
75
|
log.warn({ id: held.id }, 'mcp-server: no chat is known to tell the agent the answer');
|
|
@@ -91,15 +101,15 @@ export function holdServerApproval(approval: McpApproval, answer: Held['answer']
|
|
|
91
101
|
const tool = `${approval.connector}.${approval.tool}`;
|
|
92
102
|
const announced = announcedLine(approval.id);
|
|
93
103
|
const line = announced ?? promptLineFor(approval.id);
|
|
94
|
-
const held: Held = { id: approval.id, tool, line, answer, publish };
|
|
104
|
+
const held: Held = { id: approval.id, tool, line, tellAgent: approval.origin !== 'key', answer, publish };
|
|
95
105
|
holdPrompt(
|
|
96
106
|
{ requestId: approval.id, tool, description: approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
|
|
97
107
|
owner,
|
|
98
108
|
sender(held),
|
|
99
109
|
line === undefined ? undefined : async (text) => metroSend(line, text),
|
|
100
110
|
);
|
|
101
|
-
log.info({ id: approval.id, tool, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
|
|
102
|
-
if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool
|
|
111
|
+
log.info({ id: approval.id, tool, origin: approval.origin ?? null, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
|
|
112
|
+
if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool: `${tool}${askedBy(approval)}`, preview: approval.preview }));
|
|
103
113
|
return true;
|
|
104
114
|
}
|
|
105
115
|
|
|
@@ -4,6 +4,7 @@ import { AttachSessions } from '../stations/attach-session.js';
|
|
|
4
4
|
import { startInteractiveAttach } from '../stations/attach-interactive.js';
|
|
5
5
|
import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail-attach.js';
|
|
6
6
|
import { detachWithGmailRevocation } from './gmail-detach.js';
|
|
7
|
+
import { finishPairAgain, pairAgainInput } from './pair-again.js';
|
|
7
8
|
import { recentSenders } from '../agents/senders.js';
|
|
8
9
|
import { forwardTrainCall } from '../stations/train-call.js';
|
|
9
10
|
import type { AgentApiDeps } from '../agents/api.js';
|
|
@@ -44,11 +45,16 @@ function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
|
44
45
|
assertAttachOwner(owner);
|
|
45
46
|
return Promise.resolve();
|
|
46
47
|
},
|
|
47
|
-
start: (station, input, hooks, owner
|
|
48
|
-
|
|
48
|
+
start: (station, input, hooks, owner, repair) => {
|
|
49
|
+
if (repair !== undefined) return startInteractiveAttach(station, pairAgainInput(owner, station, repair, input), hooks);
|
|
50
|
+
return station === 'gmail' ? startGmailAttach(station, input, hooks, owner) : startInteractiveAttach(station, input, hooks);
|
|
51
|
+
},
|
|
52
|
+
complete: async (owner, station, config, active, repair) => {
|
|
49
53
|
assertAttachOwner(owner);
|
|
50
54
|
if (!active()) throw new ApiError('This sign-in was cancelled or expired.', 409);
|
|
51
|
-
const ref =
|
|
55
|
+
const ref = repair !== undefined
|
|
56
|
+
? finishPairAgain(owner, station, repair, config)
|
|
57
|
+
: (station === 'gmail' ? finishGmailUpgrade(owner, config) : null) ?? await localAttachAccount(owner.agentId, station, config);
|
|
52
58
|
const activated = await deps.syncStations(station).then(
|
|
53
59
|
() => true,
|
|
54
60
|
(err: unknown) => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
|
+
import type { StationName } from '@metro-labs/core/station-names';
|
|
3
|
+
import { localPairAgain, readLocalAgentFile } from '../agents/file-admin.js';
|
|
4
|
+
import type { AccountRef } from '../agents/account-attach.js';
|
|
5
|
+
import { stationByName } from '../stations/registry.js';
|
|
6
|
+
import type { AttachOwner } from '../stations/attach-session.js';
|
|
7
|
+
|
|
8
|
+
function identityOf(station: string): string {
|
|
9
|
+
const identity = stationByName(station)?.pairAgain?.identity;
|
|
10
|
+
if (identity === undefined) throw new ApiError(`${station} cannot pair again`, 400);
|
|
11
|
+
return identity;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function pairAgainInput(owner: AttachOwner, station: string, accountId: string, input: Record<string, unknown>): Record<string, unknown> {
|
|
15
|
+
const identity = identityOf(station);
|
|
16
|
+
const account = readLocalAgentFile(owner.agentId).stations.find((s) => s.station === station && s.id === accountId);
|
|
17
|
+
if (account === undefined) throw new ApiError('No such channel on this agent.', 404);
|
|
18
|
+
return { ...input, expect: account.config[identity] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function finishPairAgain(owner: AttachOwner, station: StationName, accountId: string, config: Record<string, unknown>): AccountRef {
|
|
22
|
+
return localPairAgain(owner.agentId, station, accountId, identityOf(station), config);
|
|
23
|
+
}
|
|
@@ -19,6 +19,7 @@ export type StartAttach = (
|
|
|
19
19
|
input: Record<string, unknown>,
|
|
20
20
|
hooks: DriverHooks,
|
|
21
21
|
owner: AttachOwner,
|
|
22
|
+
repair?: string,
|
|
22
23
|
) => Promise<StartedAttach>;
|
|
23
24
|
|
|
24
25
|
export const ATTACH_ID_RE = /^as_[A-Za-z0-9_-]{22}$/;
|
|
@@ -60,6 +61,7 @@ type CompleteAttach = (
|
|
|
60
61
|
station: StationName,
|
|
61
62
|
config: Record<string, unknown>,
|
|
62
63
|
active: () => boolean,
|
|
64
|
+
repair?: string,
|
|
63
65
|
) => Promise<{ accountId: string; activated: boolean }>;
|
|
64
66
|
|
|
65
67
|
type AuthorizeAttach = (owner: AttachOwner) => Promise<void>;
|
|
@@ -72,6 +74,7 @@ export interface AttachSessionDeps {
|
|
|
72
74
|
|
|
73
75
|
interface Session {
|
|
74
76
|
owner: AttachOwner;
|
|
77
|
+
repair?: string;
|
|
75
78
|
driver: AttachDriver | null;
|
|
76
79
|
view: AttachView;
|
|
77
80
|
finishing?: boolean;
|
|
@@ -177,6 +180,7 @@ export class AttachSessions {
|
|
|
177
180
|
session.committing = true;
|
|
178
181
|
return true;
|
|
179
182
|
},
|
|
183
|
+
session.repair,
|
|
180
184
|
);
|
|
181
185
|
this.settle(session, {
|
|
182
186
|
status: 'done',
|
|
@@ -187,12 +191,12 @@ export class AttachSessions {
|
|
|
187
191
|
});
|
|
188
192
|
log.info(
|
|
189
193
|
{ agentId: session.owner.agentId, station, account: landed.accountId },
|
|
190
|
-
'attach-session: signed in and attached a station account',
|
|
194
|
+
session.repair === undefined ? 'attach-session: signed in and attached a station account' : 'attach-session: paired a station account again',
|
|
191
195
|
);
|
|
192
196
|
} catch (err) {
|
|
193
197
|
const reason =
|
|
194
198
|
err instanceof ApiError ? err.message : 'could not store the account';
|
|
195
|
-
log.warn({ station, err: errMsg(err) }, 'attach-session: store failed');
|
|
199
|
+
log.warn({ station, ...(err instanceof ApiError ? { status: err.status } : { err: errMsg(err).replace(/\d{6,}/g, '<number>') }) }, 'attach-session: store failed');
|
|
196
200
|
this.settle(session, { status: 'failed', prompt: '', error: reason });
|
|
197
201
|
}
|
|
198
202
|
}
|
|
@@ -230,6 +234,7 @@ export class AttachSessions {
|
|
|
230
234
|
owner: AttachOwner,
|
|
231
235
|
station: InteractiveStation,
|
|
232
236
|
input: Record<string, unknown>,
|
|
237
|
+
repair?: string,
|
|
233
238
|
): Promise<AttachView> {
|
|
234
239
|
await this.deps.authorize(owner);
|
|
235
240
|
this.admit(owner);
|
|
@@ -237,6 +242,7 @@ export class AttachSessions {
|
|
|
237
242
|
const attachId = newAttachId();
|
|
238
243
|
const session: Session = {
|
|
239
244
|
owner,
|
|
245
|
+
...(repair === undefined ? {} : { repair }),
|
|
240
246
|
driver: null,
|
|
241
247
|
view: blankView(attachId, station),
|
|
242
248
|
};
|
|
@@ -248,6 +254,7 @@ export class AttachSessions {
|
|
|
248
254
|
input,
|
|
249
255
|
this.hooksFor(attachId, station),
|
|
250
256
|
owner,
|
|
257
|
+
repair,
|
|
251
258
|
);
|
|
252
259
|
if (this.sessions.get(attachId) !== session || session.view.expiresAt <= Date.now()) {
|
|
253
260
|
await started.driver.cancel();
|
|
@@ -41,6 +41,12 @@ export const accountStationCapabilities = (): Record<string, string[]> => {
|
|
|
41
41
|
return out;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
export const statusStations = (): string[] =>
|
|
45
|
+
STATIONS.filter((s) => s.reportsStatus === true).map((s) => s.name);
|
|
46
|
+
|
|
47
|
+
export const pairAgainStations = (): string[] =>
|
|
48
|
+
STATIONS.filter((s) => s.pairAgain !== undefined).map((s) => s.name);
|
|
49
|
+
|
|
44
50
|
export function forgetOrphans(known: readonly { station: string; id: string }[]): void {
|
|
45
51
|
for (const s of STATIONS) {
|
|
46
52
|
if (s.forgetExcept === undefined) continue;
|
|
@@ -11,6 +11,8 @@ const CLIENT_NAME = 'Metro';
|
|
|
11
11
|
|
|
12
12
|
export class OAuthInvalidGrant extends ConnectorVerifyError {}
|
|
13
13
|
|
|
14
|
+
const SIGN_IN_AGAIN = new Set(['invalid_grant', 'invalid_client', 'unauthorized_client']);
|
|
15
|
+
|
|
14
16
|
export interface OAuthClient {
|
|
15
17
|
clientId: string;
|
|
16
18
|
clientSecret?: string;
|
|
@@ -143,10 +145,17 @@ async function postToken(
|
|
|
143
145
|
} catch (err) {
|
|
144
146
|
throw refused(`Metro could not reach that server to finish signing in: ${whyUnreachable(err)}`);
|
|
145
147
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
return tokenAnswer(form.grant_type === 'refresh_token', res.status, res.ok, await res.json().catch(() => null));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const errorCode = (body: unknown): string | null => (isRecord(body) && typeof body.error === 'string' && /^[a-z_]{1,64}$/.test(body.error) ? body.error : null);
|
|
152
|
+
|
|
153
|
+
const grantGone = (refreshing: boolean, status: number, code: string | null): boolean => refreshing && (status === 400 || status === 401) && code !== null && SIGN_IN_AGAIN.has(code);
|
|
154
|
+
|
|
155
|
+
function tokenAnswer(refreshing: boolean, status: number, ok: boolean, body: unknown): OAuthTokens {
|
|
156
|
+
const code = errorCode(body);
|
|
157
|
+
if (grantGone(refreshing, status, code)) throw new OAuthInvalidGrant('That server requires a new sign-in.', 400);
|
|
158
|
+
if (!ok || !isRecord(body)) throw refused(`That server refused to complete the sign-in (${String(status)}${code === null ? '' : ` ${code}`}).`);
|
|
150
159
|
return tokensFrom(body);
|
|
151
160
|
}
|
|
152
161
|
|
|
@@ -27,12 +27,18 @@ export interface McpCall {
|
|
|
27
27
|
approval?: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
type McpApprovalOrigin = 'box' | 'key';
|
|
31
|
+
|
|
32
|
+
const KEY_LABEL_MAX = 64;
|
|
33
|
+
|
|
30
34
|
export interface McpApproval {
|
|
31
35
|
id: string;
|
|
32
36
|
connector: string;
|
|
33
37
|
tool: string;
|
|
34
38
|
preview: string;
|
|
35
39
|
requestedAt: number;
|
|
40
|
+
origin?: McpApprovalOrigin;
|
|
41
|
+
keyLabel?: string;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export type McpDecision = 'allow' | 'deny';
|
|
@@ -70,9 +76,16 @@ export function mcpTokenHashOf(raw: unknown): string {
|
|
|
70
76
|
return raw.tokenHash;
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
function originOf(raw: Record<string, unknown>): Pick<McpApproval, 'origin' | 'keyLabel'> {
|
|
80
|
+
if (raw.origin === 'box') return { origin: 'box' };
|
|
81
|
+
if (raw.origin !== 'key') return {};
|
|
82
|
+
const label = typeof raw.keyLabel === 'string' ? raw.keyLabel.replace(/\p{Cc}/gu, '').trim() : '';
|
|
83
|
+
return label === '' || label.length > KEY_LABEL_MAX ? { origin: 'key' } : { origin: 'key', keyLabel: label };
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
function approvalOf(raw: unknown): McpApproval | null {
|
|
74
87
|
if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || typeof raw.connector !== 'string' || typeof raw.tool !== 'string' || typeof raw.preview !== 'string' || typeof raw.requestedAt !== 'number') return null;
|
|
75
|
-
return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt };
|
|
88
|
+
return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt, ...originOf(raw) };
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
export function mcpApprovalsOf(raw: unknown): McpApproval[] {
|
|
@@ -61,19 +61,30 @@ export const toolSearchDescription = (keys: string[] | null): string =>
|
|
|
61
61
|
'Results are full definitions (name, description and the JSON schema of the arguments) in a <functions> block. Each name is <connector>.<tool>: pass it exactly as "tool" to tool_call. Tools the owner blocked are never returned.',
|
|
62
62
|
].join('\n');
|
|
63
63
|
|
|
64
|
-
export
|
|
65
|
-
'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
|
|
66
|
-
`Shape: ${TOOL_CALL_EXAMPLE}`,
|
|
67
|
-
`Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
|
|
68
|
-
'- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
|
|
69
|
-
'- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
|
|
70
|
-
'- denied or blocked: the owner refused or blocked this call. Do not retry it.',
|
|
71
|
-
'- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
|
|
72
|
-
'- unavailable: the connector or Metro could not be reached. Try again later.',
|
|
73
|
-
'- vendor_error: the connector answered with its own error, shown as it came.',
|
|
74
|
-
].join('\n');
|
|
64
|
+
export type ApprovalPlace = 'session' | 'page' | 'owner';
|
|
75
65
|
|
|
76
|
-
|
|
66
|
+
const NEEDS_APPROVAL: Record<ApprovalPlace, string> = {
|
|
67
|
+
session: '- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
|
|
68
|
+
page: '- needs_approval: the owner must approve it on this agent\'s page on metro.box. Tell the person what is waiting and ask them to approve it there, then call again with the same tool and arguments plus "approval": "<id>". An approval works once, within 10 minutes of the answer.',
|
|
69
|
+
owner: '- needs_approval: the agent\'s owner was asked in chat and on its page on metro.box. Tell the person what is waiting; once it is approved, call again with the same tool and arguments plus "approval": "<id>". An approval works once, within 10 minutes of the answer.',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const toolCallDescription = (place: ApprovalPlace): string =>
|
|
73
|
+
[
|
|
74
|
+
'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
|
|
75
|
+
`Shape: ${TOOL_CALL_EXAMPLE}`,
|
|
76
|
+
`Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
|
|
77
|
+
'- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
|
|
78
|
+
NEEDS_APPROVAL[place],
|
|
79
|
+
'- denied or blocked: the owner refused or blocked this call. Do not retry it.',
|
|
80
|
+
'- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
|
|
81
|
+
'- unavailable: the connector or Metro could not be reached. Try again later.',
|
|
82
|
+
'- vendor_error: the connector answered with its own error, shown as it came.',
|
|
83
|
+
].join('\n');
|
|
84
|
+
|
|
85
|
+
export const TOOL_CALL_DESCRIPTION = toolCallDescription('session');
|
|
86
|
+
|
|
87
|
+
export function mcpServerTools(keys: string[] | null, place: ApprovalPlace = 'session'): McpServerTool[] {
|
|
77
88
|
return [
|
|
78
89
|
{
|
|
79
90
|
name: TOOL_SEARCH,
|
|
@@ -85,7 +96,7 @@ export function mcpServerTools(keys: string[] | null): McpServerTool[] {
|
|
|
85
96
|
{
|
|
86
97
|
name: TOOL_CALL,
|
|
87
98
|
title: 'Call connector tools',
|
|
88
|
-
description:
|
|
99
|
+
description: toolCallDescription(place),
|
|
89
100
|
inputSchema: callSchema,
|
|
90
101
|
annotations: { title: 'Call connector tools', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
91
102
|
},
|
|
@@ -196,12 +196,13 @@ function makeDelete(clientFor: ClientFor): StationHandler {
|
|
|
196
196
|
function makeAccounts(clientFor: ClientFor): StationHandler {
|
|
197
197
|
return (id) => {
|
|
198
198
|
const list = [...accounts.values()].map((a) => {
|
|
199
|
-
const
|
|
199
|
+
const client = clientFor(a.id);
|
|
200
|
+
const phone = client.self();
|
|
200
201
|
return {
|
|
201
202
|
id: a.id,
|
|
202
203
|
handle: phone === null ? null : `+${phone}`,
|
|
203
204
|
url: phone === null ? null : `https://wa.me/${phone}`,
|
|
204
|
-
|
|
205
|
+
status: client.status(),
|
|
205
206
|
};
|
|
206
207
|
});
|
|
207
208
|
respond(id, { result: { accounts: list } });
|
|
@@ -4,6 +4,7 @@ import type { MemberList } from '@metro-labs/core/stations/types';
|
|
|
4
4
|
import type { ChannelList } from '@metro-labs/core/stations/channel-directory';
|
|
5
5
|
import type { ProfileChange } from '@metro-labs/core/stations/profile';
|
|
6
6
|
import type { SenderProfile } from '@metro-labs/core/stations/sender-profile';
|
|
7
|
+
import type { AccountStatus } from '@metro-labs/core/stations/account-status';
|
|
7
8
|
import type { WhatsAppAccount } from './types.js';
|
|
8
9
|
import type { InboundMessage, ReactionInput } from './format.js';
|
|
9
10
|
import type { SenderFound } from './resolve.js';
|
|
@@ -25,6 +26,7 @@ export interface WAMedia {
|
|
|
25
26
|
export interface WAClient {
|
|
26
27
|
account: WhatsAppAccount;
|
|
27
28
|
self(): string | null;
|
|
29
|
+
status(): AccountStatus;
|
|
28
30
|
start(handlers: InboundHandlers): Promise<void>;
|
|
29
31
|
read(jid: string, options?: HistoryOptions): Promise<HistoryPage>;
|
|
30
32
|
listMembers(jid: string, limit?: number): Promise<MemberList>;
|