talon-agent 1.0.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/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../util/log.js", () => ({
|
|
4
|
+
log: vi.fn(),
|
|
5
|
+
logError: vi.fn(),
|
|
6
|
+
logWarn: vi.fn(),
|
|
7
|
+
logDebug: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("../core/gateway-actions.js", () => ({
|
|
11
|
+
handleSharedAction: vi.fn(async () => null),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { Gateway } from "../core/gateway.js";
|
|
15
|
+
|
|
16
|
+
describe("gateway per-chat context", () => {
|
|
17
|
+
let gateway: Gateway;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
gateway = new Gateway();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("sets context for a chat", () => {
|
|
24
|
+
gateway.setContext(12345);
|
|
25
|
+
expect(gateway.isChatBusy(12345)).toBe(true);
|
|
26
|
+
expect(gateway.getActiveChats()).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns false for unknown chat", () => {
|
|
30
|
+
expect(gateway.isChatBusy(99999)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("clears context on release", () => {
|
|
34
|
+
gateway.setContext(200);
|
|
35
|
+
gateway.clearContext(200);
|
|
36
|
+
expect(gateway.isChatBusy(200)).toBe(false);
|
|
37
|
+
expect(gateway.getActiveChats()).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ref counting: same chat twice needs two clears", () => {
|
|
41
|
+
gateway.setContext(300);
|
|
42
|
+
gateway.setContext(300); // ref++
|
|
43
|
+
|
|
44
|
+
gateway.clearContext(300);
|
|
45
|
+
expect(gateway.isChatBusy(300)).toBe(true); // still active
|
|
46
|
+
|
|
47
|
+
gateway.clearContext(300);
|
|
48
|
+
expect(gateway.isChatBusy(300)).toBe(false); // now released
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("multiple chats can be active simultaneously", () => {
|
|
52
|
+
gateway.setContext(100);
|
|
53
|
+
gateway.setContext(200);
|
|
54
|
+
gateway.setContext(300);
|
|
55
|
+
|
|
56
|
+
expect(gateway.isChatBusy(100)).toBe(true);
|
|
57
|
+
expect(gateway.isChatBusy(200)).toBe(true);
|
|
58
|
+
expect(gateway.isChatBusy(300)).toBe(true);
|
|
59
|
+
expect(gateway.getActiveChats()).toBe(3);
|
|
60
|
+
|
|
61
|
+
gateway.clearContext(200);
|
|
62
|
+
expect(gateway.isChatBusy(100)).toBe(true);
|
|
63
|
+
expect(gateway.isChatBusy(200)).toBe(false);
|
|
64
|
+
expect(gateway.isChatBusy(300)).toBe(true);
|
|
65
|
+
expect(gateway.getActiveChats()).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("message count is per-chat", () => {
|
|
69
|
+
gateway.setContext(500);
|
|
70
|
+
gateway.setContext(600);
|
|
71
|
+
|
|
72
|
+
expect(gateway.getMessageCount(500)).toBe(0);
|
|
73
|
+
expect(gateway.getMessageCount(600)).toBe(0);
|
|
74
|
+
|
|
75
|
+
gateway.incrementMessages(500);
|
|
76
|
+
gateway.incrementMessages(500);
|
|
77
|
+
gateway.incrementMessages(600);
|
|
78
|
+
|
|
79
|
+
expect(gateway.getMessageCount(500)).toBe(2);
|
|
80
|
+
expect(gateway.getMessageCount(600)).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("message count resets when context is released and re-acquired", () => {
|
|
84
|
+
gateway.setContext(700);
|
|
85
|
+
gateway.incrementMessages(700);
|
|
86
|
+
expect(gateway.getMessageCount(700)).toBe(1);
|
|
87
|
+
|
|
88
|
+
gateway.clearContext(700);
|
|
89
|
+
gateway.setContext(700);
|
|
90
|
+
expect(gateway.getMessageCount(700)).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("message count returns 0 for unknown chat", () => {
|
|
94
|
+
expect(gateway.getMessageCount(99999)).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("clearContext with no chatId does nothing", () => {
|
|
98
|
+
gateway.setContext(800);
|
|
99
|
+
gateway.clearContext();
|
|
100
|
+
expect(gateway.isChatBusy(800)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../util/log.js", () => ({
|
|
4
|
+
log: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock("../core/plugin.js", () => ({
|
|
8
|
+
handlePluginAction: vi.fn(async () => null),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../util/watchdog.js", () => ({
|
|
12
|
+
getHealthStatus: vi.fn(() => ({
|
|
13
|
+
healthy: true, totalMessagesProcessed: 0, recentErrorCount: 0, msSinceLastMessage: 0,
|
|
14
|
+
})),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("../storage/sessions.js", () => ({
|
|
18
|
+
getActiveSessionCount: vi.fn(() => 0),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("../core/dispatcher.js", () => ({
|
|
22
|
+
getActiveCount: vi.fn(() => 0),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Mock cron-store for shared actions
|
|
26
|
+
vi.mock("../storage/cron-store.js", () => ({
|
|
27
|
+
addCronJob: vi.fn(),
|
|
28
|
+
getCronJob: vi.fn(),
|
|
29
|
+
getCronJobsForChat: vi.fn(() => []),
|
|
30
|
+
updateCronJob: vi.fn(),
|
|
31
|
+
deleteCronJob: vi.fn(),
|
|
32
|
+
validateCronExpression: vi.fn(() => ({ valid: true, next: new Date().toISOString() })),
|
|
33
|
+
generateCronId: vi.fn(() => "test-id"),
|
|
34
|
+
loadCronJobs: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock("write-file-atomic", () => ({
|
|
38
|
+
default: { sync: vi.fn() },
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import { Gateway } from "../core/gateway.js";
|
|
42
|
+
|
|
43
|
+
let gateway: Gateway;
|
|
44
|
+
let port: number;
|
|
45
|
+
|
|
46
|
+
// Mock frontend handler
|
|
47
|
+
const mockFrontendHandler = vi.fn(async (body: Record<string, unknown>) => {
|
|
48
|
+
const action = body.action as string;
|
|
49
|
+
if (action === "send_message") return { ok: true, message_id: 42, text: "sent" };
|
|
50
|
+
if (action === "get_chat_info") return { ok: true, id: 123, type: "private" };
|
|
51
|
+
return null;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
gateway = new Gateway();
|
|
56
|
+
gateway.setFrontendHandler(mockFrontendHandler);
|
|
57
|
+
port = await gateway.start(19899); // test port
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await gateway.stop();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
// Clear contexts for known test chatIds
|
|
66
|
+
for (const id of [123, 999]) {
|
|
67
|
+
gateway.clearContext(id);
|
|
68
|
+
gateway.clearContext(id);
|
|
69
|
+
}
|
|
70
|
+
mockFrontendHandler.mockClear();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function post(body: Record<string, unknown>): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
74
|
+
const resp = await fetch(`http://127.0.0.1:${port}/action`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
});
|
|
79
|
+
return { status: resp.status, body: await resp.json() as Record<string, unknown> };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("gateway HTTP server", () => {
|
|
83
|
+
describe("health endpoint", () => {
|
|
84
|
+
it("returns health JSON", async () => {
|
|
85
|
+
const resp = await fetch(`http://127.0.0.1:${port}/health`);
|
|
86
|
+
expect(resp.status).toBe(200);
|
|
87
|
+
const data = await resp.json() as Record<string, unknown>;
|
|
88
|
+
expect(data.ok).toBeDefined();
|
|
89
|
+
expect(data.uptime).toBeDefined();
|
|
90
|
+
expect(data.memory).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("404 handling", () => {
|
|
95
|
+
it("returns 404 for unknown paths", async () => {
|
|
96
|
+
const resp = await fetch(`http://127.0.0.1:${port}/nonexistent`);
|
|
97
|
+
expect(resp.status).toBe(404);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns 404 for GET /action", async () => {
|
|
101
|
+
const resp = await fetch(`http://127.0.0.1:${port}/action`);
|
|
102
|
+
expect(resp.status).toBe(404);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("malformed requests", () => {
|
|
107
|
+
it("returns 400 for invalid JSON", async () => {
|
|
108
|
+
const resp = await fetch(`http://127.0.0.1:${port}/action`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: "not valid json{{{",
|
|
112
|
+
});
|
|
113
|
+
expect(resp.status).toBe(400);
|
|
114
|
+
const data = await resp.json() as Record<string, unknown>;
|
|
115
|
+
expect(data.ok).toBe(false);
|
|
116
|
+
expect(data.error).toContain("Invalid JSON");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("action routing", () => {
|
|
121
|
+
it("returns error when no active context", async () => {
|
|
122
|
+
const { body } = await post({ action: "send_message", text: "hi" });
|
|
123
|
+
expect(body.ok).toBe(false);
|
|
124
|
+
expect(body.error).toContain("No active chat context");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("routes to shared actions (cron)", async () => {
|
|
128
|
+
gateway.setContext(123);
|
|
129
|
+
const { body } = await post({ action: "list_cron_jobs", _chatId: "123" });
|
|
130
|
+
expect(body.ok).toBe(true);
|
|
131
|
+
gateway.clearContext(123);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("routes to frontend handler", async () => {
|
|
135
|
+
gateway.setContext(123);
|
|
136
|
+
const { body } = await post({ action: "send_message", _chatId: "123", text: "hello" });
|
|
137
|
+
expect(body.ok).toBe(true);
|
|
138
|
+
expect(body.message_id).toBe(42);
|
|
139
|
+
expect(mockFrontendHandler).toHaveBeenCalled();
|
|
140
|
+
gateway.clearContext(123);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns error for unknown action", async () => {
|
|
144
|
+
gateway.setContext(123);
|
|
145
|
+
const { body } = await post({ action: "completely_unknown_action", _chatId: "123" });
|
|
146
|
+
expect(body.ok).toBe(false);
|
|
147
|
+
expect(body.error).toContain("Unknown action");
|
|
148
|
+
gateway.clearContext(123);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns error for missing action field", async () => {
|
|
152
|
+
gateway.setContext(123);
|
|
153
|
+
const { body } = await post({ _chatId: "123", text: "no action" });
|
|
154
|
+
expect(body.ok).toBe(false);
|
|
155
|
+
expect(body.error).toContain("Missing action");
|
|
156
|
+
gateway.clearContext(123);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("rejects unknown chatId (no active context)", async () => {
|
|
160
|
+
const { body } = await post({ action: "send_message", _chatId: "999" });
|
|
161
|
+
expect(body.ok).toBe(false);
|
|
162
|
+
expect(body.error).toContain("No active chat context");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("concurrent chat contexts via HTTP", () => {
|
|
167
|
+
it("handles two chats simultaneously", async () => {
|
|
168
|
+
gateway.setContext(100);
|
|
169
|
+
gateway.setContext(200);
|
|
170
|
+
|
|
171
|
+
const [r1, r2] = await Promise.all([
|
|
172
|
+
post({ action: "send_message", _chatId: "100", text: "from chat 100" }),
|
|
173
|
+
post({ action: "send_message", _chatId: "200", text: "from chat 200" }),
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(r1.body.ok).toBe(true);
|
|
177
|
+
expect(r2.body.ok).toBe(true);
|
|
178
|
+
expect(mockFrontendHandler).toHaveBeenCalledTimes(2);
|
|
179
|
+
|
|
180
|
+
gateway.clearContext(100);
|
|
181
|
+
gateway.clearContext(200);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("one chat's context doesn't affect another", async () => {
|
|
185
|
+
gateway.setContext(300);
|
|
186
|
+
// Chat 400 has no context
|
|
187
|
+
const { body } = await post({ action: "send_message", _chatId: "400", text: "no context" });
|
|
188
|
+
expect(body.ok).toBe(false);
|
|
189
|
+
expect(body.error).toContain("No active chat context");
|
|
190
|
+
// Chat 300 still works
|
|
191
|
+
const r2 = await post({ action: "send_message", _chatId: "300", text: "still works" });
|
|
192
|
+
expect(r2.body.ok).toBe(true);
|
|
193
|
+
gateway.clearContext(300);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("frontend handler error returns error result (doesn't crash server)", async () => {
|
|
197
|
+
const errorHandler = vi.fn(async () => { throw new Error("handler exploded"); });
|
|
198
|
+
gateway.setFrontendHandler(errorHandler);
|
|
199
|
+
gateway.setContext(123);
|
|
200
|
+
|
|
201
|
+
const { status, body } = await post({ action: "send_message", _chatId: "123", text: "boom" });
|
|
202
|
+
|
|
203
|
+
expect(status).toBe(200); // HTTP 200, error in body
|
|
204
|
+
expect(body.ok).toBe(false);
|
|
205
|
+
expect(body.error).toContain("handler exploded");
|
|
206
|
+
|
|
207
|
+
gateway.clearContext(123);
|
|
208
|
+
gateway.setFrontendHandler(mockFrontendHandler); // restore
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("request without _chatId is rejected", async () => {
|
|
212
|
+
gateway.setContext(123);
|
|
213
|
+
const { body } = await post({ action: "send_message", text: "no chatId" });
|
|
214
|
+
expect(body.ok).toBe(false);
|
|
215
|
+
expect(body.error).toContain("No active chat context");
|
|
216
|
+
gateway.clearContext(123);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("shared actions via HTTP", () => {
|
|
221
|
+
it("fetch_url rejects invalid URLs", async () => {
|
|
222
|
+
gateway.setContext(123);
|
|
223
|
+
const { body } = await post({ action: "fetch_url", _chatId: "123", url: "not-a-url" });
|
|
224
|
+
expect(body.ok).toBe(false);
|
|
225
|
+
gateway.clearContext(123);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("read_history returns ok", async () => {
|
|
229
|
+
gateway.setContext(123);
|
|
230
|
+
const { body } = await post({ action: "read_history", _chatId: "123" });
|
|
231
|
+
expect(body.ok).toBe(true);
|
|
232
|
+
gateway.clearContext(123);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("create_cron_job works", async () => {
|
|
236
|
+
gateway.setContext(123);
|
|
237
|
+
const { body } = await post({
|
|
238
|
+
action: "create_cron_job", _chatId: "123",
|
|
239
|
+
name: "test", schedule: "0 9 * * *", type: "message", content: "hello",
|
|
240
|
+
});
|
|
241
|
+
expect(body.ok).toBe(true);
|
|
242
|
+
gateway.clearContext(123);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../../core/dispatcher.js", () => ({
|
|
4
|
+
execute: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock("../../core/prompt-builder.js", () => ({
|
|
7
|
+
enrichDMPrompt: vi.fn((p: string) => p),
|
|
8
|
+
enrichGroupPrompt: vi.fn((p: string) => p),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("../../storage/daily-log.js", () => ({
|
|
11
|
+
appendDailyLog: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock("../../util/watchdog.js", () => ({
|
|
14
|
+
recordMessageProcessed: vi.fn(),
|
|
15
|
+
recordError: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock("../../util/log.js", () => ({
|
|
18
|
+
log: vi.fn(),
|
|
19
|
+
logError: vi.fn(),
|
|
20
|
+
logWarn: vi.fn(),
|
|
21
|
+
logDebug: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("../../core/errors.js", () => ({
|
|
24
|
+
classify: vi.fn((e: unknown) => e),
|
|
25
|
+
friendlyMessage: vi.fn(() => "error"),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("../../storage/history.js", () => ({
|
|
29
|
+
setMessageFilePath: vi.fn(),
|
|
30
|
+
getRecentBySenderId: vi.fn(() => []),
|
|
31
|
+
}));
|
|
32
|
+
vi.mock("../../storage/media-index.js", () => ({
|
|
33
|
+
addMedia: vi.fn(),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock("node:fs", () => ({
|
|
36
|
+
writeFileSync: vi.fn(),
|
|
37
|
+
mkdirSync: vi.fn(),
|
|
38
|
+
existsSync: vi.fn(() => true),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const { shouldHandleInGroup, getSenderName, getReplyContext, getForwardContext } = await import(
|
|
42
|
+
"../frontend/telegram/handlers.js"
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
describe("shouldHandleInGroup", () => {
|
|
46
|
+
const makeCtx = (overrides: {
|
|
47
|
+
type?: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
username?: string;
|
|
50
|
+
replyFromId?: number;
|
|
51
|
+
botId?: number;
|
|
52
|
+
} = {}) => ({
|
|
53
|
+
chat: { type: overrides.type ?? "supergroup" },
|
|
54
|
+
me: { id: overrides.botId ?? 999, username: overrides.username ?? "testbot" },
|
|
55
|
+
message: {
|
|
56
|
+
text: overrides.text ?? "",
|
|
57
|
+
caption: undefined,
|
|
58
|
+
reply_to_message: overrides.replyFromId
|
|
59
|
+
? { from: { id: overrides.replyFromId } }
|
|
60
|
+
: undefined,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Cast partial objects to any — we only test the mention/reply logic, not the full Context
|
|
65
|
+
const ctx = (overrides: Parameters<typeof makeCtx>[0] = {}) =>
|
|
66
|
+
makeCtx(overrides) as any;
|
|
67
|
+
|
|
68
|
+
it("returns true for DMs (not a group)", () => {
|
|
69
|
+
expect(shouldHandleInGroup({ ...ctx(), chat: { type: "private" } })).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns true when bot is mentioned with @", () => {
|
|
73
|
+
expect(shouldHandleInGroup(ctx({ text: "hey @testbot what's up" }))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns true when bot is mentioned case-insensitively", () => {
|
|
77
|
+
expect(shouldHandleInGroup(ctx({ text: "Hey @TestBot!" }))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns false when a different bot is mentioned", () => {
|
|
81
|
+
expect(shouldHandleInGroup(ctx({ text: "hey @testbot123 what's up" }))).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns false when bot name appears without @", () => {
|
|
85
|
+
expect(shouldHandleInGroup(ctx({ text: "testbot is cool" }))).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns true when replying to the bot", () => {
|
|
89
|
+
expect(shouldHandleInGroup(ctx({ replyFromId: 999, botId: 999 }))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns false when replying to someone else", () => {
|
|
93
|
+
expect(shouldHandleInGroup(ctx({ replyFromId: 123, botId: 999 }))).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns false for plain group message without mention or reply", () => {
|
|
97
|
+
expect(shouldHandleInGroup(ctx({ text: "hello everyone" }))).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("handles @mention at end of message", () => {
|
|
101
|
+
expect(shouldHandleInGroup(ctx({ text: "what do you think @testbot" }))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles @mention with punctuation after", () => {
|
|
105
|
+
expect(shouldHandleInGroup(ctx({ text: "@testbot, help me" }))).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("getSenderName", () => {
|
|
110
|
+
it("returns first + last name", () => {
|
|
111
|
+
expect(getSenderName({ first_name: "John", last_name: "Doe" })).toBe("John Doe");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns first name only when no last name", () => {
|
|
115
|
+
expect(getSenderName({ first_name: "Alice" })).toBe("Alice");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns 'User' when undefined", () => {
|
|
119
|
+
expect(getSenderName(undefined)).toBe("User");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns 'User' when empty names", () => {
|
|
123
|
+
expect(getSenderName({})).toBe("User");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("getReplyContext", () => {
|
|
128
|
+
it("returns empty for no reply", () => {
|
|
129
|
+
expect(getReplyContext(undefined, 999)).toBe("");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("includes 'bot' as author when replying to bot itself", () => {
|
|
133
|
+
const result = getReplyContext({ from: { id: 999 }, text: "hi" }, 999);
|
|
134
|
+
expect(result).toContain("bot");
|
|
135
|
+
expect(result).toContain("hi");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("includes author, text, and message_id for reply to others", () => {
|
|
139
|
+
const result = getReplyContext(
|
|
140
|
+
{ message_id: 42, from: { id: 123, first_name: "Alice" }, text: "original message" },
|
|
141
|
+
999,
|
|
142
|
+
);
|
|
143
|
+
expect(result).toContain("Alice");
|
|
144
|
+
expect(result).toContain("original message");
|
|
145
|
+
expect(result).toContain("Replying to");
|
|
146
|
+
expect(result).toContain("msg_id:42");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("truncates long reply text to 500 chars", () => {
|
|
150
|
+
const longText = "x".repeat(600);
|
|
151
|
+
const result = getReplyContext(
|
|
152
|
+
{ from: { id: 123, first_name: "Bob" }, text: longText },
|
|
153
|
+
999,
|
|
154
|
+
);
|
|
155
|
+
expect(result.length).toBeLessThan(600);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("getForwardContext", () => {
|
|
160
|
+
it("returns empty for non-forwarded messages", () => {
|
|
161
|
+
expect(getForwardContext({})).toBe("");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("handles forwarded from user", () => {
|
|
165
|
+
const result = getForwardContext({
|
|
166
|
+
forward_origin: {
|
|
167
|
+
type: "user",
|
|
168
|
+
sender_user: { first_name: "Charlie", last_name: "D" },
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
expect(result).toContain("Charlie D");
|
|
172
|
+
expect(result).toContain("Forwarded");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("handles forwarded from channel", () => {
|
|
176
|
+
const result = getForwardContext({
|
|
177
|
+
forward_origin: { type: "channel", chat: { title: "News" } },
|
|
178
|
+
});
|
|
179
|
+
expect(result).toContain("News");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("handles hidden user", () => {
|
|
183
|
+
const result = getForwardContext({
|
|
184
|
+
forward_origin: { type: "hidden_user", sender_user_name: "anon" },
|
|
185
|
+
});
|
|
186
|
+
expect(result).toContain("anon");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("handles chat type forward", () => {
|
|
190
|
+
const result = getForwardContext({
|
|
191
|
+
forward_origin: { type: "chat", chat: { title: "Support Group" } },
|
|
192
|
+
});
|
|
193
|
+
expect(result).toContain("Support Group");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("handles channel with no title", () => {
|
|
197
|
+
const result = getForwardContext({
|
|
198
|
+
forward_origin: { type: "channel", chat: {} },
|
|
199
|
+
});
|
|
200
|
+
expect(result).toContain("a chat");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("handles unknown forward type", () => {
|
|
204
|
+
const result = getForwardContext({
|
|
205
|
+
forward_origin: { type: "unknown_type" },
|
|
206
|
+
});
|
|
207
|
+
expect(result).toContain("someone");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("getReplyContext — edge cases", () => {
|
|
212
|
+
it("returns empty when reply has no text, caption, media, or message_id", () => {
|
|
213
|
+
expect(getReplyContext({ from: { id: 123 } }, 999)).toBe("");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("includes message_id and media type even without text", () => {
|
|
217
|
+
const result = getReplyContext(
|
|
218
|
+
{ message_id: 100, from: { id: 123, first_name: "Dan" }, photo: [{}] as unknown[] },
|
|
219
|
+
999,
|
|
220
|
+
);
|
|
221
|
+
expect(result).toContain("msg_id:100");
|
|
222
|
+
expect(result).toContain("[photo]");
|
|
223
|
+
expect(result).toContain("Dan");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("uses caption when text is missing", () => {
|
|
227
|
+
const result = getReplyContext(
|
|
228
|
+
{ from: { id: 123, first_name: "Eve" }, caption: "photo caption" },
|
|
229
|
+
999,
|
|
230
|
+
);
|
|
231
|
+
expect(result).toContain("photo caption");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("includes full name from first and last", () => {
|
|
235
|
+
const result = getReplyContext(
|
|
236
|
+
{ from: { id: 123, first_name: "Jane", last_name: "Smith" }, text: "hello" },
|
|
237
|
+
999,
|
|
238
|
+
);
|
|
239
|
+
expect(result).toContain("Jane Smith");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("shouldHandleInGroup — edge cases", () => {
|
|
244
|
+
const ctx = (overrides: Record<string, unknown> = {}) => ({
|
|
245
|
+
chat: { type: overrides.type ?? "supergroup" },
|
|
246
|
+
me: { id: overrides.botId ?? 999, username: overrides.username ?? "testbot" },
|
|
247
|
+
message: {
|
|
248
|
+
text: overrides.text ?? "",
|
|
249
|
+
caption: overrides.caption ?? undefined,
|
|
250
|
+
reply_to_message: overrides.replyFromId
|
|
251
|
+
? { from: { id: overrides.replyFromId } }
|
|
252
|
+
: undefined,
|
|
253
|
+
},
|
|
254
|
+
}) as any;
|
|
255
|
+
|
|
256
|
+
it("returns false when no chat", () => {
|
|
257
|
+
expect(shouldHandleInGroup({ chat: null, message: {} } as any)).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("returns false when no message", () => {
|
|
261
|
+
expect(shouldHandleInGroup({ chat: { type: "group" }, message: null } as any)).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("detects bot mention in caption", () => {
|
|
265
|
+
expect(shouldHandleInGroup(ctx({ caption: "look @testbot", text: "" }))).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("handles 'group' type (not just supergroup)", () => {
|
|
269
|
+
expect(shouldHandleInGroup(ctx({ type: "group", text: "@testbot hi" }))).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("returns false for group with @mention as part of longer username", () => {
|
|
273
|
+
// @testbot123 should NOT match @testbot (word boundary check)
|
|
274
|
+
expect(shouldHandleInGroup(ctx({ text: "hey @testbot_extra" }))).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("getSenderName — edge cases", () => {
|
|
279
|
+
it("returns last name only when no first name", () => {
|
|
280
|
+
expect(getSenderName({ last_name: "Doe" })).toBe("Doe");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("trims whitespace from names", () => {
|
|
284
|
+
// filter(Boolean) handles empty strings
|
|
285
|
+
expect(getSenderName({ first_name: "", last_name: "Smith" })).toBe("Smith");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const { handleTextMessage, handlePhotoMessage, handleCallbackQuery } = await import(
|
|
290
|
+
"../frontend/telegram/handlers.js"
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
describe("handleTextMessage — integration via mock Context", () => {
|
|
294
|
+
|
|
295
|
+
const mockBot = {
|
|
296
|
+
api: {
|
|
297
|
+
sendMessage: vi.fn(async () => ({ message_id: 1 })),
|
|
298
|
+
sendChatAction: vi.fn(async () => {}),
|
|
299
|
+
setMessageReaction: vi.fn(async () => {}),
|
|
300
|
+
sendMessageDraft: vi.fn(async () => {}),
|
|
301
|
+
},
|
|
302
|
+
} as any;
|
|
303
|
+
|
|
304
|
+
const mockConfig = {
|
|
305
|
+
botToken: "test",
|
|
306
|
+
model: "claude-sonnet-4-6",
|
|
307
|
+
maxMessageLength: 4000,
|
|
308
|
+
workspace: "/tmp/test-workspace",
|
|
309
|
+
} as any;
|
|
310
|
+
|
|
311
|
+
it("silently returns when ctx.message is missing", async () => {
|
|
312
|
+
await handleTextMessage({ chat: { id: 1 } } as any, mockBot, mockConfig);
|
|
313
|
+
// Should not throw
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("silently returns when ctx.chat is missing", async () => {
|
|
317
|
+
await handleTextMessage({ message: { text: "hi" } } as any, mockBot, mockConfig);
|
|
318
|
+
// Should not throw
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("silently returns for group message without mention", async () => {
|
|
322
|
+
const ctx = {
|
|
323
|
+
chat: { id: 1, type: "supergroup" },
|
|
324
|
+
message: { text: "hello everyone", message_id: 1, reply_to_message: null },
|
|
325
|
+
me: { id: 999, username: "testbot" },
|
|
326
|
+
from: { id: 1, first_name: "User" },
|
|
327
|
+
} as any;
|
|
328
|
+
await handleTextMessage(ctx, mockBot, mockConfig);
|
|
329
|
+
// Should not enqueue (no mention or reply to bot)
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("silently returns when photo has no photos array", async () => {
|
|
333
|
+
const ctx = {
|
|
334
|
+
chat: { id: 1, type: "private" },
|
|
335
|
+
message: { photo: null, message_id: 1 },
|
|
336
|
+
me: { id: 999, username: "testbot" },
|
|
337
|
+
from: { id: 1, first_name: "User" },
|
|
338
|
+
} as any;
|
|
339
|
+
await handlePhotoMessage(ctx, mockBot, mockConfig);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("silently returns when callback query has no data", async () => {
|
|
343
|
+
const ctx = {
|
|
344
|
+
callbackQuery: { message: { message_id: 1 } }, // no 'data' property
|
|
345
|
+
chat: { id: 1 },
|
|
346
|
+
from: { id: 1, first_name: "User" },
|
|
347
|
+
} as any;
|
|
348
|
+
await handleCallbackQuery(ctx, mockBot, mockConfig);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|