talon-agent 1.9.1 → 1.9.2
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 +4 -3
- package/prompts/mempalace.md +24 -4
- package/src/__tests__/log.test.ts +4 -3
- package/src/__tests__/tool-functional.test.ts +615 -0
- package/src/__tests__/tool-id-coercion.test.ts +136 -0
- package/src/backend/claude-sdk/handler.ts +2 -3
- package/src/core/gateway.ts +12 -2
- package/src/core/tools/history.ts +6 -5
- package/src/core/tools/members.ts +2 -1
- package/src/core/tools/messaging.ts +9 -8
- package/src/core/tools/schemas.ts +34 -0
- package/src/core/tools/stickers.ts +3 -2
- package/src/frontend/telegram/commands.ts +2 -3
- package/src/util/log.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.2",
|
|
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",
|
|
@@ -51,13 +51,14 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@anthropic-ai/claude-agent-sdk": "^0.2.108",
|
|
54
|
+
"@anthropic-ai/sdk": "^0.95.0",
|
|
54
55
|
"@brave/brave-search-mcp-server": "^2.0.75",
|
|
55
56
|
"@clack/prompts": "^1.2.0",
|
|
56
57
|
"@grammyjs/auto-retry": "^2.0.2",
|
|
57
58
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
58
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
60
|
"@opencode-ai/sdk": "^1.4.0",
|
|
60
|
-
"@playwright/mcp": "^0.0.
|
|
61
|
+
"@playwright/mcp": "^0.0.74",
|
|
61
62
|
"big-integer": "^1.6.52",
|
|
62
63
|
"cheerio": "^1.2.0",
|
|
63
64
|
"croner": "^10.0.1",
|
|
@@ -85,6 +86,6 @@
|
|
|
85
86
|
"vitest": "^4.1.3"
|
|
86
87
|
},
|
|
87
88
|
"overrides": {
|
|
88
|
-
"@anthropic-ai/sdk": "^0.
|
|
89
|
+
"@anthropic-ai/sdk": "^0.95.0"
|
|
89
90
|
}
|
|
90
91
|
}
|
package/prompts/mempalace.md
CHANGED
|
@@ -7,6 +7,7 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
|
|
7
7
|
- **Wings** = top-level categories (people, projects, topics)
|
|
8
8
|
- **Rooms** = specific subjects within a wing
|
|
9
9
|
- **Drawers** = individual memory chunks (verbatim text)
|
|
10
|
+
- **Tunnels** = cross-wing links between related rooms (auto-created in mempalace 3.3.4+ when topics overlap, plus manual)
|
|
10
11
|
- **Knowledge Graph** = entity-relationship facts with temporal validity
|
|
11
12
|
|
|
12
13
|
### Protocol — FOLLOW EVERY SESSION
|
|
@@ -25,6 +26,7 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
|
|
25
26
|
- `mempalace_status` — Palace overview: total drawers, wings, rooms.
|
|
26
27
|
- `mempalace_list_wings` / `mempalace_list_rooms` — Browse structure.
|
|
27
28
|
- `mempalace_get_taxonomy` — Full wing/room/count tree.
|
|
29
|
+
- `mempalace_get_aaak_spec` — Get the AAAK closet/compression spec. Only needed when reading/writing AAAK-compressed memories directly.
|
|
28
30
|
|
|
29
31
|
**Knowledge Graph (Temporal Facts):**
|
|
30
32
|
|
|
@@ -34,24 +36,42 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
|
|
34
36
|
- `mempalace_kg_timeline` — Chronological story of an entity.
|
|
35
37
|
- `mempalace_kg_stats` — Graph overview: entities, triples, relationship types.
|
|
36
38
|
|
|
37
|
-
**Palace Graph
|
|
39
|
+
**Palace Graph & Cross-Wing Tunnels:**
|
|
38
40
|
|
|
39
41
|
- `mempalace_traverse` — Walk from a room, find connected ideas across wings.
|
|
40
42
|
- `mempalace_find_tunnels` — Find rooms that bridge two wings.
|
|
43
|
+
- `mempalace_follow_tunnels` — From a specific (wing, room) pair, walk the outbound tunnels and see connected rooms with drawer previews.
|
|
44
|
+
- `mempalace_create_tunnel` — Manually create a cross-wing tunnel between two (wing, room) pairs. Use when you spot a connection the auto-detector missed.
|
|
45
|
+
- `mempalace_list_tunnels` — List tunnels (optionally filtered by wing).
|
|
46
|
+
- `mempalace_delete_tunnel` — Remove a tunnel by ID when it's wrong or noisy.
|
|
41
47
|
- `mempalace_graph_stats` — Graph connectivity overview.
|
|
42
48
|
|
|
43
|
-
**
|
|
49
|
+
**Drawers:**
|
|
44
50
|
|
|
45
51
|
- `mempalace_add_drawer` — Store verbatim content into a wing/room. Auto-checks duplicates.
|
|
52
|
+
- `mempalace_get_drawer` — Fetch a single drawer by ID. Returns full content + metadata. Use after a search hit when you need the verbatim text.
|
|
53
|
+
- `mempalace_list_drawers` — Browse drawers in a wing/room with pagination (`limit`, `offset`). Use for inventory/cleanup, not search.
|
|
54
|
+
- `mempalace_update_drawer` — Edit an existing drawer's content, wing, or room in place. Use to refine misfiled or stale entries instead of delete + re-add.
|
|
46
55
|
- `mempalace_delete_drawer` — Remove a drawer by ID.
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
|
|
57
|
+
**Diary:**
|
|
58
|
+
|
|
59
|
+
- `mempalace_diary_write` — Write a session diary entry (`agent_name`, `entry`, `topic`, optional `wing`).
|
|
60
|
+
- `mempalace_diary_read` — Read recent diary entries. Optional `wing` filter scopes by project.
|
|
61
|
+
|
|
62
|
+
**Maintenance:**
|
|
63
|
+
|
|
64
|
+
- `mempalace_memories_filed_away` — Check whether a recent checkpoint was saved (message count, timestamp). Useful for confirming a stop-hook flush happened.
|
|
65
|
+
- `mempalace_reconnect` — Force reconnect to the palace database. Run after external CLI/scripts modify the palace directly (the in-memory HNSW index can otherwise go stale).
|
|
66
|
+
- `mempalace_hook_settings` — Toggle silent-save / desktop-toast for the auto-save hooks.
|
|
49
67
|
|
|
50
68
|
### Tips
|
|
51
69
|
|
|
52
70
|
- Search is **semantic** (meaning-based), not keyword. "What did we discuss about database performance?" works better than "database".
|
|
53
71
|
- The knowledge graph stores typed relationships with **time windows**. It knows WHEN things were true.
|
|
54
72
|
- Use `mempalace_check_duplicate` before storing new content to avoid clutter.
|
|
73
|
+
- **Tunnels auto-form** when drawers across different wings share topics (mempalace 3.3.4+). You don't have to wire connections by hand most of the time — but `mempalace_create_tunnel` is there when the auto-detector misses something obvious, and `mempalace_delete_tunnel` is there when it overreaches.
|
|
74
|
+
- After updating facts via the CLI / external scripts, call `mempalace_reconnect` so the live MCP server picks up the changes.
|
|
55
75
|
- Diary entries accumulate across sessions. Write them to build continuity of self.
|
|
56
76
|
- Entity detection runs per-language; results include `created_at` timestamps you can surface when the user asks "when did I last…".
|
|
57
77
|
|
|
@@ -71,10 +71,11 @@ describe("log", () => {
|
|
|
71
71
|
);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("includes Error message in context", () => {
|
|
75
|
-
|
|
74
|
+
it("includes Error message and stack in context", () => {
|
|
75
|
+
const err = new Error("timeout");
|
|
76
|
+
logError("bridge", "request failed", err);
|
|
76
77
|
expect(mockError).toHaveBeenCalledWith(
|
|
77
|
-
{ component: "bridge", err: "timeout" },
|
|
78
|
+
{ component: "bridge", err: "timeout", stack: err.stack },
|
|
78
79
|
"request failed",
|
|
79
80
|
);
|
|
80
81
|
});
|
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional tests for tool schemas + the Telegram action handler.
|
|
3
|
+
*
|
|
4
|
+
* Covers two wiring layers that have historically broken silently:
|
|
5
|
+
*
|
|
6
|
+
* 1. Tool definition → bridge call
|
|
7
|
+
* `tool.execute(parsedParams, bridge)` must call the bridge
|
|
8
|
+
* with the correct action name and the correct params shape.
|
|
9
|
+
* Catches: action-name typos, missing param forwarding, and
|
|
10
|
+
* multiplexed dispatch (e.g. `send` → 13 different bridge
|
|
11
|
+
* actions depending on `type`).
|
|
12
|
+
*
|
|
13
|
+
* 2. Bridge → Telegram Bot API
|
|
14
|
+
* `createTelegramActionHandler(...)` translates a bridge
|
|
15
|
+
* action body into the right grammy `bot.api.*` call with
|
|
16
|
+
* the right arguments. Catches: drift between the bridge
|
|
17
|
+
* action name produced by a tool and the case label in the
|
|
18
|
+
* handler switch, or wrong arg ordering.
|
|
19
|
+
*
|
|
20
|
+
* No real bot, no real network, no spawned processes. Pure
|
|
21
|
+
* in-process round trips with vi.fn() spies.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { ALL_TOOLS } from "../core/tools/index.js";
|
|
27
|
+
import type { ToolDefinition } from "../core/tools/types.js";
|
|
28
|
+
|
|
29
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function getTool(name: string): ToolDefinition {
|
|
32
|
+
const tool = ALL_TOOLS.find((t) => t.name === name);
|
|
33
|
+
if (!tool) throw new Error(`tool ${name} not found in ALL_TOOLS`);
|
|
34
|
+
return tool;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run the tool's zod schema over `raw` and return the parsed/coerced output. */
|
|
38
|
+
function parseSchema(
|
|
39
|
+
tool: ToolDefinition,
|
|
40
|
+
raw: Record<string, unknown>,
|
|
41
|
+
): Record<string, unknown> {
|
|
42
|
+
const obj = z.object(tool.schema as Record<string, z.ZodTypeAny>);
|
|
43
|
+
return obj.parse(raw) as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build a vi-mocked bridge that records every call and returns ok. */
|
|
47
|
+
function makeBridge() {
|
|
48
|
+
return vi.fn(async (_action: string, _params: Record<string, unknown>) => ({
|
|
49
|
+
ok: true,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
// Part A — Tool definition → bridge call
|
|
55
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
|
|
57
|
+
describe("Tool → bridge round-trip", () => {
|
|
58
|
+
// ── Single-action tools (1:1 with a bridge action name) ──────────────────
|
|
59
|
+
describe("single-action telegram tools", () => {
|
|
60
|
+
const cases: Array<{
|
|
61
|
+
tool: string;
|
|
62
|
+
params: Record<string, unknown>;
|
|
63
|
+
bridgeAction: string;
|
|
64
|
+
expectedParams?: Record<string, unknown>;
|
|
65
|
+
}> = [
|
|
66
|
+
{
|
|
67
|
+
tool: "react",
|
|
68
|
+
params: { message_id: 2081, emoji: "❤️" },
|
|
69
|
+
bridgeAction: "react",
|
|
70
|
+
expectedParams: { message_id: 2081, emoji: "❤️" },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
tool: "edit_message",
|
|
74
|
+
params: { message_id: 2081, text: "edited" },
|
|
75
|
+
bridgeAction: "edit_message",
|
|
76
|
+
expectedParams: { message_id: 2081, text: "edited" },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
tool: "delete_message",
|
|
80
|
+
params: { message_id: 2081 },
|
|
81
|
+
bridgeAction: "delete_message",
|
|
82
|
+
expectedParams: { message_id: 2081 },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
tool: "forward_message",
|
|
86
|
+
params: { message_id: 2081 },
|
|
87
|
+
bridgeAction: "forward_message",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
tool: "pin_message",
|
|
91
|
+
params: { message_id: 2081 },
|
|
92
|
+
bridgeAction: "pin_message",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
tool: "unpin_message",
|
|
96
|
+
params: { message_id: 2081 },
|
|
97
|
+
bridgeAction: "unpin_message",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
tool: "stop_poll",
|
|
101
|
+
params: { message_id: 2081 },
|
|
102
|
+
bridgeAction: "stop_poll",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
tool: "get_member_info",
|
|
106
|
+
params: { user_id: 352042062 },
|
|
107
|
+
bridgeAction: "get_member_info",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
tool: "get_message_by_id",
|
|
111
|
+
params: { message_id: 2081 },
|
|
112
|
+
bridgeAction: "get_message_by_id",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
tool: "download_media",
|
|
116
|
+
params: { message_id: 2081 },
|
|
117
|
+
bridgeAction: "download_media",
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
for (const c of cases) {
|
|
122
|
+
it(`${c.tool} → bridge("${c.bridgeAction}")`, async () => {
|
|
123
|
+
const tool = getTool(c.tool);
|
|
124
|
+
const bridge = makeBridge();
|
|
125
|
+
const parsed = parseSchema(tool, c.params);
|
|
126
|
+
|
|
127
|
+
await tool.execute(parsed, bridge);
|
|
128
|
+
|
|
129
|
+
expect(bridge).toHaveBeenCalledTimes(1);
|
|
130
|
+
const [action, params] = bridge.mock.calls[0]!;
|
|
131
|
+
expect(action).toBe(c.bridgeAction);
|
|
132
|
+
if (c.expectedParams) {
|
|
133
|
+
expect(params).toEqual(c.expectedParams);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── send tool dispatches to many bridge actions ──────────────────────────
|
|
140
|
+
describe("send tool dispatches by type", () => {
|
|
141
|
+
const cases: Array<{
|
|
142
|
+
label: string;
|
|
143
|
+
params: Record<string, unknown>;
|
|
144
|
+
bridgeAction: string;
|
|
145
|
+
paramShape?: (p: Record<string, unknown>) => void;
|
|
146
|
+
}> = [
|
|
147
|
+
{
|
|
148
|
+
label: "text",
|
|
149
|
+
params: { type: "text", text: "hello" },
|
|
150
|
+
bridgeAction: "send_message",
|
|
151
|
+
paramShape: (p) => {
|
|
152
|
+
expect(p.text).toBe("hello");
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
label: "text with reply_to",
|
|
157
|
+
params: { type: "text", text: "ok", reply_to: 2081 },
|
|
158
|
+
bridgeAction: "send_message",
|
|
159
|
+
paramShape: (p) => {
|
|
160
|
+
expect(p.reply_to_message_id).toBe(2081);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
label: "text with buttons",
|
|
165
|
+
params: {
|
|
166
|
+
type: "text",
|
|
167
|
+
text: "choose",
|
|
168
|
+
buttons: [[{ text: "A", callback_data: "a" }]],
|
|
169
|
+
},
|
|
170
|
+
bridgeAction: "send_message_with_buttons",
|
|
171
|
+
paramShape: (p) => {
|
|
172
|
+
expect((p.rows as unknown[]).length).toBe(1);
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
label: "delayed text → schedule_message",
|
|
177
|
+
params: { type: "text", text: "later", delay_seconds: 60 },
|
|
178
|
+
bridgeAction: "schedule_message",
|
|
179
|
+
paramShape: (p) => {
|
|
180
|
+
expect(p.delay_seconds).toBe(60);
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
label: "photo",
|
|
185
|
+
params: { type: "photo", file_path: "/tmp/x.jpg", caption: "hi" },
|
|
186
|
+
bridgeAction: "send_photo",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
label: "file",
|
|
190
|
+
params: { type: "file", file_path: "/tmp/x.pdf" },
|
|
191
|
+
bridgeAction: "send_file",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
label: "video",
|
|
195
|
+
params: { type: "video", file_path: "/tmp/x.mp4" },
|
|
196
|
+
bridgeAction: "send_video",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
label: "voice",
|
|
200
|
+
params: { type: "voice", file_path: "/tmp/x.ogg" },
|
|
201
|
+
bridgeAction: "send_voice",
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
label: "audio",
|
|
205
|
+
params: {
|
|
206
|
+
type: "audio",
|
|
207
|
+
file_path: "/tmp/song.mp3",
|
|
208
|
+
title: "T",
|
|
209
|
+
performer: "P",
|
|
210
|
+
},
|
|
211
|
+
bridgeAction: "send_audio",
|
|
212
|
+
paramShape: (p) => {
|
|
213
|
+
expect(p.title).toBe("T");
|
|
214
|
+
expect(p.performer).toBe("P");
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
label: "animation",
|
|
219
|
+
params: { type: "animation", file_path: "/tmp/x.gif" },
|
|
220
|
+
bridgeAction: "send_animation",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
label: "sticker",
|
|
224
|
+
params: { type: "sticker", file_id: "CAACAgI..." },
|
|
225
|
+
bridgeAction: "send_sticker",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
label: "poll",
|
|
229
|
+
params: {
|
|
230
|
+
type: "poll",
|
|
231
|
+
question: "Best?",
|
|
232
|
+
options: ["A", "B"],
|
|
233
|
+
},
|
|
234
|
+
bridgeAction: "send_poll",
|
|
235
|
+
paramShape: (p) => {
|
|
236
|
+
expect(p.question).toBe("Best?");
|
|
237
|
+
expect(p.type).toBe("regular");
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
label: "poll with correct_option_id → quiz",
|
|
242
|
+
params: {
|
|
243
|
+
type: "poll",
|
|
244
|
+
question: "Q?",
|
|
245
|
+
options: ["A", "B"],
|
|
246
|
+
correct_option_id: 1,
|
|
247
|
+
},
|
|
248
|
+
bridgeAction: "send_poll",
|
|
249
|
+
paramShape: (p) => {
|
|
250
|
+
expect(p.type).toBe("quiz");
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
label: "location",
|
|
255
|
+
params: { type: "location", latitude: 53.15, longitude: -6.07 },
|
|
256
|
+
bridgeAction: "send_location",
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
label: "contact",
|
|
260
|
+
params: {
|
|
261
|
+
type: "contact",
|
|
262
|
+
phone_number: "+1234",
|
|
263
|
+
first_name: "Test",
|
|
264
|
+
},
|
|
265
|
+
bridgeAction: "send_contact",
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
label: "dice",
|
|
269
|
+
params: { type: "dice" },
|
|
270
|
+
bridgeAction: "send_dice",
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
for (const c of cases) {
|
|
275
|
+
it(`send(${c.label}) → bridge("${c.bridgeAction}")`, async () => {
|
|
276
|
+
const tool = getTool("send");
|
|
277
|
+
const bridge = makeBridge();
|
|
278
|
+
const parsed = parseSchema(tool, c.params);
|
|
279
|
+
|
|
280
|
+
await tool.execute(parsed, bridge);
|
|
281
|
+
|
|
282
|
+
expect(bridge).toHaveBeenCalledTimes(1);
|
|
283
|
+
const [action, params] = bridge.mock.calls[0]!;
|
|
284
|
+
expect(action).toBe(c.bridgeAction);
|
|
285
|
+
if (c.paramShape) c.paramShape(params as Record<string, unknown>);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ── Coercion goes end-to-end through execute() ───────────────────────────
|
|
291
|
+
describe("ID coercion survives the schema → execute pipeline", () => {
|
|
292
|
+
it("react with stringified message_id arrives at bridge as a number", async () => {
|
|
293
|
+
const tool = getTool("react");
|
|
294
|
+
const bridge = makeBridge();
|
|
295
|
+
const parsed = parseSchema(tool, {
|
|
296
|
+
message_id: "2081",
|
|
297
|
+
emoji: "❤️",
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await tool.execute(parsed, bridge);
|
|
301
|
+
|
|
302
|
+
const [, params] = bridge.mock.calls[0]!;
|
|
303
|
+
expect((params as { message_id: unknown }).message_id).toBe(2081);
|
|
304
|
+
expect(typeof (params as { message_id: unknown }).message_id).toBe(
|
|
305
|
+
"number",
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("send.reply_to with stringified value survives to send_message", async () => {
|
|
310
|
+
const tool = getTool("send");
|
|
311
|
+
const bridge = makeBridge();
|
|
312
|
+
const parsed = parseSchema(tool, {
|
|
313
|
+
type: "text",
|
|
314
|
+
text: "hi",
|
|
315
|
+
reply_to: "2081",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await tool.execute(parsed, bridge);
|
|
319
|
+
|
|
320
|
+
const [, params] = bridge.mock.calls[0]!;
|
|
321
|
+
expect(
|
|
322
|
+
(params as { reply_to_message_id: unknown }).reply_to_message_id,
|
|
323
|
+
).toBe(2081);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("get_member_info with stringified user_id arrives as number", async () => {
|
|
327
|
+
const tool = getTool("get_member_info");
|
|
328
|
+
const bridge = makeBridge();
|
|
329
|
+
const parsed = parseSchema(tool, { user_id: "352042062" });
|
|
330
|
+
|
|
331
|
+
await tool.execute(parsed, bridge);
|
|
332
|
+
|
|
333
|
+
const [, params] = bridge.mock.calls[0]!;
|
|
334
|
+
expect((params as { user_id: unknown }).user_id).toBe(352042062);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
340
|
+
// Part B — Telegram action handler → grammy Bot API
|
|
341
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
342
|
+
|
|
343
|
+
import {
|
|
344
|
+
createTelegramActionHandler,
|
|
345
|
+
// re-export for test side-only — handler depends on the InputFile constructor
|
|
346
|
+
} from "../frontend/telegram/actions.js";
|
|
347
|
+
import type { Bot } from "grammy";
|
|
348
|
+
import type { Gateway } from "../core/gateway.js";
|
|
349
|
+
|
|
350
|
+
interface BotApiSpy {
|
|
351
|
+
setMessageReaction: ReturnType<typeof vi.fn>;
|
|
352
|
+
editMessageText: ReturnType<typeof vi.fn>;
|
|
353
|
+
deleteMessage: ReturnType<typeof vi.fn>;
|
|
354
|
+
pinChatMessage: ReturnType<typeof vi.fn>;
|
|
355
|
+
unpinChatMessage: ReturnType<typeof vi.fn>;
|
|
356
|
+
forwardMessage: ReturnType<typeof vi.fn>;
|
|
357
|
+
copyMessage: ReturnType<typeof vi.fn>;
|
|
358
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
359
|
+
sendChatAction: ReturnType<typeof vi.fn>;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function makeBotSpy(): { bot: Bot; api: BotApiSpy } {
|
|
363
|
+
const api: BotApiSpy = {
|
|
364
|
+
setMessageReaction: vi.fn(async () => true),
|
|
365
|
+
editMessageText: vi.fn(async () => ({ message_id: 1 })),
|
|
366
|
+
deleteMessage: vi.fn(async () => true),
|
|
367
|
+
pinChatMessage: vi.fn(async () => true),
|
|
368
|
+
unpinChatMessage: vi.fn(async () => true),
|
|
369
|
+
forwardMessage: vi.fn(async () => ({ message_id: 999 })),
|
|
370
|
+
copyMessage: vi.fn(async () => ({ message_id: 1000 })),
|
|
371
|
+
sendMessage: vi.fn(async () => ({ message_id: 1001 })),
|
|
372
|
+
sendChatAction: vi.fn(async () => true),
|
|
373
|
+
};
|
|
374
|
+
const bot = { api } as unknown as Bot;
|
|
375
|
+
return { bot, api };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function makeGateway(): Gateway {
|
|
379
|
+
return {
|
|
380
|
+
incrementMessages: vi.fn(),
|
|
381
|
+
incrementErrors: vi.fn(),
|
|
382
|
+
incrementRetries: vi.fn(),
|
|
383
|
+
incrementSuccess: vi.fn(),
|
|
384
|
+
} as unknown as Gateway;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
class StubInputFile {
|
|
388
|
+
// Match the grammy InputFile shape just enough for `new InputFileClass(...)`.
|
|
389
|
+
data: unknown;
|
|
390
|
+
filename: string;
|
|
391
|
+
constructor(data: unknown, filename: string) {
|
|
392
|
+
this.data = data;
|
|
393
|
+
this.filename = filename;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
describe("createTelegramActionHandler", () => {
|
|
398
|
+
let bot: Bot;
|
|
399
|
+
let api: BotApiSpy;
|
|
400
|
+
let gateway: Gateway;
|
|
401
|
+
let handler: ReturnType<typeof createTelegramActionHandler>;
|
|
402
|
+
const chatId = 12345;
|
|
403
|
+
|
|
404
|
+
beforeEach(() => {
|
|
405
|
+
const spy = makeBotSpy();
|
|
406
|
+
bot = spy.bot;
|
|
407
|
+
api = spy.api;
|
|
408
|
+
gateway = makeGateway();
|
|
409
|
+
handler = createTelegramActionHandler(
|
|
410
|
+
bot,
|
|
411
|
+
StubInputFile as unknown as typeof import("grammy").InputFile,
|
|
412
|
+
"fake-token",
|
|
413
|
+
gateway,
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("react → bot.api.setMessageReaction with chatId, message_id, emoji", async () => {
|
|
418
|
+
const result = await handler(
|
|
419
|
+
{ action: "react", message_id: 2081, emoji: "❤️" },
|
|
420
|
+
chatId,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
expect(api.setMessageReaction).toHaveBeenCalledTimes(1);
|
|
424
|
+
expect(api.setMessageReaction).toHaveBeenCalledWith(chatId, 2081, [
|
|
425
|
+
{ type: "emoji", emoji: "❤️" },
|
|
426
|
+
]);
|
|
427
|
+
expect(result).toEqual({ ok: true });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("react with stringified message_id (post-coercion) still calls bot.api correctly", async () => {
|
|
431
|
+
// Even if something upstream skipped coercion and delivered a string,
|
|
432
|
+
// the handler does Number(body.message_id) and recovers.
|
|
433
|
+
await handler({ action: "react", message_id: "2081", emoji: "🔥" }, chatId);
|
|
434
|
+
|
|
435
|
+
expect(api.setMessageReaction).toHaveBeenCalledWith(chatId, 2081, [
|
|
436
|
+
{ type: "emoji", emoji: "🔥" },
|
|
437
|
+
]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("react falls back to 👍 if custom emoji rejected, and reports ok", async () => {
|
|
441
|
+
const seen: string[] = [];
|
|
442
|
+
api.setMessageReaction.mockImplementation(
|
|
443
|
+
async (
|
|
444
|
+
_chatId: number,
|
|
445
|
+
_msgId: number,
|
|
446
|
+
reactions: Array<{ type: string; emoji: string }>,
|
|
447
|
+
) => {
|
|
448
|
+
seen.push(reactions[0]!.emoji);
|
|
449
|
+
if (reactions[0]!.emoji === "🦄") {
|
|
450
|
+
throw new Error("REACTION_INVALID");
|
|
451
|
+
}
|
|
452
|
+
return true;
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const result = await handler(
|
|
457
|
+
{ action: "react", message_id: 1, emoji: "🦄" },
|
|
458
|
+
chatId,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
expect(api.setMessageReaction).toHaveBeenCalledTimes(2);
|
|
462
|
+
expect(seen).toEqual(["🦄", "👍"]);
|
|
463
|
+
expect(result).toEqual({ ok: true });
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("delete_message → bot.api.deleteMessage(chatId, message_id)", async () => {
|
|
467
|
+
const result = await handler(
|
|
468
|
+
{ action: "delete_message", message_id: 2081 },
|
|
469
|
+
chatId,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
expect(api.deleteMessage).toHaveBeenCalledWith(chatId, 2081);
|
|
473
|
+
expect(result).toEqual({ ok: true });
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("pin_message → bot.api.pinChatMessage(chatId, message_id)", async () => {
|
|
477
|
+
await handler({ action: "pin_message", message_id: 2081 }, chatId);
|
|
478
|
+
expect(api.pinChatMessage).toHaveBeenCalledWith(chatId, 2081);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("unpin_message → bot.api.unpinChatMessage(chatId, message_id?)", async () => {
|
|
482
|
+
await handler({ action: "unpin_message", message_id: 2081 }, chatId);
|
|
483
|
+
expect(api.unpinChatMessage).toHaveBeenCalledWith(chatId, 2081);
|
|
484
|
+
|
|
485
|
+
api.unpinChatMessage.mockClear();
|
|
486
|
+
await handler({ action: "unpin_message" }, chatId);
|
|
487
|
+
expect(api.unpinChatMessage).toHaveBeenCalledWith(chatId, undefined);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("forward_message → bot.api.forwardMessage(chatId, chatId, message_id)", async () => {
|
|
491
|
+
const result = await handler(
|
|
492
|
+
{ action: "forward_message", message_id: 2081 },
|
|
493
|
+
chatId,
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expect(api.forwardMessage).toHaveBeenCalledWith(chatId, chatId, 2081);
|
|
497
|
+
expect(result).toEqual({ ok: true, message_id: 999 });
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("forward_message rejects cross-chat targets", async () => {
|
|
501
|
+
const result = await handler(
|
|
502
|
+
{ action: "forward_message", message_id: 2081, to_chat_id: 99999 },
|
|
503
|
+
chatId,
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
expect(result).toEqual({
|
|
507
|
+
ok: false,
|
|
508
|
+
error: "Cross-chat forwarding not allowed.",
|
|
509
|
+
});
|
|
510
|
+
expect(api.forwardMessage).not.toHaveBeenCalled();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("copy_message → bot.api.copyMessage(chatId, chatId, message_id)", async () => {
|
|
514
|
+
const result = await handler(
|
|
515
|
+
{ action: "copy_message", message_id: 2081 },
|
|
516
|
+
chatId,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
expect(api.copyMessage).toHaveBeenCalledWith(chatId, chatId, 2081);
|
|
520
|
+
expect(result).toEqual({ ok: true, message_id: 1000 });
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("edit_message → bot.api.editMessageText(chatId, message_id, html, opts)", async () => {
|
|
524
|
+
await handler(
|
|
525
|
+
{ action: "edit_message", message_id: 2081, text: "**bold**" },
|
|
526
|
+
chatId,
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
expect(api.editMessageText).toHaveBeenCalledTimes(1);
|
|
530
|
+
const call = api.editMessageText.mock.calls[0]!;
|
|
531
|
+
expect(call[0]).toBe(chatId);
|
|
532
|
+
expect(call[1]).toBe(2081);
|
|
533
|
+
expect(typeof call[2]).toBe("string");
|
|
534
|
+
expect(call[3]).toEqual({ parse_mode: "HTML" });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("edit_message rejects text > TELEGRAM_MAX_TEXT (4096)", async () => {
|
|
538
|
+
const longText = "x".repeat(5000);
|
|
539
|
+
const result = await handler(
|
|
540
|
+
{ action: "edit_message", message_id: 2081, text: longText },
|
|
541
|
+
chatId,
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
expect(result).toEqual({
|
|
545
|
+
ok: false,
|
|
546
|
+
error: "Text too long (max 4096)",
|
|
547
|
+
});
|
|
548
|
+
expect(api.editMessageText).not.toHaveBeenCalled();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("send_chat_action → bot.api.sendChatAction(chatId, action_str)", async () => {
|
|
552
|
+
await handler(
|
|
553
|
+
{ action: "send_chat_action", chat_action: "typing" },
|
|
554
|
+
chatId,
|
|
555
|
+
);
|
|
556
|
+
expect(api.sendChatAction).toHaveBeenCalledWith(chatId, "typing");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("send_message → withRetry → bot.api.sendMessage with HTML and reply_to", async () => {
|
|
560
|
+
const result = await handler(
|
|
561
|
+
{
|
|
562
|
+
action: "send_message",
|
|
563
|
+
text: "hello",
|
|
564
|
+
reply_to_message_id: 2081,
|
|
565
|
+
},
|
|
566
|
+
chatId,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
|
570
|
+
expect(gateway.incrementMessages).toHaveBeenCalledWith(chatId);
|
|
571
|
+
const call = api.sendMessage.mock.calls[0]!;
|
|
572
|
+
expect(call[0]).toBe(chatId);
|
|
573
|
+
expect(typeof call[1]).toBe("string");
|
|
574
|
+
expect(call[2]).toMatchObject({
|
|
575
|
+
reply_parameters: { message_id: 2081 },
|
|
576
|
+
parse_mode: "HTML",
|
|
577
|
+
});
|
|
578
|
+
expect(result).toEqual({ ok: true, message_id: 1001 });
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("schedule_message returns a schedule id and keeps a timer", async () => {
|
|
582
|
+
vi.useFakeTimers();
|
|
583
|
+
try {
|
|
584
|
+
const result = (await handler(
|
|
585
|
+
{
|
|
586
|
+
action: "schedule_message",
|
|
587
|
+
text: "later",
|
|
588
|
+
delay_seconds: 5,
|
|
589
|
+
},
|
|
590
|
+
chatId,
|
|
591
|
+
)) as { ok: true; schedule_id: string; delay_seconds: number };
|
|
592
|
+
|
|
593
|
+
expect(result.ok).toBe(true);
|
|
594
|
+
expect(result.delay_seconds).toBe(5);
|
|
595
|
+
expect(typeof result.schedule_id).toBe("string");
|
|
596
|
+
|
|
597
|
+
// Cancel before it fires so we don't leak a real send.
|
|
598
|
+
const cancel = await handler(
|
|
599
|
+
{ action: "cancel_scheduled", schedule_id: result.schedule_id },
|
|
600
|
+
chatId,
|
|
601
|
+
);
|
|
602
|
+
expect(cancel).toEqual({ ok: true, cancelled: true });
|
|
603
|
+
} finally {
|
|
604
|
+
vi.useRealTimers();
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("cancel_scheduled with unknown id returns ok:false", async () => {
|
|
609
|
+
const result = await handler(
|
|
610
|
+
{ action: "cancel_scheduled", schedule_id: "nonexistent" },
|
|
611
|
+
chatId,
|
|
612
|
+
);
|
|
613
|
+
expect(result).toEqual({ ok: false, error: "Schedule not found" });
|
|
614
|
+
});
|
|
615
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression + audit tests — ID-shaped tool params use the strict
|
|
3
|
+
* shared `idSchema` from `core/tools/schemas.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Background: some MCP transport / model paths deliver `message_id`,
|
|
6
|
+
* `user_id`, `reply_to`, `offset_id` as JSON strings ("2081") rather
|
|
7
|
+
* than numbers (2081). Plain `z.number()` rejects those with
|
|
8
|
+
* `expected number, received string`, which manifested as
|
|
9
|
+
* `react`/`pin_message`/`get_member_info`/`download_media` failing
|
|
10
|
+
* out of nowhere even when the model formatted the call correctly
|
|
11
|
+
* for a number-typed JSON Schema field.
|
|
12
|
+
*
|
|
13
|
+
* Initial fix used `z.coerce.number().int()`, but per Copilot review
|
|
14
|
+
* that was too lax — `""`/`null` coerce to 0 and `true` to 1, which
|
|
15
|
+
* then pass `.int()` and reach the bot API. The current `idSchema` is
|
|
16
|
+
* a union: `z.number().int().positive()` OR a digit-only string that
|
|
17
|
+
* gets transformed to a positive integer. Everything else is rejected.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect } from "vitest";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { ALL_TOOLS } from "../core/tools/index.js";
|
|
22
|
+
|
|
23
|
+
const ID_FIELD_NAMES = new Set([
|
|
24
|
+
"message_id",
|
|
25
|
+
"user_id",
|
|
26
|
+
"reply_to",
|
|
27
|
+
"offset_id",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function getIdSchema(toolName: string, field: string): z.ZodTypeAny {
|
|
31
|
+
const tool = ALL_TOOLS.find((t) => t.name === toolName);
|
|
32
|
+
if (!tool) throw new Error(`tool ${toolName} not found`);
|
|
33
|
+
const schema = (tool.schema as Record<string, z.ZodTypeAny>)[field];
|
|
34
|
+
if (!schema) throw new Error(`field ${field} not found on ${toolName}`);
|
|
35
|
+
return schema;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("ID-shaped tool params accept stringified numbers", () => {
|
|
39
|
+
// Spot-check a representative set across all four files.
|
|
40
|
+
const cases: Array<[string, string]> = [
|
|
41
|
+
["react", "message_id"],
|
|
42
|
+
["edit_message", "message_id"],
|
|
43
|
+
["delete_message", "message_id"],
|
|
44
|
+
["forward_message", "message_id"],
|
|
45
|
+
["pin_message", "message_id"],
|
|
46
|
+
["unpin_message", "message_id"],
|
|
47
|
+
["stop_poll", "message_id"],
|
|
48
|
+
["send", "reply_to"],
|
|
49
|
+
["get_member_info", "user_id"],
|
|
50
|
+
["create_sticker_set", "user_id"],
|
|
51
|
+
["add_sticker_to_set", "user_id"],
|
|
52
|
+
["read_chat_history", "offset_id"],
|
|
53
|
+
["get_message_by_id", "message_id"],
|
|
54
|
+
["download_media", "message_id"],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
for (const [tool, field] of cases) {
|
|
58
|
+
it(`${tool}.${field} accepts a number`, () => {
|
|
59
|
+
const s = getIdSchema(tool, field);
|
|
60
|
+
const out = s.parse(2081);
|
|
61
|
+
expect(out).toBe(2081);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it(`${tool}.${field} accepts a numeric string and coerces it`, () => {
|
|
65
|
+
const s = getIdSchema(tool, field);
|
|
66
|
+
const out = s.parse("2081");
|
|
67
|
+
expect(out).toBe(2081);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it(`${tool}.${field} rejects a non-numeric string`, () => {
|
|
71
|
+
const s = getIdSchema(tool, field);
|
|
72
|
+
expect(() => s.parse("abc")).toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it(`${tool}.${field} rejects a non-integer`, () => {
|
|
76
|
+
const s = getIdSchema(tool, field);
|
|
77
|
+
expect(() => s.parse(1.5)).toThrow();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("Audit: every ID-shaped field in tool schemas is the strict idSchema", () => {
|
|
83
|
+
/**
|
|
84
|
+
* For each ID field on every tool, this test asserts the full
|
|
85
|
+
* accept/reject contract of `idSchema` — not just "accepts a
|
|
86
|
+
* string." That guards against a future replacement that passes
|
|
87
|
+
* the loose check (`safeParse("123")` succeeds) but doesn't
|
|
88
|
+
* actually return a positive integer (e.g. `z.string()` would
|
|
89
|
+
* accept "123" and return the string "123").
|
|
90
|
+
*/
|
|
91
|
+
for (const tool of ALL_TOOLS) {
|
|
92
|
+
const schema = tool.schema as Record<string, unknown>;
|
|
93
|
+
for (const field of Object.keys(schema)) {
|
|
94
|
+
if (!ID_FIELD_NAMES.has(field)) continue;
|
|
95
|
+
const zSchema = schema[field] as z.ZodTypeAny;
|
|
96
|
+
|
|
97
|
+
it(`${tool.name}.${field}: accepts a positive integer number`, () => {
|
|
98
|
+
const r = zSchema.safeParse(2081);
|
|
99
|
+
expect(r.success).toBe(true);
|
|
100
|
+
if (r.success) expect(r.data).toBe(2081);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it(`${tool.name}.${field}: accepts a digit string and returns a number`, () => {
|
|
104
|
+
const r = zSchema.safeParse("2081");
|
|
105
|
+
expect(r.success).toBe(true);
|
|
106
|
+
if (r.success) {
|
|
107
|
+
expect(typeof r.data).toBe("number");
|
|
108
|
+
expect(Number.isInteger(r.data as number)).toBe(true);
|
|
109
|
+
expect(r.data).toBe(2081);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Reject the inputs that bare `z.coerce.number().int()` was too
|
|
114
|
+
// permissive about — empty/whitespace/null/booleans coerce to
|
|
115
|
+
// 0/0/0/1 and would pass `.int()`. The strict union rejects all.
|
|
116
|
+
const rejectCases: Array<[unknown, string]> = [
|
|
117
|
+
["", "empty string"],
|
|
118
|
+
[" ", "whitespace string"],
|
|
119
|
+
["abc", "non-numeric string"],
|
|
120
|
+
["2081abc", "mixed string"],
|
|
121
|
+
[null, "null"],
|
|
122
|
+
[true, "true"],
|
|
123
|
+
[false, "false"],
|
|
124
|
+
[0, "zero"],
|
|
125
|
+
[-1, "negative integer"],
|
|
126
|
+
[1.5, "non-integer"],
|
|
127
|
+
];
|
|
128
|
+
for (const [bad, label] of rejectCases) {
|
|
129
|
+
it(`${tool.name}.${field}: rejects ${label}`, () => {
|
|
130
|
+
const r = zSchema.safeParse(bad);
|
|
131
|
+
expect(r.success).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
@@ -227,10 +227,9 @@ export async function handleMessage(
|
|
|
227
227
|
// ── Build result ──────────────────────────────────────────────────────────
|
|
228
228
|
|
|
229
229
|
state.allResponseText += state.currentBlockText;
|
|
230
|
-
const
|
|
231
|
-
state.sdkInputTokens + state.sdkCacheRead + state.sdkCacheWrite;
|
|
230
|
+
const cacheTotal = state.sdkInputTokens + state.sdkCacheRead;
|
|
232
231
|
const cacheHitPct =
|
|
233
|
-
|
|
232
|
+
cacheTotal > 0 ? Math.round((state.sdkCacheRead / cacheTotal) * 100) : 0;
|
|
234
233
|
|
|
235
234
|
log(
|
|
236
235
|
"agent",
|
package/src/core/gateway.ts
CHANGED
|
@@ -267,9 +267,19 @@ export class Gateway {
|
|
|
267
267
|
res.end(json);
|
|
268
268
|
} catch (err) {
|
|
269
269
|
if (res.headersSent) return;
|
|
270
|
-
|
|
270
|
+
// Log full error (incl. stack via logError's structured `stack`
|
|
271
|
+
// field) on the server; return a generic message to the client so
|
|
272
|
+
// we don't leak implementation details.
|
|
273
|
+
// CodeQL: js/stack-trace-exposure (alert #4).
|
|
274
|
+
logError(
|
|
275
|
+
"gateway",
|
|
276
|
+
`Unhandled error on ${req.method} ${req.url}`,
|
|
277
|
+
err,
|
|
278
|
+
);
|
|
271
279
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
272
|
-
res.end(
|
|
280
|
+
res.end(
|
|
281
|
+
JSON.stringify({ ok: false, error: "Internal server error" }),
|
|
282
|
+
);
|
|
273
283
|
}
|
|
274
284
|
},
|
|
275
285
|
);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import type { ToolDefinition } from "./types.js";
|
|
7
|
+
import { idSchema } from "./schemas.js";
|
|
7
8
|
|
|
8
9
|
export const historyTools: ToolDefinition[] = [
|
|
9
10
|
{
|
|
@@ -19,7 +20,7 @@ export const historyTools: ToolDefinition[] = [
|
|
|
19
20
|
.string()
|
|
20
21
|
.optional()
|
|
21
22
|
.describe("Fetch messages before this date (ISO format)"),
|
|
22
|
-
offset_id:
|
|
23
|
+
offset_id: idSchema.optional().describe("Fetch before this message ID"),
|
|
23
24
|
},
|
|
24
25
|
execute: (params, bridge) =>
|
|
25
26
|
bridge("read_history", {
|
|
@@ -58,7 +59,7 @@ export const historyTools: ToolDefinition[] = [
|
|
|
58
59
|
{
|
|
59
60
|
name: "get_message_by_id",
|
|
60
61
|
description: "Get a specific message by ID.",
|
|
61
|
-
schema: { message_id:
|
|
62
|
+
schema: { message_id: idSchema },
|
|
62
63
|
execute: (params, bridge) => bridge("get_message_by_id", params),
|
|
63
64
|
frontends: ["telegram"],
|
|
64
65
|
tag: "history",
|
|
@@ -69,9 +70,9 @@ export const historyTools: ToolDefinition[] = [
|
|
|
69
70
|
description:
|
|
70
71
|
"Download a photo, document, or other media from a message by its ID. Saves the file to the workspace and returns the file path so you can read/analyze it. Use this when you see a [photo] or [document] in chat history but don't have the file.",
|
|
71
72
|
schema: {
|
|
72
|
-
message_id:
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
message_id: idSchema.describe(
|
|
74
|
+
"Message ID containing the media to download",
|
|
75
|
+
),
|
|
75
76
|
},
|
|
76
77
|
execute: (params, bridge) => bridge("download_media", params),
|
|
77
78
|
frontends: ["telegram"],
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import type { ToolDefinition } from "./types.js";
|
|
7
|
+
import { idSchema } from "./schemas.js";
|
|
7
8
|
|
|
8
9
|
export const memberTools: ToolDefinition[] = [
|
|
9
10
|
{
|
|
@@ -19,7 +20,7 @@ export const memberTools: ToolDefinition[] = [
|
|
|
19
20
|
{
|
|
20
21
|
name: "get_member_info",
|
|
21
22
|
description: "Get detailed info about a user by ID.",
|
|
22
|
-
schema: { user_id:
|
|
23
|
+
schema: { user_id: idSchema },
|
|
23
24
|
execute: (params, bridge) => bridge("get_member_info", params),
|
|
24
25
|
frontends: ["telegram"],
|
|
25
26
|
tag: "members",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import type { ToolDefinition } from "./types.js";
|
|
7
|
+
import { idSchema } from "./schemas.js";
|
|
7
8
|
|
|
8
9
|
export const messagingTools: ToolDefinition[] = [
|
|
9
10
|
// ── Telegram unified send ─────────────────────────────────────────────
|
|
@@ -43,7 +44,7 @@ Examples:
|
|
|
43
44
|
.string()
|
|
44
45
|
.optional()
|
|
45
46
|
.describe("Message text (for type=text). Supports Markdown."),
|
|
46
|
-
reply_to:
|
|
47
|
+
reply_to: idSchema.optional().describe("Message ID to reply to"),
|
|
47
48
|
file_path: z
|
|
48
49
|
.string()
|
|
49
50
|
.optional()
|
|
@@ -227,7 +228,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
227
228
|
description:
|
|
228
229
|
"Add an emoji reaction to a message. Valid: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉 🤩 🤮 💩 🙏 👌 🕊 🤡 🥱 🥴 😍 🐳 ❤🔥 🌚 🌭 💯 🤣 ⚡ 🍌 🏆 💔 🤨 😐 🍓 🍾 💋 🖕 😈 😴 😭 🤓 👻 👨💻 👀 🎃 🙈 😇 😨 🤝 ✍ 🤗 🫡 🎅 🎄 ☃ 💅 🤪 🗿 🆒 💘 🙉 🦄 😘 💊 🙊 😎 👾 🤷 🤷♂ 🤷♀ 😡",
|
|
229
230
|
schema: {
|
|
230
|
-
message_id:
|
|
231
|
+
message_id: idSchema.describe("Message ID"),
|
|
231
232
|
emoji: z.string().describe("Reaction emoji"),
|
|
232
233
|
},
|
|
233
234
|
execute: (params, bridge) => bridge("react", params),
|
|
@@ -239,7 +240,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
239
240
|
{
|
|
240
241
|
name: "edit_message",
|
|
241
242
|
description: "Edit a previously sent message.",
|
|
242
|
-
schema: { message_id:
|
|
243
|
+
schema: { message_id: idSchema, text: z.string() },
|
|
243
244
|
execute: (params, bridge) => bridge("edit_message", params),
|
|
244
245
|
frontends: ["telegram"],
|
|
245
246
|
tag: "messaging",
|
|
@@ -249,7 +250,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
249
250
|
{
|
|
250
251
|
name: "delete_message",
|
|
251
252
|
description: "Delete a message.",
|
|
252
|
-
schema: { message_id:
|
|
253
|
+
schema: { message_id: idSchema },
|
|
253
254
|
execute: (params, bridge) => bridge("delete_message", params),
|
|
254
255
|
frontends: ["telegram"],
|
|
255
256
|
tag: "messaging",
|
|
@@ -259,7 +260,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
259
260
|
{
|
|
260
261
|
name: "forward_message",
|
|
261
262
|
description: "Forward a message within the chat.",
|
|
262
|
-
schema: { message_id:
|
|
263
|
+
schema: { message_id: idSchema },
|
|
263
264
|
execute: (params, bridge) => bridge("forward_message", params),
|
|
264
265
|
frontends: ["telegram"],
|
|
265
266
|
tag: "messaging",
|
|
@@ -269,7 +270,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
269
270
|
{
|
|
270
271
|
name: "pin_message",
|
|
271
272
|
description: "Pin a message.",
|
|
272
|
-
schema: { message_id:
|
|
273
|
+
schema: { message_id: idSchema },
|
|
273
274
|
execute: (params, bridge) => bridge("pin_message", params),
|
|
274
275
|
frontends: ["telegram"],
|
|
275
276
|
tag: "messaging",
|
|
@@ -279,7 +280,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
279
280
|
{
|
|
280
281
|
name: "unpin_message",
|
|
281
282
|
description: "Unpin a message.",
|
|
282
|
-
schema: { message_id:
|
|
283
|
+
schema: { message_id: idSchema.optional() },
|
|
283
284
|
execute: (params, bridge) => bridge("unpin_message", params),
|
|
284
285
|
frontends: ["telegram"],
|
|
285
286
|
tag: "messaging",
|
|
@@ -291,7 +292,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
291
292
|
description:
|
|
292
293
|
"Stop an active poll and get the final results. Returns vote counts for each option.",
|
|
293
294
|
schema: {
|
|
294
|
-
message_id:
|
|
295
|
+
message_id: idSchema.describe("Message ID of the poll to stop"),
|
|
295
296
|
},
|
|
296
297
|
execute: (params, bridge) => bridge("stop_poll", params),
|
|
297
298
|
frontends: ["telegram"],
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared zod schema fragments for tool input definitions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Telegram-style positive integer ID (message_id, user_id, reply_to,
|
|
9
|
+
* offset_id, etc.).
|
|
10
|
+
*
|
|
11
|
+
* Accepts:
|
|
12
|
+
* - actual numbers that are positive integers (`2081`)
|
|
13
|
+
* - digit-only strings (`"2081"`) which are transformed into numbers
|
|
14
|
+
*
|
|
15
|
+
* Rejects:
|
|
16
|
+
* - non-numeric strings (`"abc"`, `"2081abc"`, `""`, `" "`)
|
|
17
|
+
* - booleans, `null`, `undefined` (would otherwise coerce to 0/1)
|
|
18
|
+
* - non-integer numbers (`1.5`)
|
|
19
|
+
* - zero and negatives
|
|
20
|
+
*
|
|
21
|
+
* Use this instead of `z.number()` or `z.coerce.number()` for any ID
|
|
22
|
+
* field on tool input schemas. The plain coercion path was too lax —
|
|
23
|
+
* `z.coerce.number().int()` happily turns `null`/`""` into `0` and
|
|
24
|
+
* `true` into `1`, both of which the Telegram bot API would then
|
|
25
|
+
* dispatch to. The strict union avoids that.
|
|
26
|
+
*/
|
|
27
|
+
export const idSchema = z.union([
|
|
28
|
+
z.number().int().positive(),
|
|
29
|
+
z
|
|
30
|
+
.string()
|
|
31
|
+
.regex(/^\d+$/, "must be a positive integer")
|
|
32
|
+
.transform((s) => Number(s))
|
|
33
|
+
.pipe(z.number().int().positive()),
|
|
34
|
+
]);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import type { ToolDefinition } from "./types.js";
|
|
7
|
+
import { idSchema } from "./schemas.js";
|
|
7
8
|
|
|
8
9
|
export const stickerTools: ToolDefinition[] = [
|
|
9
10
|
{
|
|
@@ -56,7 +57,7 @@ The set name will automatically get "_by_<botname>" appended if needed.
|
|
|
56
57
|
|
|
57
58
|
Example: create_sticker_set(user_id=123, name="cool_pack", title="Cool Stickers", file_path="/path/to/sticker.png", emoji_list=["😎"])`,
|
|
58
59
|
schema: {
|
|
59
|
-
user_id:
|
|
60
|
+
user_id: idSchema.describe("Telegram user ID who will own the pack"),
|
|
60
61
|
name: z
|
|
61
62
|
.string()
|
|
62
63
|
.describe(
|
|
@@ -85,7 +86,7 @@ Example: create_sticker_set(user_id=123, name="cool_pack", title="Cool Stickers"
|
|
|
85
86
|
description:
|
|
86
87
|
"Add a new sticker to an existing sticker pack created by the bot.",
|
|
87
88
|
schema: {
|
|
88
|
-
user_id:
|
|
89
|
+
user_id: idSchema.describe("Telegram user ID who owns the pack"),
|
|
89
90
|
name: z.string().describe("Sticker set name (including _by_<botname>)"),
|
|
90
91
|
file_path: z.string().describe("Path to the sticker image file"),
|
|
91
92
|
emoji_list: z
|
|
@@ -529,10 +529,9 @@ export function registerCommands(
|
|
|
529
529
|
"\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
|
|
530
530
|
const contextWarn = ctxPct >= 80 ? " \u26A0\uFE0F consider /reset" : "";
|
|
531
531
|
|
|
532
|
-
const
|
|
533
|
-
displayInputTokens + displayCacheRead + displayCacheWrite;
|
|
532
|
+
const cacheTotal = displayInputTokens + displayCacheRead;
|
|
534
533
|
const cacheHitPct =
|
|
535
|
-
|
|
534
|
+
cacheTotal > 0 ? Math.round((displayCacheRead / cacheTotal) * 100) : 0;
|
|
536
535
|
|
|
537
536
|
const avgResponseMs =
|
|
538
537
|
info.turns > 0 && u.totalResponseMs
|
package/src/util/log.ts
CHANGED
|
@@ -128,7 +128,10 @@ export function logError(
|
|
|
128
128
|
err?: unknown,
|
|
129
129
|
): void {
|
|
130
130
|
if (err instanceof Error) {
|
|
131
|
-
|
|
131
|
+
// Capture both the concise message (for log consumers that look at `err`)
|
|
132
|
+
// and the full stack (for diagnostics). pino-pretty renders the `stack`
|
|
133
|
+
// field on its own line; JSON consumers can read either field.
|
|
134
|
+
logger.error({ component, err: err.message, stack: err.stack }, message);
|
|
132
135
|
} else if (err !== undefined) {
|
|
133
136
|
logger.error({ component, err: String(err) }, message);
|
|
134
137
|
} else {
|