talon-agent 1.10.0 → 1.11.0
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 +24 -6
- package/src/backend/claude-sdk/handler.ts +26 -22
- package/src/backend/claude-sdk/options.ts +78 -10
- package/src/core/gateway.ts +10 -3
- package/src/core/tools/index.ts +25 -2
- package/src/frontend/telegram/handlers.ts +120 -7
- package/src/__tests__/chat-id.test.ts +0 -91
- package/src/__tests__/chat-settings.test.ts +0 -471
- package/src/__tests__/claude-sdk-models.test.ts +0 -146
- package/src/__tests__/claude-sdk-options.test.ts +0 -110
- package/src/__tests__/cleanup-registry.test.ts +0 -58
- package/src/__tests__/compose-tools.test.ts +0 -216
- package/src/__tests__/config.test.ts +0 -716
- package/src/__tests__/cron-store-extended.test.ts +0 -661
- package/src/__tests__/cron-store.test.ts +0 -574
- package/src/__tests__/daily-log.test.ts +0 -357
- package/src/__tests__/disallowed-tools.test.ts +0 -64
- package/src/__tests__/dispatcher.test.ts +0 -784
- package/src/__tests__/dream.test.ts +0 -1145
- package/src/__tests__/end-turn.test.ts +0 -189
- package/src/__tests__/errors-extended.test.ts +0 -428
- package/src/__tests__/errors.test.ts +0 -332
- package/src/__tests__/fixtures/test-mcp-server.ts +0 -37
- package/src/__tests__/fuzz.test.ts +0 -375
- package/src/__tests__/gateway-actions.test.ts +0 -1772
- package/src/__tests__/gateway-context.test.ts +0 -102
- package/src/__tests__/gateway-http.test.ts +0 -436
- package/src/__tests__/gateway-retry.test.ts +0 -355
- package/src/__tests__/gateway-withRetry-extended.test.ts +0 -343
- package/src/__tests__/graph.test.ts +0 -830
- package/src/__tests__/handlers-stream.test.ts +0 -203
- package/src/__tests__/handlers.test.ts +0 -2914
- package/src/__tests__/heartbeat.test.ts +0 -388
- package/src/__tests__/history-extended.test.ts +0 -775
- package/src/__tests__/history-persistence.test.ts +0 -227
- package/src/__tests__/history.test.ts +0 -693
- package/src/__tests__/integration.test.ts +0 -224
- package/src/__tests__/log-init.test.ts +0 -129
- package/src/__tests__/log.test.ts +0 -129
- package/src/__tests__/mcp-launcher-functional.test.ts +0 -334
- package/src/__tests__/mcp-launcher.test.ts +0 -139
- package/src/__tests__/mcp-lifecycle.test.ts +0 -165
- package/src/__tests__/media-index.test.ts +0 -559
- package/src/__tests__/mempalace-plugin.test.ts +0 -350
- package/src/__tests__/metrics.test.ts +0 -76
- package/src/__tests__/opencode-models.test.ts +0 -117
- package/src/__tests__/opencode-summary.test.ts +0 -105
- package/src/__tests__/opencode-ui.test.ts +0 -94
- package/src/__tests__/plugin.test.ts +0 -962
- package/src/__tests__/reload-plugins.test.ts +0 -342
- package/src/__tests__/sessions.test.ts +0 -877
- package/src/__tests__/storage-save-errors.test.ts +0 -342
- package/src/__tests__/teams-frontend.test.ts +0 -762
- package/src/__tests__/telegram-formatting.test.ts +0 -86
- package/src/__tests__/telegram-helpers.test.ts +0 -151
- package/src/__tests__/telegram.test.ts +0 -176
- package/src/__tests__/terminal-commands.test.ts +0 -666
- package/src/__tests__/terminal-frontend.test.ts +0 -141
- package/src/__tests__/terminal-renderer.test.ts +0 -501
- package/src/__tests__/time.test.ts +0 -107
- package/src/__tests__/tool-functional.test.ts +0 -615
- package/src/__tests__/tool-id-coercion.test.ts +0 -136
- package/src/__tests__/watchdog.test.ts +0 -285
- package/src/__tests__/workspace-migrate.test.ts +0 -256
- package/src/__tests__/workspace.test.ts +0 -284
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,16 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"bin/",
|
|
33
|
-
"src/",
|
|
33
|
+
"src/backend/",
|
|
34
|
+
"src/core/",
|
|
35
|
+
"src/frontend/",
|
|
36
|
+
"src/plugins/",
|
|
37
|
+
"src/storage/",
|
|
38
|
+
"src/util/",
|
|
39
|
+
"src/bootstrap.ts",
|
|
40
|
+
"src/cli.ts",
|
|
41
|
+
"src/index.ts",
|
|
42
|
+
"src/login.ts",
|
|
34
43
|
"prompts/",
|
|
35
44
|
"README.md",
|
|
36
45
|
"tsconfig.json"
|
|
@@ -41,13 +50,20 @@
|
|
|
41
50
|
"setup": "tsx src/cli.ts setup",
|
|
42
51
|
"dev": "tsx --watch src/index.ts",
|
|
43
52
|
"test": "vitest run",
|
|
53
|
+
"test:ci": "vitest run --reporter=verbose --reporter=json --outputFile=test-results.json",
|
|
54
|
+
"test:functional": "vitest run --reporter=verbose --reporter=json --outputFile=functional-results.json src/__tests__/package.functional.test.ts src/__tests__/tool-functional.test.ts src/__tests__/mcp-launcher.test.ts src/__tests__/mcp-launcher-functional.test.ts src/__tests__/integration/sdk-stub.test.ts src/__tests__/integration/talon-functional.test.ts",
|
|
55
|
+
"test:integration": "vitest run --reporter=verbose --reporter=json --outputFile=integration-results.json src/__tests__/integration/talon-mcp-functional.test.ts",
|
|
56
|
+
"test:integration:all": "vitest run --reporter=verbose src/__tests__/integration/",
|
|
57
|
+
"tarball:check": "node .github/scripts/tarball-check.mjs",
|
|
58
|
+
"build:stub-sea": "node src/__tests__/integration/stub-claude/build-sea.mjs",
|
|
44
59
|
"test:watch": "vitest",
|
|
45
60
|
"test:coverage": "vitest run --coverage",
|
|
46
61
|
"typecheck": "tsc --noEmit",
|
|
47
62
|
"lint": "oxlint src/",
|
|
48
63
|
"knip": "knip",
|
|
49
64
|
"format": "prettier --write src/ prompts/",
|
|
50
|
-
"format:check": "prettier --check src/ prompts/"
|
|
65
|
+
"format:check": "prettier --check src/ prompts/",
|
|
66
|
+
"ci:protect": "node .github/scripts/enforce-ci-gate.mjs"
|
|
51
67
|
},
|
|
52
68
|
"dependencies": {
|
|
53
69
|
"@anthropic-ai/claude-agent-sdk": "^0.2.108",
|
|
@@ -58,7 +74,7 @@
|
|
|
58
74
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
59
75
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
60
76
|
"@opencode-ai/sdk": "^1.4.0",
|
|
61
|
-
"@playwright/mcp": "^0.0.
|
|
77
|
+
"@playwright/mcp": "^0.0.75",
|
|
62
78
|
"big-integer": "^1.6.52",
|
|
63
79
|
"cheerio": "^1.2.0",
|
|
64
80
|
"croner": "^10.0.1",
|
|
@@ -71,7 +87,7 @@
|
|
|
71
87
|
"telegram": "^2.26.22",
|
|
72
88
|
"tsx": "^4.21.0",
|
|
73
89
|
"undici": "^8.0.2",
|
|
74
|
-
"write-file-atomic": "^
|
|
90
|
+
"write-file-atomic": "^8.0.0",
|
|
75
91
|
"zod": "^4.3.6"
|
|
76
92
|
},
|
|
77
93
|
"devDependencies": {
|
|
@@ -86,6 +102,8 @@
|
|
|
86
102
|
"vitest": "^4.1.3"
|
|
87
103
|
},
|
|
88
104
|
"overrides": {
|
|
89
|
-
"@anthropic-ai/sdk": "^0.95.0"
|
|
105
|
+
"@anthropic-ai/sdk": "^0.95.0",
|
|
106
|
+
"ip-address": "^10.1.1",
|
|
107
|
+
"fast-uri": "^3.1.2"
|
|
90
108
|
}
|
|
91
109
|
}
|
|
@@ -24,7 +24,7 @@ import { log, logError, logWarn } from "../../util/log.js";
|
|
|
24
24
|
import { traceMessage } from "../../util/trace.js";
|
|
25
25
|
import { incrementCounter, recordHistogram } from "../../util/metrics.js";
|
|
26
26
|
import { formatFullDatetime } from "../../util/time.js";
|
|
27
|
-
import { isTurnTerminator } from "../../core/tools/index.js";
|
|
27
|
+
import { isTurnTerminator, stripMcpPrefix } from "../../core/tools/index.js";
|
|
28
28
|
|
|
29
29
|
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
|
30
30
|
import type { QueryParams, QueryResult } from "../../core/types.js";
|
|
@@ -99,15 +99,20 @@ export async function handleMessage(
|
|
|
99
99
|
// so the end-of-turn trailing-text fallback can dedupe against content
|
|
100
100
|
// already delivered. Without this, a model that writes prose AND calls a
|
|
101
101
|
// delivery tool with similar text would surface twice in the chat.
|
|
102
|
+
//
|
|
103
|
+
// Tool names arrive MCP-prefixed (e.g. `mcp__telegram-tools__end_turn`)
|
|
104
|
+
// when routed through MCP — strip the prefix so equality checks match
|
|
105
|
+
// the registry's bare names.
|
|
102
106
|
const captureDeliveredText = (
|
|
103
107
|
toolName: string,
|
|
104
108
|
input: Record<string, unknown>,
|
|
105
109
|
): void => {
|
|
110
|
+
const bareName = stripMcpPrefix(toolName);
|
|
106
111
|
let deliveredText: string | undefined;
|
|
107
|
-
if (
|
|
112
|
+
if (bareName === "end_turn" && typeof input.text === "string") {
|
|
108
113
|
deliveredText = input.text;
|
|
109
114
|
} else if (
|
|
110
|
-
|
|
115
|
+
bareName === "send" &&
|
|
111
116
|
input.type === "text" &&
|
|
112
117
|
typeof input.text === "string"
|
|
113
118
|
) {
|
|
@@ -169,25 +174,24 @@ export async function handleMessage(
|
|
|
169
174
|
}
|
|
170
175
|
}
|
|
171
176
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// the
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
177
|
+
// Note: we previously called `qi.interrupt()` here when a turn-
|
|
178
|
+
// terminator tool fired, intending to short-circuit the SDK's
|
|
179
|
+
// wasted "wrap up after end_turn tool_result" follow-up API call
|
|
180
|
+
// (~3s of phantom typing while the model says nothing useful).
|
|
181
|
+
// That interrupt races with in-flight MCP tool dispatches in the
|
|
182
|
+
// same assistant message — `end_turn` itself is an MCP tool, and
|
|
183
|
+
// the model frequently emits sibling tool_use blocks in the same
|
|
184
|
+
// message. interrupt cancels their AbortController mid-flight,
|
|
185
|
+
// which surfaces as `MCP error -32001: AbortError` in the SDK
|
|
186
|
+
// result and bubbles up to the user as "Something went wrong".
|
|
187
|
+
//
|
|
188
|
+
// The natural-close path is fine: the SDK does one more API call
|
|
189
|
+
// after end_turn returns (the model has nothing to say so it
|
|
190
|
+
// returns a stop turn quickly, ~2-3s typing lag), then yields a
|
|
191
|
+
// result message and exits the iterator cleanly. We accept the
|
|
192
|
+
// typing lag in exchange for not breaking turns. `state.turnTerminated`
|
|
193
|
+
// is still tracked so the flow-violation re-prompt path below can
|
|
194
|
+
// skip its retry when the model explicitly ended its turn.
|
|
191
195
|
continue;
|
|
192
196
|
}
|
|
193
197
|
|
|
@@ -6,12 +6,20 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
-
import
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import type {
|
|
11
|
+
Options,
|
|
12
|
+
PostToolBatchHookInput,
|
|
13
|
+
HookCallback,
|
|
14
|
+
HookJSONOutput,
|
|
15
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
10
16
|
import { getSession } from "../../storage/sessions.js";
|
|
11
17
|
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
12
18
|
import { getPluginMcpServers } from "../../core/plugin.js";
|
|
13
19
|
import { resolveModelId } from "../../core/models.js";
|
|
14
20
|
import { wrapMcpServer } from "../../util/mcp-launcher.js";
|
|
21
|
+
import { isTurnTerminator } from "../../core/tools/index.js";
|
|
22
|
+
import { log } from "../../util/log.js";
|
|
15
23
|
import { getConfig, getBridgePort } from "./state.js";
|
|
16
24
|
import { DISALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
|
|
17
25
|
|
|
@@ -38,10 +46,17 @@ export function buildMcpServers(
|
|
|
38
46
|
const config = getConfig();
|
|
39
47
|
const bridgeUrl = `http://127.0.0.1:${getBridgePort()}`;
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
// tsx as a Node loader is passed via `--import <url>`. Node accepts URLs
|
|
50
|
+
// or absolute paths, but on Windows a raw backslash path (`D:\…\tsx`) is
|
|
51
|
+
// ambiguous between path and URL — the loader hook fails to register and
|
|
52
|
+
// every subsequent `import` of a .ts file throws. `pathToFileURL` produces
|
|
53
|
+
// a cross-platform `file://` URL that Node always treats as a loader URL.
|
|
54
|
+
const tsxImport = pathToFileURL(
|
|
55
|
+
resolve(
|
|
56
|
+
import.meta.dirname ?? ".",
|
|
57
|
+
"../../../node_modules/tsx/dist/esm/index.mjs",
|
|
58
|
+
),
|
|
59
|
+
).href;
|
|
45
60
|
const mcpServerPath = resolve(
|
|
46
61
|
import.meta.dirname ?? ".",
|
|
47
62
|
"../../core/tools/mcp-server.ts",
|
|
@@ -65,12 +80,14 @@ export function buildMcpServers(
|
|
|
65
80
|
TALON_CHAT_ID: chatId,
|
|
66
81
|
TALON_FRONTEND: frontend,
|
|
67
82
|
};
|
|
83
|
+
// `node --import <tsx-loader>` everywhere — tsx as a Node loader works
|
|
84
|
+
// identically on Windows and POSIX, and avoids spawning `npx.cmd` (which
|
|
85
|
+
// Node 20.19+ refuses to execute via child_process.spawn without
|
|
86
|
+
// shell:true; CVE-2024-27980 mitigation). The wrapping launcher would
|
|
87
|
+
// hit the same .cmd ban when calling its child.
|
|
68
88
|
servers[serverName] = wrapMcpServer({
|
|
69
|
-
command:
|
|
70
|
-
args:
|
|
71
|
-
process.platform === "win32"
|
|
72
|
-
? ["tsx", mcpServerPath]
|
|
73
|
-
: ["--import", tsxImport, mcpServerPath],
|
|
89
|
+
command: "node",
|
|
90
|
+
args: ["--import", tsxImport, mcpServerPath],
|
|
74
91
|
env: mcpEnv,
|
|
75
92
|
});
|
|
76
93
|
}
|
|
@@ -90,6 +107,54 @@ export function buildMcpServers(
|
|
|
90
107
|
return servers;
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
// ── Hooks ───────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* PostToolBatch hook: terminate the SDK query loop the moment a turn-terminator
|
|
114
|
+
* tool (e.g. `end_turn`) resolves in the assistant's tool batch.
|
|
115
|
+
*
|
|
116
|
+
* Why PostToolBatch and not PostToolUse:
|
|
117
|
+
* - PostToolUse fires per-tool and may run concurrently for parallel tool
|
|
118
|
+
* calls. Returning `continue: false` from there can race with sibling MCP
|
|
119
|
+
* tools whose AbortControllers haven't yet completed — the same race that
|
|
120
|
+
* killed the previous `qi.interrupt()` approach (see handler.ts comment
|
|
121
|
+
* and commit `d5ce30f`).
|
|
122
|
+
* - PostToolBatch fires exactly ONCE after every tool in the batch has
|
|
123
|
+
* resolved. By definition there are no in-flight siblings to race with.
|
|
124
|
+
*
|
|
125
|
+
* What this saves:
|
|
126
|
+
* - The ~2-3s "phantom typing" round-trip the SDK makes after `end_turn`
|
|
127
|
+
* returns (the model has nothing to say, generates a stop_turn anyway).
|
|
128
|
+
* - Trailing prose that gets generated during that round-trip and was
|
|
129
|
+
* previously suppressed only at the delivery layer (real tokens spent).
|
|
130
|
+
*
|
|
131
|
+
* Returns `{ continue: false, stopReason: ... }` → SDK exits with TerminalReason
|
|
132
|
+
* `'hook_stopped'`, no further model generation.
|
|
133
|
+
*/
|
|
134
|
+
const turnTerminatorHook: HookCallback = async (
|
|
135
|
+
input,
|
|
136
|
+
): Promise<HookJSONOutput> => {
|
|
137
|
+
if (input.hook_event_name !== "PostToolBatch") {
|
|
138
|
+
return { continue: true };
|
|
139
|
+
}
|
|
140
|
+
const batch = input as PostToolBatchHookInput;
|
|
141
|
+
const terminator = batch.tool_calls.find((tc) =>
|
|
142
|
+
isTurnTerminator(tc.tool_name),
|
|
143
|
+
);
|
|
144
|
+
if (terminator) {
|
|
145
|
+
log(
|
|
146
|
+
"agent",
|
|
147
|
+
`PostToolBatch: terminating SDK loop on ${terminator.tool_name} ` +
|
|
148
|
+
`(batch size: ${batch.tool_calls.length})`,
|
|
149
|
+
);
|
|
150
|
+
return {
|
|
151
|
+
continue: false,
|
|
152
|
+
stopReason: "turn terminated by end_turn / send",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { continue: true };
|
|
156
|
+
};
|
|
157
|
+
|
|
93
158
|
// ── Options builder ─────────────────────────────────────────────────────────
|
|
94
159
|
|
|
95
160
|
export function buildSdkOptions(chatId: string): BuildSdkOptionsResult {
|
|
@@ -120,6 +185,9 @@ export function buildSdkOptions(chatId: string): BuildSdkOptionsResult {
|
|
|
120
185
|
...buildMcpServers(chatId),
|
|
121
186
|
...getPluginMcpServers(`http://127.0.0.1:${getBridgePort()}`, chatId),
|
|
122
187
|
},
|
|
188
|
+
hooks: {
|
|
189
|
+
PostToolBatch: [{ hooks: [turnTerminatorHook] }],
|
|
190
|
+
},
|
|
123
191
|
...(session.sessionId ? { resume: session.sessionId } : {}),
|
|
124
192
|
};
|
|
125
193
|
|
package/src/core/gateway.ts
CHANGED
|
@@ -298,9 +298,16 @@ export class Gateway {
|
|
|
298
298
|
});
|
|
299
299
|
httpServer.listen(p, "127.0.0.1", () => {
|
|
300
300
|
this.server = httpServer;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
301
|
+
// When the caller asks for port 0 the OS assigns a random free
|
|
302
|
+
// port — read the actual port off the listening socket instead
|
|
303
|
+
// of saving the requested 0.
|
|
304
|
+
const addr = httpServer.address();
|
|
305
|
+
this.port =
|
|
306
|
+
typeof addr === "object" && addr !== null
|
|
307
|
+
? (addr as { port: number }).port
|
|
308
|
+
: p;
|
|
309
|
+
log("gateway", `Action gateway on :${this.port}`);
|
|
310
|
+
resolve(this.port);
|
|
304
311
|
});
|
|
305
312
|
};
|
|
306
313
|
tryPort(port);
|
package/src/core/tools/index.ts
CHANGED
|
@@ -43,9 +43,32 @@ const TURN_TERMINATOR_NAMES: ReadonlySet<string> = new Set(
|
|
|
43
43
|
ALL_TOOLS.filter((t) => t.endsTurn).map((t) => t.name),
|
|
44
44
|
);
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Strip an MCP server prefix (`mcp__<server>__`) from a tool name.
|
|
48
|
+
*
|
|
49
|
+
* Tools served through MCP arrive at the SDK with the prefix attached
|
|
50
|
+
* (e.g. `mcp__telegram-tools__end_turn`), while the registry stores them
|
|
51
|
+
* by their bare name (`end_turn`). Callers that want to compare against
|
|
52
|
+
* the registry should normalize first.
|
|
53
|
+
*
|
|
54
|
+
* Returns the input unchanged if no prefix matches — safe to call on any
|
|
55
|
+
* tool name. The non-greedy `.+?` matches the FIRST `__` boundary after
|
|
56
|
+
* `mcp__`, which is the server-name terminator in MCP's naming scheme.
|
|
57
|
+
*/
|
|
58
|
+
export function stripMcpPrefix(toolName: string): string {
|
|
59
|
+
return toolName.replace(/^mcp__.+?__/, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a tool call by this name should terminate the model's turn.
|
|
64
|
+
*
|
|
65
|
+
* Accepts both bare names (`end_turn`) and MCP-prefixed names
|
|
66
|
+
* (`mcp__telegram-tools__end_turn`) — the prefix is stripped before
|
|
67
|
+
* comparing against the terminator set.
|
|
68
|
+
*/
|
|
47
69
|
export function isTurnTerminator(toolName: string): boolean {
|
|
48
|
-
|
|
70
|
+
if (TURN_TERMINATOR_NAMES.has(toolName)) return true;
|
|
71
|
+
return TURN_TERMINATOR_NAMES.has(stripMcpPrefix(toolName));
|
|
49
72
|
}
|
|
50
73
|
|
|
51
74
|
/** Filter options for composing a tool set. */
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
appendDailyLog,
|
|
15
15
|
appendDailyLogResponse,
|
|
16
16
|
} from "../../storage/daily-log.js";
|
|
17
|
+
import { stripMcpPrefix } from "../../core/tools/index.js";
|
|
17
18
|
import { setMessageFilePath } from "../../storage/history.js";
|
|
18
19
|
import { addMedia } from "../../storage/media-index.js";
|
|
19
20
|
import { recordMessageProcessed, recordError } from "../../util/watchdog.js";
|
|
@@ -158,11 +159,117 @@ export async function isAccessAllowed(
|
|
|
158
159
|
return false;
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Maximum length of an unauthorized message body to retain in logs.
|
|
164
|
+
* Keeps abusive payloads (large pastes, attachment captions etc.) bounded
|
|
165
|
+
* while still preserving enough context to understand what was sent.
|
|
166
|
+
*/
|
|
167
|
+
const UNAUTHORIZED_BODY_MAX_LEN = 1024;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Best-effort preview of an unauthorized message for forensics.
|
|
171
|
+
*
|
|
172
|
+
* Returns the visible text payload (text or caption), a short tag for
|
|
173
|
+
* media-only messages (`[sticker: 🤖]`, `[photo]`, `[voice 14s]`, etc.),
|
|
174
|
+
* or `undefined` if there's nothing meaningful to capture (e.g. a service
|
|
175
|
+
* message or empty content).
|
|
176
|
+
*
|
|
177
|
+
* Truncated to UNAUTHORIZED_BODY_MAX_LEN to keep log lines bounded.
|
|
178
|
+
*/
|
|
179
|
+
export function extractUnauthorizedPreview(
|
|
180
|
+
message:
|
|
181
|
+
| {
|
|
182
|
+
text?: string;
|
|
183
|
+
caption?: string;
|
|
184
|
+
sticker?: { emoji?: string; set_name?: string };
|
|
185
|
+
photo?: unknown;
|
|
186
|
+
voice?: { duration?: number };
|
|
187
|
+
video?: unknown;
|
|
188
|
+
video_note?: unknown;
|
|
189
|
+
audio?: unknown;
|
|
190
|
+
animation?: unknown;
|
|
191
|
+
document?: { file_name?: string };
|
|
192
|
+
contact?: unknown;
|
|
193
|
+
location?: unknown;
|
|
194
|
+
poll?: { question?: string };
|
|
195
|
+
dice?: { emoji?: string };
|
|
196
|
+
}
|
|
197
|
+
| undefined,
|
|
198
|
+
): string | undefined {
|
|
199
|
+
if (!message) return undefined;
|
|
200
|
+
|
|
201
|
+
const text = message.text ?? message.caption;
|
|
202
|
+
if (typeof text === "string" && text.trim().length > 0) {
|
|
203
|
+
return text.length > UNAUTHORIZED_BODY_MAX_LEN
|
|
204
|
+
? `${text.slice(0, UNAUTHORIZED_BODY_MAX_LEN)}… [truncated]`
|
|
205
|
+
: text;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (message.sticker) {
|
|
209
|
+
const emoji = message.sticker.emoji ?? "?";
|
|
210
|
+
const set = message.sticker.set_name
|
|
211
|
+
? ` from ${message.sticker.set_name}`
|
|
212
|
+
: "";
|
|
213
|
+
return `[sticker: ${emoji}${set}]`;
|
|
214
|
+
}
|
|
215
|
+
if (message.photo) return "[photo]";
|
|
216
|
+
if (message.voice) {
|
|
217
|
+
const dur = message.voice.duration;
|
|
218
|
+
return dur ? `[voice ${dur}s]` : "[voice]";
|
|
219
|
+
}
|
|
220
|
+
if (message.video_note) return "[video note]";
|
|
221
|
+
if (message.video) return "[video]";
|
|
222
|
+
if (message.audio) return "[audio]";
|
|
223
|
+
if (message.animation) return "[animation]";
|
|
224
|
+
if (message.document) {
|
|
225
|
+
return message.document.file_name
|
|
226
|
+
? `[document: ${message.document.file_name}]`
|
|
227
|
+
: "[document]";
|
|
228
|
+
}
|
|
229
|
+
if (message.contact) return "[contact]";
|
|
230
|
+
if (message.location) return "[location]";
|
|
231
|
+
if (message.poll) {
|
|
232
|
+
return message.poll.question
|
|
233
|
+
? `[poll: ${message.poll.question}]`
|
|
234
|
+
: "[poll]";
|
|
235
|
+
}
|
|
236
|
+
if (message.dice) return `[dice: ${message.dice.emoji ?? "🎲"}]`;
|
|
237
|
+
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
161
241
|
async function notifyUnauthorized(
|
|
162
242
|
bot: Bot,
|
|
163
243
|
ctx: Context,
|
|
164
244
|
type: "dm" | "group",
|
|
165
245
|
): Promise<void> {
|
|
246
|
+
const sender = getSenderName(ctx.from);
|
|
247
|
+
const username = ctx.from?.username ? ` (@${ctx.from.username})` : "";
|
|
248
|
+
const userId = ctx.from?.id ?? "unknown";
|
|
249
|
+
|
|
250
|
+
// Capture message body BEFORE the cooldown check — every unauthorized
|
|
251
|
+
// attempt should be recorded for forensics, even if the user-facing
|
|
252
|
+
// warning + admin notification are suppressed by cooldown. Without
|
|
253
|
+
// this, follow-up DMs from a known social-engineering account vanish
|
|
254
|
+
// entirely from logs.
|
|
255
|
+
const body = extractUnauthorizedPreview(
|
|
256
|
+
ctx.message as Parameters<typeof extractUnauthorizedPreview>[0],
|
|
257
|
+
);
|
|
258
|
+
if (body) {
|
|
259
|
+
try {
|
|
260
|
+
appendDailyLog(
|
|
261
|
+
`⚠️ UNAUTHORIZED ${sender}${username} [id:${userId}]`,
|
|
262
|
+
body,
|
|
263
|
+
);
|
|
264
|
+
} catch {
|
|
265
|
+
/* daily log unavailable — fall through to talon.log */
|
|
266
|
+
}
|
|
267
|
+
logWarn(
|
|
268
|
+
"access",
|
|
269
|
+
`Unauthorized ${type} body from ${sender}${username} [id:${userId}]: ${body.slice(0, 200)}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
166
273
|
const key = type === "dm" ? `dm:${ctx.from?.id}` : `group:${ctx.chat?.id}`;
|
|
167
274
|
const now = Date.now();
|
|
168
275
|
const lastWarned = unauthorizedCooldown.get(key);
|
|
@@ -172,10 +279,6 @@ async function notifyUnauthorized(
|
|
|
172
279
|
}
|
|
173
280
|
unauthorizedCooldown.set(key, now);
|
|
174
281
|
|
|
175
|
-
const sender = getSenderName(ctx.from);
|
|
176
|
-
const username = ctx.from?.username ? ` (@${ctx.from.username})` : "";
|
|
177
|
-
const userId = ctx.from?.id ?? "unknown";
|
|
178
|
-
|
|
179
282
|
// Warn the user
|
|
180
283
|
try {
|
|
181
284
|
await bot.api.sendMessage(
|
|
@@ -192,8 +295,9 @@ async function notifyUnauthorized(
|
|
|
192
295
|
type === "dm"
|
|
193
296
|
? `🚨 Unauthorized DM from ${sender}${username} [id:${userId}]`
|
|
194
297
|
: `🚨 Unauthorized group access: "${(ctx.chat as { title?: string })?.title ?? ctx.chat!.id}" [id:${ctx.chat!.id}] by ${sender}${username}`;
|
|
298
|
+
const detailWithBody = body ? `${detail}\n\n${body.slice(0, 400)}` : detail;
|
|
195
299
|
try {
|
|
196
|
-
await bot.api.sendMessage(adminId,
|
|
300
|
+
await bot.api.sendMessage(adminId, detailWithBody);
|
|
197
301
|
} catch {
|
|
198
302
|
/* admin unreachable — ignore */
|
|
199
303
|
}
|
|
@@ -771,8 +875,17 @@ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
|
|
|
771
875
|
onStreamDelta,
|
|
772
876
|
onTextBlock,
|
|
773
877
|
onToolUse: (toolName, input) => {
|
|
774
|
-
|
|
775
|
-
|
|
878
|
+
// Tool names arrive MCP-prefixed (e.g. `mcp__telegram-tools__end_turn`)
|
|
879
|
+
// when routed through MCP — strip the prefix so equality checks
|
|
880
|
+
// match the registry's bare names. Both `end_turn(text=...)` and
|
|
881
|
+
// `send(type="text")` are user-facing text deliveries; capture
|
|
882
|
+
// both so the daily log records bot responses regardless of which
|
|
883
|
+
// delivery tool the model used.
|
|
884
|
+
const bareName = stripMcpPrefix(toolName);
|
|
885
|
+
if (bareName === "end_turn" && typeof input.text === "string") {
|
|
886
|
+
appendDailyLogResponse("Talon", input.text, { chatTitle });
|
|
887
|
+
} else if (
|
|
888
|
+
bareName === "send" &&
|
|
776
889
|
input.type === "text" &&
|
|
777
890
|
typeof input.text === "string"
|
|
778
891
|
) {
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
deriveNumericChatId,
|
|
5
|
-
generateTerminalChatId,
|
|
6
|
-
isTerminalChatId,
|
|
7
|
-
} from "../util/chat-id.js";
|
|
8
|
-
|
|
9
|
-
describe("deriveNumericChatId", () => {
|
|
10
|
-
it("returns a positive number", () => {
|
|
11
|
-
const id = deriveNumericChatId("test-chat");
|
|
12
|
-
expect(id).toBeGreaterThan(0);
|
|
13
|
-
expect(Number.isInteger(id)).toBe(true);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("returns the same value for the same input", () => {
|
|
17
|
-
const a = deriveNumericChatId("stable-id");
|
|
18
|
-
const b = deriveNumericChatId("stable-id");
|
|
19
|
-
expect(a).toBe(b);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("returns different values for different inputs", () => {
|
|
23
|
-
const a = deriveNumericChatId("chat-alpha");
|
|
24
|
-
const b = deriveNumericChatId("chat-beta");
|
|
25
|
-
expect(a).not.toBe(b);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("handles terminal-style IDs", () => {
|
|
29
|
-
const id = deriveNumericChatId("t_1711360000000");
|
|
30
|
-
expect(id).toBeGreaterThan(0);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("handles empty string", () => {
|
|
34
|
-
const id = deriveNumericChatId("");
|
|
35
|
-
expect(id).toBeGreaterThanOrEqual(0);
|
|
36
|
-
expect(Number.isInteger(id)).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe("generateTerminalChatId", () => {
|
|
41
|
-
it("returns a string starting with t_", () => {
|
|
42
|
-
const id = generateTerminalChatId();
|
|
43
|
-
expect(id).toMatch(/^t_\d+$/);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("returns a string with numeric timestamp portion", () => {
|
|
47
|
-
const id = generateTerminalChatId();
|
|
48
|
-
const ts = Number(id.slice(2));
|
|
49
|
-
expect(Number.isNaN(ts)).toBe(false);
|
|
50
|
-
expect(ts).toBeGreaterThan(0);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("uses a recent timestamp", () => {
|
|
54
|
-
const before = Date.now();
|
|
55
|
-
const id = generateTerminalChatId();
|
|
56
|
-
const after = Date.now();
|
|
57
|
-
const ts = Number(id.slice(2));
|
|
58
|
-
expect(ts).toBeGreaterThanOrEqual(before);
|
|
59
|
-
expect(ts).toBeLessThanOrEqual(after);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("isTerminalChatId", () => {
|
|
64
|
-
it('returns true for "1" (legacy ID)', () => {
|
|
65
|
-
expect(isTerminalChatId("1")).toBe(true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("returns true for t_ prefixed IDs", () => {
|
|
69
|
-
expect(isTerminalChatId("t_1711360000000")).toBe(true);
|
|
70
|
-
expect(isTerminalChatId("t_0")).toBe(true);
|
|
71
|
-
expect(isTerminalChatId("t_abc")).toBe(true);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("returns false for Telegram numeric IDs", () => {
|
|
75
|
-
expect(isTerminalChatId("123456789")).toBe(false);
|
|
76
|
-
expect(isTerminalChatId("-100123456")).toBe(false);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("returns false for Teams IDs", () => {
|
|
80
|
-
expect(isTerminalChatId("teams_chat_abc123")).toBe(false);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("returns false for empty string", () => {
|
|
84
|
-
expect(isTerminalChatId("")).toBe(false);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('returns false for "10" and other strings starting with 1', () => {
|
|
88
|
-
expect(isTerminalChatId("10")).toBe(false);
|
|
89
|
-
expect(isTerminalChatId("100")).toBe(false);
|
|
90
|
-
});
|
|
91
|
-
});
|