@botcord/daemon 0.2.78 → 0.2.79
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/dist/attention-policy-fetcher.d.ts +14 -0
- package/dist/attention-policy-fetcher.js +59 -0
- package/dist/cloud-daemon.js +8 -0
- package/dist/cloud-gateway-runtime.d.ts +29 -0
- package/dist/cloud-gateway-runtime.js +122 -0
- package/dist/daemon.js +21 -6
- package/dist/gateway/channels/botcord.js +29 -9
- package/dist/gateway/channels/login-session.d.ts +12 -0
- package/dist/gateway/channels/login-session.js +20 -2
- package/dist/gateway/channels/sanitize.d.ts +5 -18
- package/dist/gateway/channels/sanitize.js +5 -54
- package/dist/gateway/channels/text-split.d.ts +5 -11
- package/dist/gateway/channels/text-split.js +5 -31
- package/dist/gateway/dispatcher.d.ts +7 -1
- package/dist/gateway/dispatcher.js +88 -8
- package/dist/gateway/gateway.d.ts +16 -1
- package/dist/gateway/gateway.js +21 -0
- package/dist/gateway/policy-resolver.js +17 -9
- package/dist/gateway/runtimes/deepseek-tui.js +31 -12
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- package/dist/provision.d.ts +7 -3
- package/dist/provision.js +115 -8
- package/dist/room-recovery-context.d.ts +11 -0
- package/dist/room-recovery-context.js +97 -0
- package/package.json +2 -2
- package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
- package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
- package/src/__tests__/gateway-control.test.ts +136 -0
- package/src/__tests__/policy-resolver.test.ts +20 -0
- package/src/__tests__/provision.test.ts +65 -0
- package/src/attention-policy-fetcher.ts +87 -0
- package/src/cloud-daemon.ts +8 -0
- package/src/cloud-gateway-runtime.ts +171 -0
- package/src/daemon.ts +23 -6
- package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +177 -0
- package/src/gateway/__tests__/dispatcher.test.ts +56 -0
- package/src/gateway/channels/botcord.ts +32 -8
- package/src/gateway/channels/login-session.ts +20 -2
- package/src/gateway/channels/sanitize.ts +8 -66
- package/src/gateway/channels/text-split.ts +5 -27
- package/src/gateway/dispatcher.ts +123 -27
- package/src/gateway/gateway.ts +29 -0
- package/src/gateway/policy-resolver.ts +20 -9
- package/src/gateway/runtimes/deepseek-tui.ts +37 -12
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/provision.ts +133 -7
- package/src/room-recovery-context.ts +131 -0
package/dist/provision.js
CHANGED
|
@@ -20,6 +20,7 @@ import { discoverAgentCredentials } from "./agent-discovery.js";
|
|
|
20
20
|
import { resolveMemoryDir } from "./working-memory.js";
|
|
21
21
|
import { discoverRuntimeModelCatalog } from "./runtime-models.js";
|
|
22
22
|
import { buildRuntimeSelectionExtraArgs, mergeRuntimeExtraArgs, } from "./runtime-route-options.js";
|
|
23
|
+
import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
|
|
23
24
|
/**
|
|
24
25
|
* Build a dispatcher function that routes a `ControlFrame` to the right
|
|
25
26
|
* handler. Returned function signature matches
|
|
@@ -41,7 +42,7 @@ export function createProvisioner(opts) {
|
|
|
41
42
|
return { ok: true, result: { pong: true, ts: Date.now() } };
|
|
42
43
|
case CONTROL_FRAME_TYPES.HELLO: {
|
|
43
44
|
const params = (frame.params ?? {});
|
|
44
|
-
const result = applyHelloIdentitySnapshot(params.agents);
|
|
45
|
+
const result = applyHelloIdentitySnapshot(params.agents, { gateway });
|
|
45
46
|
daemonLog.debug("hello: identity snapshot applied", {
|
|
46
47
|
frameId: frame.id,
|
|
47
48
|
received: params.agents?.length ?? 0,
|
|
@@ -62,12 +63,19 @@ export function createProvisioner(opts) {
|
|
|
62
63
|
displayName: params.displayName,
|
|
63
64
|
bio: params.bio,
|
|
64
65
|
});
|
|
66
|
+
const runtimeResult = applyAgentRuntimeSnapshot(params, { gateway });
|
|
67
|
+
const combined = {
|
|
68
|
+
changed: result.changed || runtimeResult.changed,
|
|
69
|
+
identity: result,
|
|
70
|
+
runtime: runtimeResult,
|
|
71
|
+
};
|
|
65
72
|
daemonLog.info("update_agent applied", {
|
|
66
73
|
agentId: params.agentId,
|
|
67
|
-
changed:
|
|
68
|
-
|
|
74
|
+
changed: combined.changed,
|
|
75
|
+
identitySkipped: result.skipped ?? null,
|
|
76
|
+
runtimeSkipped: runtimeResult.skipped ?? null,
|
|
69
77
|
});
|
|
70
|
-
return { ok: true, result };
|
|
78
|
+
return { ok: true, result: combined };
|
|
71
79
|
}
|
|
72
80
|
case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
|
|
73
81
|
const params = (frame.params ?? {});
|
|
@@ -266,6 +274,30 @@ export function createProvisioner(opts) {
|
|
|
266
274
|
return v.ack;
|
|
267
275
|
return gatewayControl.handleSend(v.params);
|
|
268
276
|
}
|
|
277
|
+
case "cloud_gateway_runtime_inbound": {
|
|
278
|
+
const params = (frame.params ?? {});
|
|
279
|
+
const runtimeFrame = params.frame;
|
|
280
|
+
if (!runtimeFrame || typeof runtimeFrame !== "object") {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
error: {
|
|
284
|
+
code: "bad_params",
|
|
285
|
+
message: "cloud_gateway_runtime_inbound requires params.frame",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame);
|
|
290
|
+
return result.accepted
|
|
291
|
+
? { ok: true, result }
|
|
292
|
+
: {
|
|
293
|
+
ok: false,
|
|
294
|
+
result,
|
|
295
|
+
error: result.error ?? {
|
|
296
|
+
code: "runtime_inbound_rejected",
|
|
297
|
+
message: "cloud gateway runtime inbound was rejected",
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
269
301
|
case "list_agent_files": {
|
|
270
302
|
const params = (frame.params ?? {});
|
|
271
303
|
if (!params.agentId) {
|
|
@@ -1998,10 +2030,84 @@ function openclawBindingIndex() {
|
|
|
1998
2030
|
}
|
|
1999
2031
|
return out;
|
|
2000
2032
|
}
|
|
2033
|
+
function hasOwnField(obj, key) {
|
|
2034
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
2035
|
+
}
|
|
2036
|
+
function cleanNullableString(value) {
|
|
2037
|
+
if (typeof value !== "string")
|
|
2038
|
+
return undefined;
|
|
2039
|
+
const trimmed = value.trim();
|
|
2040
|
+
return trimmed || undefined;
|
|
2041
|
+
}
|
|
2042
|
+
function applyAgentRuntimeSnapshot(snapshot, ctx = {}) {
|
|
2043
|
+
const hasRuntimeFields = (hasOwnField(snapshot, "runtime") ||
|
|
2044
|
+
hasOwnField(snapshot, "runtimeModel") ||
|
|
2045
|
+
hasOwnField(snapshot, "reasoningEffort") ||
|
|
2046
|
+
hasOwnField(snapshot, "thinking"));
|
|
2047
|
+
if (!hasRuntimeFields)
|
|
2048
|
+
return { changed: false, skipped: "no_runtime_fields" };
|
|
2049
|
+
const credentialsFile = defaultCredentialsFile(snapshot.agentId);
|
|
2050
|
+
if (!existsSync(credentialsFile)) {
|
|
2051
|
+
return { changed: false, skipped: "credentials_missing" };
|
|
2052
|
+
}
|
|
2053
|
+
const credentials = loadStoredCredentials(credentialsFile);
|
|
2054
|
+
let changed = false;
|
|
2055
|
+
if (hasOwnField(snapshot, "runtime")) {
|
|
2056
|
+
const runtime = cleanNullableString(snapshot.runtime);
|
|
2057
|
+
if (runtime !== credentials.runtime) {
|
|
2058
|
+
if (runtime)
|
|
2059
|
+
credentials.runtime = runtime;
|
|
2060
|
+
else
|
|
2061
|
+
delete credentials.runtime;
|
|
2062
|
+
changed = true;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
if (hasOwnField(snapshot, "runtimeModel")) {
|
|
2066
|
+
const runtimeModel = cleanNullableString(snapshot.runtimeModel);
|
|
2067
|
+
if (runtimeModel !== credentials.runtimeModel) {
|
|
2068
|
+
if (runtimeModel)
|
|
2069
|
+
credentials.runtimeModel = runtimeModel;
|
|
2070
|
+
else
|
|
2071
|
+
delete credentials.runtimeModel;
|
|
2072
|
+
changed = true;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
if (hasOwnField(snapshot, "reasoningEffort")) {
|
|
2076
|
+
const reasoningEffort = cleanNullableString(snapshot.reasoningEffort);
|
|
2077
|
+
if (reasoningEffort !== credentials.reasoningEffort) {
|
|
2078
|
+
if (reasoningEffort)
|
|
2079
|
+
credentials.reasoningEffort = reasoningEffort;
|
|
2080
|
+
else
|
|
2081
|
+
delete credentials.reasoningEffort;
|
|
2082
|
+
changed = true;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
if (hasOwnField(snapshot, "thinking")) {
|
|
2086
|
+
if (typeof snapshot.thinking === "boolean") {
|
|
2087
|
+
if (credentials.thinking !== snapshot.thinking) {
|
|
2088
|
+
credentials.thinking = snapshot.thinking;
|
|
2089
|
+
changed = true;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
else if (typeof credentials.thinking === "boolean") {
|
|
2093
|
+
delete credentials.thinking;
|
|
2094
|
+
changed = true;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
if (!changed)
|
|
2098
|
+
return { changed: false };
|
|
2099
|
+
writeCredentialsFile(credentialsFile, credentials);
|
|
2100
|
+
if (ctx.gateway) {
|
|
2101
|
+
upsertManagedRouteForCredentials(credentials, loadConfig(), ctx.gateway);
|
|
2102
|
+
return { changed: true, routeUpdated: true };
|
|
2103
|
+
}
|
|
2104
|
+
return { changed: true };
|
|
2105
|
+
}
|
|
2001
2106
|
/**
|
|
2002
2107
|
* Reconcile every agent identity carried by the `hello.agents` snapshot
|
|
2003
|
-
* against the on-disk `identity.md
|
|
2004
|
-
* file-system error for one agent never
|
|
2108
|
+
* against the on-disk `identity.md` and credentials runtime selectors.
|
|
2109
|
+
* Best-effort: a malformed entry or a file-system error for one agent never
|
|
2110
|
+
* aborts the rest.
|
|
2005
2111
|
*
|
|
2006
2112
|
* Identity-snapshot semantics intentionally only touch the metadata
|
|
2007
2113
|
* line + Bio body — Role/Boundaries paragraphs the user authored locally
|
|
@@ -2009,7 +2115,7 @@ function openclawBindingIndex() {
|
|
|
2009
2115
|
* (agent provisioned on a different daemon, or workspace cleared) are
|
|
2010
2116
|
* silently skipped.
|
|
2011
2117
|
*/
|
|
2012
|
-
export function applyHelloIdentitySnapshot(snapshot) {
|
|
2118
|
+
export function applyHelloIdentitySnapshot(snapshot, ctx = {}) {
|
|
2013
2119
|
const out = { updated: 0, skipped: 0 };
|
|
2014
2120
|
if (!Array.isArray(snapshot))
|
|
2015
2121
|
return out;
|
|
@@ -2023,7 +2129,8 @@ export function applyHelloIdentitySnapshot(snapshot) {
|
|
|
2023
2129
|
displayName: entry.displayName,
|
|
2024
2130
|
bio: entry.bio,
|
|
2025
2131
|
});
|
|
2026
|
-
|
|
2132
|
+
const runtimeResult = applyAgentRuntimeSnapshot(entry, ctx);
|
|
2133
|
+
if (result.changed || runtimeResult.changed)
|
|
2027
2134
|
out.updated += 1;
|
|
2028
2135
|
else
|
|
2029
2136
|
out.skipped += 1;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
2
|
+
export interface RecentRoomMessagesRecoveryOptions {
|
|
3
|
+
credentialPathByAgentId: Map<string, string>;
|
|
4
|
+
defaultCredentialsPath?: string;
|
|
5
|
+
hubBaseUrl?: string;
|
|
6
|
+
limit?: number;
|
|
7
|
+
log?: {
|
|
8
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare function createRecentRoomMessagesRecoveryBuilder(opts: RecentRoomMessagesRecoveryOptions): (message: GatewayInboundMessage) => Promise<string | null>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a compact, deterministic recovery block from recent Hub room messages.
|
|
3
|
+
* Used when a runtime-native session is discarded and the same turn is retried
|
|
4
|
+
* in a fresh session.
|
|
5
|
+
*/
|
|
6
|
+
import { BotCordClient, loadStoredCredentials } from "@botcord/protocol-core";
|
|
7
|
+
import { sanitizeUntrustedContent } from "./gateway/index.js";
|
|
8
|
+
const DEFAULT_RECENT_LIMIT = 20;
|
|
9
|
+
const MAX_MESSAGE_TEXT_CHARS = 1200;
|
|
10
|
+
function stripNewlines(s) {
|
|
11
|
+
return s.replace(/[\r\n]+/g, " ");
|
|
12
|
+
}
|
|
13
|
+
function messageLabel(m) {
|
|
14
|
+
const name = typeof m.from_name === "string" && m.from_name.trim()
|
|
15
|
+
? m.from_name
|
|
16
|
+
: typeof m.from === "string" && m.from.trim()
|
|
17
|
+
? m.from
|
|
18
|
+
: "unknown";
|
|
19
|
+
return sanitizeUntrustedContent(stripNewlines(name));
|
|
20
|
+
}
|
|
21
|
+
function formatRecentMessages(messages) {
|
|
22
|
+
if (messages.length === 0)
|
|
23
|
+
return "[Recent Room Messages]\n(none)";
|
|
24
|
+
const chronological = [...messages].reverse();
|
|
25
|
+
const lines = ["[Recent Room Messages]"];
|
|
26
|
+
for (const m of chronological) {
|
|
27
|
+
const text = typeof m.text === "string" ? m.text.trim() : "";
|
|
28
|
+
if (!text)
|
|
29
|
+
continue;
|
|
30
|
+
const ts = typeof m.ts === "string" ? m.ts : "";
|
|
31
|
+
const topic = typeof m.topic_title === "string" && m.topic_title.trim()
|
|
32
|
+
? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_title))}`
|
|
33
|
+
: typeof m.topic_id === "string" && m.topic_id
|
|
34
|
+
? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_id))}`
|
|
35
|
+
: "";
|
|
36
|
+
const safeText = sanitizeUntrustedContent(text.length > MAX_MESSAGE_TEXT_CHARS
|
|
37
|
+
? `${text.slice(0, MAX_MESSAGE_TEXT_CHARS)}...`
|
|
38
|
+
: text);
|
|
39
|
+
lines.push(`- ${ts ? `${ts} ` : ""}${messageLabel(m)}${topic}: ${safeText}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
export function createRecentRoomMessagesRecoveryBuilder(opts) {
|
|
44
|
+
const clients = new Map();
|
|
45
|
+
const limit = opts.limit ?? DEFAULT_RECENT_LIMIT;
|
|
46
|
+
function getClient(accountId) {
|
|
47
|
+
const existing = clients.get(accountId);
|
|
48
|
+
if (existing)
|
|
49
|
+
return existing.client;
|
|
50
|
+
const credsPath = opts.credentialPathByAgentId.get(accountId) ?? opts.defaultCredentialsPath;
|
|
51
|
+
if (!credsPath) {
|
|
52
|
+
opts.log?.warn("daemon.recovery-context.no-credentials", { accountId });
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const creds = loadStoredCredentials(credsPath);
|
|
57
|
+
const client = new BotCordClient({
|
|
58
|
+
hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
|
|
59
|
+
agentId: creds.agentId,
|
|
60
|
+
keyId: creds.keyId,
|
|
61
|
+
privateKey: creds.privateKey,
|
|
62
|
+
...(creds.token ? { token: creds.token } : {}),
|
|
63
|
+
...(creds.tokenExpiresAt !== undefined
|
|
64
|
+
? { tokenExpiresAt: creds.tokenExpiresAt }
|
|
65
|
+
: {}),
|
|
66
|
+
});
|
|
67
|
+
clients.set(accountId, { client, credentialsPath: credsPath });
|
|
68
|
+
return client;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
opts.log?.warn("daemon.recovery-context.client-init-failed", {
|
|
72
|
+
accountId,
|
|
73
|
+
credsPath,
|
|
74
|
+
error: err instanceof Error ? err.message : String(err),
|
|
75
|
+
});
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return async (message) => {
|
|
80
|
+
const client = getClient(message.accountId);
|
|
81
|
+
if (!client)
|
|
82
|
+
return null;
|
|
83
|
+
try {
|
|
84
|
+
const body = await client.roomMessages(message.conversation.id, { limit });
|
|
85
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
86
|
+
return formatRecentMessages(messages);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
opts.log?.warn("daemon.recovery-context.fetch-failed", {
|
|
90
|
+
accountId: message.accountId,
|
|
91
|
+
roomId: message.conversation.id,
|
|
92
|
+
error: err instanceof Error ? err.message : String(err),
|
|
93
|
+
});
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.79",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@botcord/cli": "^0.1.7",
|
|
31
|
-
"@botcord/protocol-core": "^0.2.
|
|
31
|
+
"@botcord/protocol-core": "^0.2.10",
|
|
32
32
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
33
33
|
"ws": "^8.20.1"
|
|
34
34
|
},
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { generateKeypair } from "@botcord/protocol-core";
|
|
6
|
+
import { createAttentionPolicyFetcher } from "../attention-policy-fetcher.js";
|
|
7
|
+
|
|
8
|
+
function writeCredentials(agentId: string): string {
|
|
9
|
+
const dir = mkdtempSync(path.join(tmpdir(), "botcord-policy-"));
|
|
10
|
+
const file = path.join(dir, `${agentId}.json`);
|
|
11
|
+
const keys = generateKeypair();
|
|
12
|
+
writeFileSync(
|
|
13
|
+
file,
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
version: 1,
|
|
16
|
+
hubUrl: "https://hub.test",
|
|
17
|
+
agentId,
|
|
18
|
+
keyId: "key_1",
|
|
19
|
+
privateKey: keys.privateKey,
|
|
20
|
+
publicKey: keys.publicKey,
|
|
21
|
+
savedAt: new Date().toISOString(),
|
|
22
|
+
token: "jwt",
|
|
23
|
+
tokenExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("createAttentionPolicyFetcher", () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("fetches effective room attention policy with agent credentials", async () => {
|
|
35
|
+
const credentialsPath = writeCredentials("ag_policy");
|
|
36
|
+
const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
37
|
+
expect(String(url)).toBe(
|
|
38
|
+
"https://hub.test/hub/attention-policy?room_id=rm_1",
|
|
39
|
+
);
|
|
40
|
+
expect((init?.headers as Record<string, string>).Authorization).toBe(
|
|
41
|
+
"Bearer jwt",
|
|
42
|
+
);
|
|
43
|
+
return new Response(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
mode: "mention_only",
|
|
46
|
+
keywords: [],
|
|
47
|
+
allowedSenderIds: [],
|
|
48
|
+
}),
|
|
49
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
53
|
+
|
|
54
|
+
const fetchPolicy = createAttentionPolicyFetcher({
|
|
55
|
+
credentialPathByAgentId: new Map([["ag_policy", credentialsPath]]),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
fetchPolicy({ agentId: "ag_policy", roomId: "rm_1" }),
|
|
60
|
+
).resolves.toEqual({
|
|
61
|
+
mode: "mention_only",
|
|
62
|
+
keywords: [],
|
|
63
|
+
allowedSenderIds: [],
|
|
64
|
+
});
|
|
65
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { RUNTIME_FRAME_TYPES, type GatewayInboundFrame } from "@botcord/protocol-core";
|
|
6
|
+
|
|
7
|
+
import { handleCloudGatewayRuntimeInbound } from "../cloud-gateway-runtime.js";
|
|
8
|
+
import { Gateway, type ChannelAdapter } from "../gateway/index.js";
|
|
9
|
+
|
|
10
|
+
describe("cloud gateway runtime inbound", () => {
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "cloud-gateway-runtime-"));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("injects a gateway_inbound frame and captures the runtime reply", async () => {
|
|
22
|
+
const gateway = new Gateway({
|
|
23
|
+
config: {
|
|
24
|
+
channels: [],
|
|
25
|
+
defaultRoute: { runtime: "fake", cwd: tmpDir },
|
|
26
|
+
},
|
|
27
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
28
|
+
createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
|
|
29
|
+
createRuntime: () => ({
|
|
30
|
+
id: "fake",
|
|
31
|
+
async run() {
|
|
32
|
+
return { text: "hello from runtime", newSessionId: "sess_1" };
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
transcriptEnabled: false,
|
|
36
|
+
});
|
|
37
|
+
await gateway.start();
|
|
38
|
+
|
|
39
|
+
const frame: GatewayInboundFrame = {
|
|
40
|
+
type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
|
|
41
|
+
event_id: "evt_1",
|
|
42
|
+
gateway_id: "gw_tg_1",
|
|
43
|
+
agent_id: "ag_1",
|
|
44
|
+
provider: "telegram",
|
|
45
|
+
message: {
|
|
46
|
+
id: "telegram:1:2",
|
|
47
|
+
channel: "gw_tg_1",
|
|
48
|
+
accountId: "ag_1",
|
|
49
|
+
conversation: { id: "telegram:user:1", kind: "direct" },
|
|
50
|
+
sender: { id: "telegram:user:1", kind: "user" },
|
|
51
|
+
text: "hi",
|
|
52
|
+
replyTo: null,
|
|
53
|
+
mentioned: true,
|
|
54
|
+
receivedAt: Date.now(),
|
|
55
|
+
trace: { id: "telegram:1:2", streamable: false },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, frame);
|
|
60
|
+
await gateway.stop("test");
|
|
61
|
+
|
|
62
|
+
expect(result.accepted).toBe(true);
|
|
63
|
+
expect(result.eventId).toBe("evt_1");
|
|
64
|
+
expect(result.gatewayId).toBe("gw_tg_1");
|
|
65
|
+
expect(result.conversationId).toBe("telegram:user:1");
|
|
66
|
+
expect(result.outbound?.finalText).toBe("hello from runtime");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects frames outside the token scope", async () => {
|
|
70
|
+
const gateway = new Gateway({
|
|
71
|
+
config: {
|
|
72
|
+
channels: [],
|
|
73
|
+
defaultRoute: { runtime: "fake", cwd: tmpDir },
|
|
74
|
+
},
|
|
75
|
+
sessionStorePath: path.join(tmpDir, "sessions.json"),
|
|
76
|
+
createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
|
|
77
|
+
createRuntime: () => ({
|
|
78
|
+
id: "fake",
|
|
79
|
+
async run() {
|
|
80
|
+
return { text: "unused", newSessionId: "sess_1" };
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
transcriptEnabled: false,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, {
|
|
87
|
+
type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
|
|
88
|
+
event_id: "evt_bad",
|
|
89
|
+
gateway_id: "gw_tg_1",
|
|
90
|
+
agent_id: "ag_1",
|
|
91
|
+
provider: "telegram",
|
|
92
|
+
message: {
|
|
93
|
+
id: "telegram:1:2",
|
|
94
|
+
channel: "gw_other",
|
|
95
|
+
accountId: "ag_1",
|
|
96
|
+
conversation: { id: "telegram:user:1", kind: "direct" },
|
|
97
|
+
sender: { id: "telegram:user:1", kind: "user" },
|
|
98
|
+
text: "hi",
|
|
99
|
+
replyTo: null,
|
|
100
|
+
mentioned: true,
|
|
101
|
+
receivedAt: Date.now(),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.accepted).toBe(false);
|
|
106
|
+
expect(result.error?.code).toBe("channel_mismatch");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function stubChannel(id: string, type: string, accountId: string): ChannelAdapter {
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
type,
|
|
114
|
+
async start() {
|
|
115
|
+
return undefined;
|
|
116
|
+
},
|
|
117
|
+
async stop() {
|
|
118
|
+
return undefined;
|
|
119
|
+
},
|
|
120
|
+
async send() {
|
|
121
|
+
return {};
|
|
122
|
+
},
|
|
123
|
+
status() {
|
|
124
|
+
return { channel: id, accountId, running: true };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -733,3 +733,139 @@ describe("W4: handleLoginStatus accountId ownership check", () => {
|
|
|
733
733
|
expect(ack.error?.code).toBe("bad_params");
|
|
734
734
|
});
|
|
735
735
|
});
|
|
736
|
+
|
|
737
|
+
describe("login_missing vs login_expired", () => {
|
|
738
|
+
it("wechat upsert with an unknown loginId returns login_missing", async () => {
|
|
739
|
+
const gw = makeFakeGateway();
|
|
740
|
+
const { io } = makeConfigIO(baseCfg());
|
|
741
|
+
const sessions = new LoginSessionStore();
|
|
742
|
+
const ctrl = createGatewayControl({
|
|
743
|
+
gateway: gw as any,
|
|
744
|
+
configIO: io,
|
|
745
|
+
loginSessions: sessions,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const ack = await ctrl.handleUpsert({
|
|
749
|
+
id: uniqId("wx_missing"),
|
|
750
|
+
type: "wechat",
|
|
751
|
+
accountId: "ag_alice",
|
|
752
|
+
enabled: true,
|
|
753
|
+
loginId: "wxl_never_created",
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
expect(ack.ok).toBe(false);
|
|
757
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
758
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("feishu upsert with an unknown loginId returns login_missing", async () => {
|
|
762
|
+
const gw = makeFakeGateway();
|
|
763
|
+
const { io } = makeConfigIO(baseCfg());
|
|
764
|
+
const sessions = new LoginSessionStore();
|
|
765
|
+
const ctrl = createGatewayControl({
|
|
766
|
+
gateway: gw as any,
|
|
767
|
+
configIO: io,
|
|
768
|
+
loginSessions: sessions,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const ack = await ctrl.handleUpsert({
|
|
772
|
+
id: uniqId("fs_missing"),
|
|
773
|
+
type: "feishu",
|
|
774
|
+
accountId: "ag_alice",
|
|
775
|
+
enabled: true,
|
|
776
|
+
loginId: "fsl_never_created",
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
expect(ack.ok).toBe(false);
|
|
780
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
781
|
+
expect(gw.addChannel).not.toHaveBeenCalled();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("recent_senders with an unknown loginId returns login_missing", async () => {
|
|
785
|
+
const gw = makeFakeGateway();
|
|
786
|
+
const { io } = makeConfigIO(baseCfg());
|
|
787
|
+
const sessions = new LoginSessionStore();
|
|
788
|
+
const ctrl = createGatewayControl({
|
|
789
|
+
gateway: gw as any,
|
|
790
|
+
configIO: io,
|
|
791
|
+
loginSessions: sessions,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const ack = await ctrl.handleRecentSenders({
|
|
795
|
+
provider: "wechat",
|
|
796
|
+
loginId: "wxl_never_created",
|
|
797
|
+
accountId: "ag_alice",
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
expect(ack.ok).toBe(false);
|
|
801
|
+
expect(ack.error?.code).toBe("login_missing");
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("wechat upsert with a TTL-expired loginId returns login_expired", async () => {
|
|
805
|
+
const gw = makeFakeGateway();
|
|
806
|
+
const { io } = makeConfigIO(baseCfg());
|
|
807
|
+
let nowMs = 1_000_000;
|
|
808
|
+
const sessions = new LoginSessionStore({ now: () => nowMs, ttlMs: 60_000 });
|
|
809
|
+
sessions.create({
|
|
810
|
+
loginId: "wxl_aged",
|
|
811
|
+
accountId: "ag_alice",
|
|
812
|
+
provider: "wechat",
|
|
813
|
+
qrcode: "QR",
|
|
814
|
+
baseUrl: "https://ilinkai.weixin.qq.com",
|
|
815
|
+
botToken: "wechat-bot-token-aged",
|
|
816
|
+
});
|
|
817
|
+
nowMs += 120_000;
|
|
818
|
+
|
|
819
|
+
const ctrl = createGatewayControl({
|
|
820
|
+
gateway: gw as any,
|
|
821
|
+
configIO: io,
|
|
822
|
+
loginSessions: sessions,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const ack = await ctrl.handleUpsert({
|
|
826
|
+
id: uniqId("wx_expired"),
|
|
827
|
+
type: "wechat",
|
|
828
|
+
accountId: "ag_alice",
|
|
829
|
+
enabled: true,
|
|
830
|
+
loginId: "wxl_aged",
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
expect(ack.ok).toBe(false);
|
|
834
|
+
expect(ack.error?.code).toBe("login_expired");
|
|
835
|
+
// resolve() also evicts — a follow-up call should now report missing.
|
|
836
|
+
expect(sessions.resolve("wxl_aged").state).toBe("missing");
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("feishu upsert with a TTL-expired loginId returns login_expired", async () => {
|
|
840
|
+
const gw = makeFakeGateway();
|
|
841
|
+
const { io } = makeConfigIO(baseCfg());
|
|
842
|
+
let nowMs = 2_000_000;
|
|
843
|
+
const sessions = new LoginSessionStore({ now: () => nowMs, ttlMs: 60_000 });
|
|
844
|
+
sessions.create({
|
|
845
|
+
loginId: "fsl_aged",
|
|
846
|
+
accountId: "ag_alice",
|
|
847
|
+
provider: "feishu",
|
|
848
|
+
appId: "cli_xxx",
|
|
849
|
+
appSecret: "feishu-secret-aged",
|
|
850
|
+
domain: "feishu",
|
|
851
|
+
});
|
|
852
|
+
nowMs += 120_000;
|
|
853
|
+
|
|
854
|
+
const ctrl = createGatewayControl({
|
|
855
|
+
gateway: gw as any,
|
|
856
|
+
configIO: io,
|
|
857
|
+
loginSessions: sessions,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const ack = await ctrl.handleUpsert({
|
|
861
|
+
id: uniqId("fs_expired"),
|
|
862
|
+
type: "feishu",
|
|
863
|
+
accountId: "ag_alice",
|
|
864
|
+
enabled: true,
|
|
865
|
+
loginId: "fsl_aged",
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
expect(ack.ok).toBe(false);
|
|
869
|
+
expect(ack.error?.code).toBe("login_expired");
|
|
870
|
+
});
|
|
871
|
+
});
|
|
@@ -65,6 +65,26 @@ describe("PolicyResolver", () => {
|
|
|
65
65
|
expect(p.mode).toBe("always");
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it.each(["telegram:user:42", "wechat:user:alice", "feishu:user:ou_alice"])(
|
|
69
|
+
"forces third-party direct chat %s to mode=always",
|
|
70
|
+
async (conversationId) => {
|
|
71
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
72
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
73
|
+
const p = await resolver.resolve("ag_a", conversationId);
|
|
74
|
+
expect(p.mode).toBe("always");
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
it.each(["telegram:group:-1001", "feishu:chat:oc_team"])(
|
|
79
|
+
"does not force third-party group chat %s to mode=always",
|
|
80
|
+
async (conversationId) => {
|
|
81
|
+
const resolver = new PolicyResolver({ fetchGlobal: async () => undefined });
|
|
82
|
+
resolver.put("ag_a", null, { mode: "mention_only", keywords: [] });
|
|
83
|
+
const p = await resolver.resolve("ag_a", conversationId);
|
|
84
|
+
expect(p.mode).toBe("mention_only");
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
68
88
|
it("falls back to defaults when fetch throws", async () => {
|
|
69
89
|
const resolver = new PolicyResolver({
|
|
70
90
|
fetchGlobal: async () => {
|