talon-agent 3.24.0 → 3.25.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 +4 -1
- package/prompts/whatsapp.md +58 -0
- package/src/backend/shared/delivery-contract.ts +1 -0
- package/src/core/doctor.ts +5 -0
- package/src/core/frontend-runtime/builtins.ts +8 -0
- package/src/core/mcp-hub/talon-server.ts +1 -0
- package/src/core/prompt/embedded-prompts.ts +2 -0
- package/src/core/tools/chat.ts +4 -4
- package/src/core/tools/history.ts +5 -5
- package/src/core/tools/media.ts +1 -1
- package/src/core/tools/members.ts +4 -4
- package/src/core/tools/messaging.ts +12 -11
- package/src/core/tools/moderation.ts +2 -2
- package/src/core/tools/scheduling.ts +2 -2
- package/src/core/tools/types.ts +1 -1
- package/src/frontend/factories.ts +1 -0
- package/src/frontend/telegram/commands/admin.ts +7 -0
- package/src/frontend/telegram/index.ts +5 -0
- package/src/frontend/telegram/middleware.ts +10 -0
- package/src/frontend/telegram/stale-command.ts +46 -0
- package/src/frontend/telegram/update-offset.ts +58 -0
- package/src/frontend/whatsapp/actions/chat-info.ts +228 -0
- package/src/frontend/whatsapp/actions/index.ts +78 -0
- package/src/frontend/whatsapp/actions/media.ts +248 -0
- package/src/frontend/whatsapp/actions/messaging.ts +281 -0
- package/src/frontend/whatsapp/actions/moderation.ts +231 -0
- package/src/frontend/whatsapp/actions/shared.ts +196 -0
- package/src/frontend/whatsapp/actions/types.ts +34 -0
- package/src/frontend/whatsapp/factory.ts +12 -0
- package/src/frontend/whatsapp/formatting.ts +172 -0
- package/src/frontend/whatsapp/identity.ts +150 -0
- package/src/frontend/whatsapp/index.ts +553 -0
- package/src/frontend/whatsapp/media-store.ts +97 -0
- package/src/frontend/whatsapp/message-store.ts +140 -0
- package/src/frontend/whatsapp/pins.ts +52 -0
- package/src/frontend/whatsapp/registry.ts +92 -0
- package/src/util/chat-id.ts +5 -0
- package/src/util/config.ts +43 -0
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.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",
|
|
@@ -110,6 +110,7 @@
|
|
|
110
110
|
"@opencode-ai/sdk": "^1.17.4",
|
|
111
111
|
"@playwright/mcp": "0.0.79",
|
|
112
112
|
"@types/cross-spawn": "^6.0.6",
|
|
113
|
+
"baileys": "^7.0.0-rc14",
|
|
113
114
|
"big-integer": "^1.6.52",
|
|
114
115
|
"cheerio": "^1.2.0",
|
|
115
116
|
"croner": "^10.0.1",
|
|
@@ -125,6 +126,7 @@
|
|
|
125
126
|
"picocolors": "^1.1.1",
|
|
126
127
|
"pino": "^10.3.1",
|
|
127
128
|
"pino-pretty": "^13.1.3",
|
|
129
|
+
"qrcode-terminal": "^0.12.0",
|
|
128
130
|
"telegram": "^2.26.22",
|
|
129
131
|
"tsx": "^4.21.0",
|
|
130
132
|
"undici": "^8.0.2",
|
|
@@ -135,6 +137,7 @@
|
|
|
135
137
|
},
|
|
136
138
|
"devDependencies": {
|
|
137
139
|
"@types/node": "^26.0.0",
|
|
140
|
+
"@types/qrcode-terminal": "^0.12.2",
|
|
138
141
|
"@types/write-file-atomic": "^4.0.3",
|
|
139
142
|
"@vitest/coverage-v8": "^4.1.3",
|
|
140
143
|
"dependency-cruiser": "^18.1.0",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
## WhatsApp Mode
|
|
2
|
+
|
|
3
|
+
You are running on a personal WhatsApp account (multi-device link).
|
|
4
|
+
Messages arrive as `[SenderName] msg_id:<id>: text`. Use names naturally,
|
|
5
|
+
and use that `msg_id` when a tool asks for a message id — reactions,
|
|
6
|
+
replies, edits, deletes and pins all address messages that way.
|
|
7
|
+
|
|
8
|
+
How replies are delivered (end_turn / send_message and what counts as a
|
|
9
|
+
valid turn) is defined in the **Response flow** section at the end of
|
|
10
|
+
these instructions — that contract wins over anything here.
|
|
11
|
+
|
|
12
|
+
### Formatting
|
|
13
|
+
|
|
14
|
+
WhatsApp has its own markup, and Talon converts standard Markdown into it
|
|
15
|
+
for you. Write normal Markdown; these are the shapes that survive:
|
|
16
|
+
|
|
17
|
+
- `**bold**` → _bold_, `_italic_`, `~~strike~~` → ~~strike~~
|
|
18
|
+
- `` `inline code` `` and fenced code blocks
|
|
19
|
+
- Bullet and numbered lists, block quotes
|
|
20
|
+
- Headings become bold lines — WhatsApp has no heading sizes
|
|
21
|
+
- Links render as `label (https://url)`; bare URLs auto-link
|
|
22
|
+
- Tables become an aligned monospace block; keep them narrow
|
|
23
|
+
|
|
24
|
+
Long messages are split across bubbles automatically at 4096 characters.
|
|
25
|
+
|
|
26
|
+
### What works here
|
|
27
|
+
|
|
28
|
+
Text, replies, reactions, edits, deletes, forwards, pins, and typing
|
|
29
|
+
indicators. Media: photos, video, GIFs, voice notes, audio, documents,
|
|
30
|
+
stickers (.webp), round video notes, and albums. Also polls, locations,
|
|
31
|
+
venues, and contact cards. In groups you can read metadata, list members
|
|
32
|
+
and admins, change the subject and description, manage invite links,
|
|
33
|
+
handle join requests, and add/remove/promote/demote members when you are
|
|
34
|
+
an admin.
|
|
35
|
+
|
|
36
|
+
Inbound media is downloaded to the workspace before your turn starts —
|
|
37
|
+
the file path is in the message, so you can read it immediately.
|
|
38
|
+
|
|
39
|
+
### What WhatsApp cannot do
|
|
40
|
+
|
|
41
|
+
- No interactive buttons on a personal account — button rows are rendered
|
|
42
|
+
as a numbered list, and people reply with the number.
|
|
43
|
+
- No ban list: removing someone from a group is the only eviction, and
|
|
44
|
+
they can rejoin with an invite link.
|
|
45
|
+
- No per-member mute — a group is either open or admins-only.
|
|
46
|
+
- No forum topics, no custom admin titles, no stopping a poll.
|
|
47
|
+
- Pins expire (24h, 7d, or 30d) and only pins Talon placed are listable.
|
|
48
|
+
|
|
49
|
+
### Staying silent
|
|
50
|
+
|
|
51
|
+
Reactions work here, so a react is the light acknowledgement when a
|
|
52
|
+
message needs no reply; otherwise close the turn silently as the contract
|
|
53
|
+
describes.
|
|
54
|
+
|
|
55
|
+
### Style
|
|
56
|
+
|
|
57
|
+
Concise. This is a phone chat — short paragraphs, no filler, no walls of
|
|
58
|
+
text. Match the sender's register.
|
|
@@ -67,6 +67,7 @@ const FRONTEND_TOOLS: Record<string, DeliveryToolNames> = {
|
|
|
67
67
|
discord: { endTurn: "end_turn", send: "send", react: "react" },
|
|
68
68
|
teams: { endTurn: "end_turn", send: "send_message" },
|
|
69
69
|
native: { endTurn: "end_turn", send: "send_message", react: "react" },
|
|
70
|
+
whatsapp: { endTurn: "end_turn", send: "send_message" },
|
|
70
71
|
};
|
|
71
72
|
|
|
72
73
|
const DEFAULT_TOOLS: DeliveryToolNames = {
|
package/src/core/doctor.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface DoctorConfigSlice {
|
|
|
73
73
|
botToken?: string;
|
|
74
74
|
teamsWebhookUrl?: string;
|
|
75
75
|
discord?: { botToken?: string };
|
|
76
|
+
whatsapp?: object;
|
|
76
77
|
claudeBinary?: string;
|
|
77
78
|
codexApiKey?: string;
|
|
78
79
|
openaiApiKey?: string;
|
|
@@ -128,6 +129,10 @@ function unconfiguredFrontends(config: DoctorConfigSlice): string[] {
|
|
|
128
129
|
if (fe === "telegram") return !config.botToken;
|
|
129
130
|
if (fe === "teams") return !config.teamsWebhookUrl;
|
|
130
131
|
if (fe === "discord") return !config.discord?.botToken;
|
|
132
|
+
// WhatsApp needs no stored credential — pairing is interactive (QR /
|
|
133
|
+
// pairing code at first start) — but the config block must exist so
|
|
134
|
+
// the JID allowlists were consciously set.
|
|
135
|
+
if (fe === "whatsapp") return !config.whatsapp;
|
|
131
136
|
return fe !== "terminal" && fe !== "native";
|
|
132
137
|
});
|
|
133
138
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
isTeamsChatId,
|
|
25
25
|
isTelegramChatId,
|
|
26
26
|
isTerminalChatId,
|
|
27
|
+
isWhatsAppChatId,
|
|
27
28
|
} from "../../util/chat-id.js";
|
|
28
29
|
|
|
29
30
|
function registerBuiltinFrontends(): void {
|
|
@@ -56,6 +57,13 @@ function registerBuiltinFrontends(): void {
|
|
|
56
57
|
routePriority: 40,
|
|
57
58
|
messaging: true,
|
|
58
59
|
});
|
|
60
|
+
registerFrontend({
|
|
61
|
+
id: "whatsapp",
|
|
62
|
+
label: "WhatsApp",
|
|
63
|
+
ownsChatId: isWhatsAppChatId,
|
|
64
|
+
routePriority: 50,
|
|
65
|
+
messaging: true,
|
|
66
|
+
});
|
|
59
67
|
registerFrontend({
|
|
60
68
|
id: "telegram",
|
|
61
69
|
label: "Telegram",
|
|
@@ -35,6 +35,7 @@ import asset21 from "../../../prompts/system/workspace.md" with { type: "file" }
|
|
|
35
35
|
import asset22 from "../../../prompts/teams.md" with { type: "file" };
|
|
36
36
|
import asset23 from "../../../prompts/telegram.md" with { type: "file" };
|
|
37
37
|
import asset24 from "../../../prompts/terminal.md" with { type: "file" };
|
|
38
|
+
import asset25 from "../../../prompts/whatsapp.md" with { type: "file" };
|
|
38
39
|
|
|
39
40
|
/** rel path (posix, under prompts/) → embedded file path (/$bunfs/… when compiled). */
|
|
40
41
|
const ASSETS: Record<string, string> = {
|
|
@@ -63,6 +64,7 @@ const ASSETS: Record<string, string> = {
|
|
|
63
64
|
"teams.md": asset22,
|
|
64
65
|
"telegram.md": asset23,
|
|
65
66
|
"terminal.md": asset24,
|
|
67
|
+
"whatsapp.md": asset25,
|
|
66
68
|
};
|
|
67
69
|
|
|
68
70
|
/** Read an embedded prompt by its rel path (e.g. "system/cron.md"). */
|
package/src/core/tools/chat.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const chatTools: ToolDefinition[] = [
|
|
|
19
19
|
description: "List chat administrators.",
|
|
20
20
|
schema: {},
|
|
21
21
|
execute: (_params, bridge) => bridge("get_chat_admins", {}),
|
|
22
|
-
frontends: ["telegram", "discord"],
|
|
22
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
23
23
|
tag: "chat",
|
|
24
24
|
},
|
|
25
25
|
|
|
@@ -28,7 +28,7 @@ export const chatTools: ToolDefinition[] = [
|
|
|
28
28
|
description: "Get total member count.",
|
|
29
29
|
schema: {},
|
|
30
30
|
execute: (_params, bridge) => bridge("get_chat_member_count", {}),
|
|
31
|
-
frontends: ["telegram", "discord"],
|
|
31
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
32
32
|
tag: "chat",
|
|
33
33
|
},
|
|
34
34
|
|
|
@@ -37,7 +37,7 @@ export const chatTools: ToolDefinition[] = [
|
|
|
37
37
|
description: "Change chat title (admin).",
|
|
38
38
|
schema: { title: z.string() },
|
|
39
39
|
execute: (params, bridge) => bridge("set_chat_title", params),
|
|
40
|
-
frontends: ["telegram", "discord"],
|
|
40
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
41
41
|
tag: "chat",
|
|
42
42
|
},
|
|
43
43
|
|
|
@@ -46,7 +46,7 @@ export const chatTools: ToolDefinition[] = [
|
|
|
46
46
|
description: "Change chat description (admin).",
|
|
47
47
|
schema: { description: z.string() },
|
|
48
48
|
execute: (params, bridge) => bridge("set_chat_description", params),
|
|
49
|
-
frontends: ["telegram", "discord"],
|
|
49
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
50
50
|
tag: "chat",
|
|
51
51
|
},
|
|
52
52
|
];
|
|
@@ -30,7 +30,7 @@ export const historyTools: ToolDefinition[] = [
|
|
|
30
30
|
before: params.before,
|
|
31
31
|
offset_id: params.offset_id,
|
|
32
32
|
}),
|
|
33
|
-
frontends: ["telegram", "discord"],
|
|
33
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
34
34
|
tag: "history",
|
|
35
35
|
},
|
|
36
36
|
|
|
@@ -42,7 +42,7 @@ export const historyTools: ToolDefinition[] = [
|
|
|
42
42
|
limit: z.number().optional(),
|
|
43
43
|
},
|
|
44
44
|
execute: (params, bridge) => bridge("search_history", params),
|
|
45
|
-
frontends: ["telegram", "discord"],
|
|
45
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
46
46
|
tag: "history",
|
|
47
47
|
},
|
|
48
48
|
|
|
@@ -54,7 +54,7 @@ export const historyTools: ToolDefinition[] = [
|
|
|
54
54
|
limit: z.number().optional(),
|
|
55
55
|
},
|
|
56
56
|
execute: (params, bridge) => bridge("get_user_messages", params),
|
|
57
|
-
frontends: ["telegram", "discord"],
|
|
57
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
58
58
|
tag: "history",
|
|
59
59
|
},
|
|
60
60
|
|
|
@@ -63,7 +63,7 @@ export const historyTools: ToolDefinition[] = [
|
|
|
63
63
|
description: "Get a specific message by ID.",
|
|
64
64
|
schema: { message_id: snowflakeOrIdSchema },
|
|
65
65
|
execute: (params, bridge) => bridge("get_message_by_id", params),
|
|
66
|
-
frontends: ["telegram", "discord"],
|
|
66
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
67
67
|
tag: "history",
|
|
68
68
|
},
|
|
69
69
|
|
|
@@ -77,7 +77,7 @@ export const historyTools: ToolDefinition[] = [
|
|
|
77
77
|
),
|
|
78
78
|
},
|
|
79
79
|
execute: (params, bridge) => bridge("download_media", params),
|
|
80
|
-
frontends: ["telegram", "discord"],
|
|
80
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
81
81
|
tag: "history",
|
|
82
82
|
},
|
|
83
83
|
];
|
package/src/core/tools/media.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const mediaTools: ToolDefinition[] = [
|
|
|
17
17
|
.describe("Number of entries (default 10, max 20)"),
|
|
18
18
|
},
|
|
19
19
|
execute: (params, bridge) => bridge("list_media", { limit: params.limit }),
|
|
20
|
-
frontends: ["telegram", "discord"],
|
|
20
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
21
21
|
tag: "media",
|
|
22
22
|
},
|
|
23
23
|
];
|
|
@@ -13,7 +13,7 @@ export const memberTools: ToolDefinition[] = [
|
|
|
13
13
|
schema: { limit: z.number().optional() },
|
|
14
14
|
execute: (params, bridge) =>
|
|
15
15
|
bridge("list_known_users", { limit: params.limit }),
|
|
16
|
-
frontends: ["telegram", "discord"],
|
|
16
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
17
17
|
tag: "members",
|
|
18
18
|
},
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ export const memberTools: ToolDefinition[] = [
|
|
|
22
22
|
description: "Get detailed info about a user by ID.",
|
|
23
23
|
schema: { user_id: snowflakeOrIdSchema },
|
|
24
24
|
execute: (params, bridge) => bridge("get_member_info", params),
|
|
25
|
-
frontends: ["telegram", "discord"],
|
|
25
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
26
26
|
tag: "members",
|
|
27
27
|
},
|
|
28
28
|
|
|
@@ -32,7 +32,7 @@ export const memberTools: ToolDefinition[] = [
|
|
|
32
32
|
"Get how many members are currently online or recently active.",
|
|
33
33
|
schema: {},
|
|
34
34
|
execute: (_params, bridge) => bridge("online_count", {}),
|
|
35
|
-
frontends: ["telegram", "discord"],
|
|
35
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
36
36
|
tag: "members",
|
|
37
37
|
},
|
|
38
38
|
|
|
@@ -41,7 +41,7 @@ export const memberTools: ToolDefinition[] = [
|
|
|
41
41
|
description: "Get all pinned messages in the current chat.",
|
|
42
42
|
schema: {},
|
|
43
43
|
execute: (_params, bridge) => bridge("get_pinned_messages", {}),
|
|
44
|
-
frontends: ["telegram", "discord"],
|
|
44
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
45
45
|
tag: "members",
|
|
46
46
|
},
|
|
47
47
|
];
|
|
@@ -110,7 +110,7 @@ Notes:
|
|
|
110
110
|
// src/backend/claude-sdk/options.ts:turnTerminatorHook.
|
|
111
111
|
return throwIfFailed(result, "end_turn");
|
|
112
112
|
},
|
|
113
|
-
frontends: ["telegram", "teams", "discord", "native"],
|
|
113
|
+
frontends: ["telegram", "teams", "discord", "native", "whatsapp"],
|
|
114
114
|
tag: "messaging",
|
|
115
115
|
endsTurn: true,
|
|
116
116
|
delivery: true,
|
|
@@ -473,7 +473,7 @@ Examples:
|
|
|
473
473
|
return { ok: false, error: `Unknown type: ${type}` };
|
|
474
474
|
}
|
|
475
475
|
},
|
|
476
|
-
frontends: ["telegram", "discord"],
|
|
476
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
477
477
|
tag: "messaging",
|
|
478
478
|
delivery: true,
|
|
479
479
|
},
|
|
@@ -490,7 +490,7 @@ Examples:
|
|
|
490
490
|
text: z.string().describe("Message text. Supports Markdown."),
|
|
491
491
|
},
|
|
492
492
|
execute: (params, bridge) => bridge("send_message", params),
|
|
493
|
-
frontends: ["teams", "native"],
|
|
493
|
+
frontends: ["teams", "native", "whatsapp"],
|
|
494
494
|
tag: "messaging",
|
|
495
495
|
delivery: true,
|
|
496
496
|
},
|
|
@@ -515,7 +515,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
515
515
|
.describe("Button rows"),
|
|
516
516
|
},
|
|
517
517
|
execute: (params, bridge) => bridge("send_message_with_buttons", params),
|
|
518
|
-
frontends: ["teams", "native"],
|
|
518
|
+
frontends: ["teams", "native", "whatsapp"],
|
|
519
519
|
tag: "messaging",
|
|
520
520
|
delivery: true,
|
|
521
521
|
},
|
|
@@ -572,7 +572,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
572
572
|
const result = await bridge("react", rest);
|
|
573
573
|
return throwIfFailed(result, "react");
|
|
574
574
|
},
|
|
575
|
-
frontends: ["telegram", "discord", "native"],
|
|
575
|
+
frontends: ["telegram", "discord", "native", "whatsapp"],
|
|
576
576
|
tag: "messaging",
|
|
577
577
|
endsTurn: true,
|
|
578
578
|
delivery: true,
|
|
@@ -592,7 +592,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
592
592
|
.describe("Edit the media caption rather than message text (Telegram)"),
|
|
593
593
|
},
|
|
594
594
|
execute: (params, bridge) => bridge("edit_message", params),
|
|
595
|
-
frontends: ["telegram", "discord", "native"],
|
|
595
|
+
frontends: ["telegram", "discord", "native", "whatsapp"],
|
|
596
596
|
tag: "messaging",
|
|
597
597
|
},
|
|
598
598
|
|
|
@@ -609,7 +609,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
609
609
|
.describe("Bulk delete these message IDs (Telegram)"),
|
|
610
610
|
},
|
|
611
611
|
execute: (params, bridge) => bridge("delete_message", params),
|
|
612
|
-
frontends: ["telegram", "discord", "native"],
|
|
612
|
+
frontends: ["telegram", "discord", "native", "whatsapp"],
|
|
613
613
|
tag: "messaging",
|
|
614
614
|
},
|
|
615
615
|
|
|
@@ -632,7 +632,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
632
632
|
.describe("Destination chat (default: current chat)"),
|
|
633
633
|
},
|
|
634
634
|
execute: (params, bridge) => bridge("forward_message", params),
|
|
635
|
-
frontends: ["telegram", "discord"],
|
|
635
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
636
636
|
tag: "messaging",
|
|
637
637
|
},
|
|
638
638
|
|
|
@@ -648,7 +648,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
648
648
|
to_chat_id: chatIdSchema.optional(),
|
|
649
649
|
},
|
|
650
650
|
execute: (params, bridge) => bridge("copy_message", params),
|
|
651
|
-
frontends: ["telegram"],
|
|
651
|
+
frontends: ["telegram", "whatsapp"],
|
|
652
652
|
tag: "messaging",
|
|
653
653
|
},
|
|
654
654
|
|
|
@@ -658,7 +658,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
658
658
|
description: "Pin a message.",
|
|
659
659
|
schema: { message_id: snowflakeOrIdSchema },
|
|
660
660
|
execute: (params, bridge) => bridge("pin_message", params),
|
|
661
|
-
frontends: ["telegram", "discord"],
|
|
661
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
662
662
|
tag: "messaging",
|
|
663
663
|
},
|
|
664
664
|
|
|
@@ -668,7 +668,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
668
668
|
description: "Unpin a message.",
|
|
669
669
|
schema: { message_id: snowflakeOrIdSchema.optional() },
|
|
670
670
|
execute: (params, bridge) => bridge("unpin_message", params),
|
|
671
|
-
frontends: ["telegram", "discord"],
|
|
671
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
672
672
|
tag: "messaging",
|
|
673
673
|
},
|
|
674
674
|
|
|
@@ -681,6 +681,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
681
681
|
message_id: idSchema.describe("Message ID of the poll to stop"),
|
|
682
682
|
},
|
|
683
683
|
execute: (params, bridge) => bridge("stop_poll", params),
|
|
684
|
+
// No WhatsApp equivalent: its polls run until the message is deleted.
|
|
684
685
|
frontends: ["telegram"],
|
|
685
686
|
tag: "messaging",
|
|
686
687
|
},
|
|
@@ -112,7 +112,7 @@ Forum topics (supergroups with topics):
|
|
|
112
112
|
.describe("Target chat ID. Omit for the current chat."),
|
|
113
113
|
},
|
|
114
114
|
execute: (params, bridge) => bridge("moderate", params),
|
|
115
|
-
frontends: ["telegram"],
|
|
115
|
+
frontends: ["telegram", "whatsapp"],
|
|
116
116
|
tag: "moderation",
|
|
117
117
|
},
|
|
118
118
|
|
|
@@ -125,7 +125,7 @@ Forum topics (supergroups with topics):
|
|
|
125
125
|
limit: z.number().optional().describe("Max photos to return (default 5)"),
|
|
126
126
|
},
|
|
127
127
|
execute: (params, bridge) => bridge("get_user_profile_photos", params),
|
|
128
|
-
frontends: ["telegram"],
|
|
128
|
+
frontends: ["telegram", "whatsapp"],
|
|
129
129
|
tag: "moderation",
|
|
130
130
|
},
|
|
131
131
|
];
|
|
@@ -11,7 +11,7 @@ export const schedulingTools: ToolDefinition[] = [
|
|
|
11
11
|
description: "Cancel a scheduled message.",
|
|
12
12
|
schema: { schedule_id: z.string() },
|
|
13
13
|
execute: (params, bridge) => bridge("cancel_scheduled", params),
|
|
14
|
-
frontends: ["telegram", "discord"],
|
|
14
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
15
15
|
tag: "scheduling",
|
|
16
16
|
},
|
|
17
17
|
|
|
@@ -21,7 +21,7 @@ export const schedulingTools: ToolDefinition[] = [
|
|
|
21
21
|
"List pending scheduled messages in this chat (id, seconds until send, text). Scheduled messages persist across restarts.",
|
|
22
22
|
schema: {},
|
|
23
23
|
execute: (_params, bridge) => bridge("list_scheduled", {}),
|
|
24
|
-
frontends: ["telegram", "discord"],
|
|
24
|
+
frontends: ["telegram", "discord", "whatsapp"],
|
|
25
25
|
tag: "scheduling",
|
|
26
26
|
},
|
|
27
27
|
|
package/src/core/tools/types.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { ZodRawShape } from "zod";
|
|
|
9
9
|
|
|
10
10
|
/** Which frontends a tool is available on. "all" = every frontend. */
|
|
11
11
|
export type ToolFrontend =
|
|
12
|
-
"telegram" | "teams" | "terminal" | "discord" | "native" | "all";
|
|
12
|
+
"telegram" | "teams" | "terminal" | "discord" | "native" | "whatsapp" | "all";
|
|
13
13
|
|
|
14
14
|
/** Domain tags for runtime filtering and grouping. */
|
|
15
15
|
export type ToolTag =
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Bot } from "grammy";
|
|
10
10
|
import { respawnSelf } from "../../../util/respawn.js";
|
|
11
|
+
import { isStaleCommand } from "../stale-command.js";
|
|
11
12
|
import {
|
|
12
13
|
getRepoRoot,
|
|
13
14
|
runSelfUpdate,
|
|
@@ -154,6 +155,10 @@ export function registerAdminCommands(
|
|
|
154
155
|
await ctx.reply("Not authorized.");
|
|
155
156
|
return;
|
|
156
157
|
}
|
|
158
|
+
// A restart that predates this process is a redelivery (or an order
|
|
159
|
+
// aimed at a daemon that is already gone) — obeying it would restart
|
|
160
|
+
// us again, and again, on every boot.
|
|
161
|
+
if (isStaleCommand(ctx.message?.date, "/restart")) return;
|
|
157
162
|
await ctx.reply("♻️ Restarting...");
|
|
158
163
|
respawnSelf("telegram /restart");
|
|
159
164
|
});
|
|
@@ -169,6 +174,8 @@ export function registerAdminCommands(
|
|
|
169
174
|
await ctx.reply("Not authorized.");
|
|
170
175
|
return;
|
|
171
176
|
}
|
|
177
|
+
// Same redelivery hazard as /restart — it also ends the process.
|
|
178
|
+
if (isStaleCommand(ctx.message?.date, "/update")) return;
|
|
172
179
|
const remote = config.update?.remote ?? "origin";
|
|
173
180
|
const branch = config.update?.branch ?? "main";
|
|
174
181
|
const sent = await ctx.reply(
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "./commands/index.js";
|
|
24
24
|
import { setAccessControl } from "./handlers/index.js";
|
|
25
25
|
import { registerMiddleware } from "./middleware.js";
|
|
26
|
+
import { confirmUpdates } from "./update-offset.js";
|
|
26
27
|
import { registerCallbacks } from "./callbacks/index.js";
|
|
27
28
|
import { log, logError } from "../../util/log.js";
|
|
28
29
|
|
|
@@ -142,6 +143,10 @@ export function createTelegramFrontend(
|
|
|
142
143
|
async stop() {
|
|
143
144
|
try {
|
|
144
145
|
await bot.stop();
|
|
146
|
+
// grammY advances the update offset on its NEXT poll, which never
|
|
147
|
+
// comes once we are shutting down — so confirm it explicitly or
|
|
148
|
+
// Telegram redelivers the command that triggered this shutdown.
|
|
149
|
+
await confirmUpdates(bot);
|
|
145
150
|
log("shutdown", "Bot disconnected");
|
|
146
151
|
} catch (err) {
|
|
147
152
|
logError("shutdown", "Bot stop error", err);
|
|
@@ -10,6 +10,7 @@ import { allowChat, revokeChat } from "./userbot.js";
|
|
|
10
10
|
import { registerChat } from "../../core/background/pulse.js";
|
|
11
11
|
import { log } from "../../util/log.js";
|
|
12
12
|
import { getSenderName } from "./handlers/index.js";
|
|
13
|
+
import { noteUpdateId } from "./update-offset.js";
|
|
13
14
|
import { noteInboundThread } from "./topics.js";
|
|
14
15
|
import { recordJoinRequest } from "./join-requests.js";
|
|
15
16
|
import { newlyAddedEmojis, recordReactionToBot } from "../../core/soul/taps.js";
|
|
@@ -26,6 +27,15 @@ import {
|
|
|
26
27
|
} from "./handlers/index.js";
|
|
27
28
|
|
|
28
29
|
export function registerMiddleware(bot: Bot, config: TalonConfig): void {
|
|
30
|
+
// ── Update-offset tracking (every update, before anything else) ──────────
|
|
31
|
+
// Telegram redelivers any update whose id was never confirmed; the
|
|
32
|
+
// shutdown path confirms this one so a process-ending command can't be
|
|
33
|
+
// served twice. See update-offset.ts.
|
|
34
|
+
bot.use((ctx, next) => {
|
|
35
|
+
noteUpdateId(ctx.update.update_id);
|
|
36
|
+
return next();
|
|
37
|
+
});
|
|
38
|
+
|
|
29
39
|
// ── History capture (runs for ALL messages, before handlers) ─────────────
|
|
30
40
|
bot.on("message", (ctx, next) => {
|
|
31
41
|
const chatId = String(ctx.chat.id);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness guard for process-ending commands.
|
|
3
|
+
*
|
|
4
|
+
* Second layer under `update-offset.ts`. Confirming the offset stops the
|
|
5
|
+
* redelivery that caused the restart loop, but it can't be guaranteed —
|
|
6
|
+
* the confirmation is a network call on the way out, and a SIGKILL, a
|
|
7
|
+
* crash, or a 409 from two pollers skips it entirely. Any command that
|
|
8
|
+
* ends the process is therefore also checked for age: it must be newer
|
|
9
|
+
* than this process, because a `/restart` issued before we booted has
|
|
10
|
+
* either already been served (redelivery) or refers to a daemon that is
|
|
11
|
+
* no longer running.
|
|
12
|
+
*
|
|
13
|
+
* Only self-terminating commands are gated. Ordinary messages sent while
|
|
14
|
+
* the daemon was down are real work and must still be processed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { logWarn } from "../../util/log.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Grace window for clock skew between Telegram's timestamps and ours,
|
|
21
|
+
* and for a command issued in the seconds around a boot.
|
|
22
|
+
*/
|
|
23
|
+
const CLOCK_SKEW_GRACE_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
/** When this process started — the cutoff a fresh command must beat. */
|
|
26
|
+
const PROCESS_START_MS = Date.now();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when a command predates this process and must not be re-executed.
|
|
30
|
+
* `messageDate` is Telegram's `message.date` (seconds since epoch).
|
|
31
|
+
*/
|
|
32
|
+
export function isStaleCommand(
|
|
33
|
+
messageDate: number | undefined,
|
|
34
|
+
command: string,
|
|
35
|
+
): boolean {
|
|
36
|
+
if (!messageDate) return false; // no timestamp — treat as live
|
|
37
|
+
const sentAtMs = messageDate * 1000;
|
|
38
|
+
if (sentAtMs >= PROCESS_START_MS - CLOCK_SKEW_GRACE_MS) return false;
|
|
39
|
+
const ageSec = Math.round((Date.now() - sentAtMs) / 1000);
|
|
40
|
+
logWarn(
|
|
41
|
+
"bot",
|
|
42
|
+
`Ignoring stale ${command} from ${ageSec}s ago — it predates this process ` +
|
|
43
|
+
`(a redelivered restart would loop the daemon).`,
|
|
44
|
+
);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-offset confirmation — the reason a `/restart` used to run twice.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's getUpdates is at-least-once: an update stays queued until a
|
|
5
|
+
* LATER call passes `offset = update_id + 1`. grammY advances that offset
|
|
6
|
+
* on its next poll, so a command that ends the process — `/restart`,
|
|
7
|
+
* `/update` — exits before the confirmation is ever sent. Telegram then
|
|
8
|
+
* redelivers it to the successor, which restarts, which never confirms
|
|
9
|
+
* either: a boot loop that survives every restart, observed live on
|
|
10
|
+
* 2026-08-22 taking the daemon down four times in a row.
|
|
11
|
+
*
|
|
12
|
+
* The fix is to confirm explicitly before exiting. This module tracks the
|
|
13
|
+
* highest update_id seen and `confirmUpdates` acknowledges it with a
|
|
14
|
+
* zero-timeout getUpdates, which is exactly what grammY's next poll would
|
|
15
|
+
* have done.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Bot } from "grammy";
|
|
19
|
+
import { log, logWarn } from "../../util/log.js";
|
|
20
|
+
|
|
21
|
+
let highestUpdateId = 0;
|
|
22
|
+
|
|
23
|
+
/** Record an update as seen. Called for every update grammY dispatches. */
|
|
24
|
+
export function noteUpdateId(updateId: number): void {
|
|
25
|
+
if (updateId > highestUpdateId) highestUpdateId = updateId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Highest update id seen this process (0 when none). Test seam. */
|
|
29
|
+
export function lastUpdateId(): number {
|
|
30
|
+
return highestUpdateId;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Test seam: forget the tracked offset. */
|
|
34
|
+
export function resetUpdateOffset(): void {
|
|
35
|
+
highestUpdateId = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tell Telegram every update seen so far is handled, so none of them is
|
|
40
|
+
* redelivered to the next process. Best-effort: this runs on the
|
|
41
|
+
* shutdown path, where a failure must never block exit.
|
|
42
|
+
*/
|
|
43
|
+
export async function confirmUpdates(bot: Bot): Promise<void> {
|
|
44
|
+
if (highestUpdateId === 0) return;
|
|
45
|
+
try {
|
|
46
|
+
await bot.api.getUpdates({
|
|
47
|
+
offset: highestUpdateId + 1,
|
|
48
|
+
limit: 1,
|
|
49
|
+
timeout: 0,
|
|
50
|
+
});
|
|
51
|
+
log("shutdown", `Confirmed Telegram updates through ${highestUpdateId}`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logWarn(
|
|
54
|
+
"shutdown",
|
|
55
|
+
`Could not confirm Telegram update offset: ${err instanceof Error ? err.message : err}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|