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.
Files changed (40) hide show
  1. package/package.json +4 -1
  2. package/prompts/whatsapp.md +58 -0
  3. package/src/backend/shared/delivery-contract.ts +1 -0
  4. package/src/core/doctor.ts +5 -0
  5. package/src/core/frontend-runtime/builtins.ts +8 -0
  6. package/src/core/mcp-hub/talon-server.ts +1 -0
  7. package/src/core/prompt/embedded-prompts.ts +2 -0
  8. package/src/core/tools/chat.ts +4 -4
  9. package/src/core/tools/history.ts +5 -5
  10. package/src/core/tools/media.ts +1 -1
  11. package/src/core/tools/members.ts +4 -4
  12. package/src/core/tools/messaging.ts +12 -11
  13. package/src/core/tools/moderation.ts +2 -2
  14. package/src/core/tools/scheduling.ts +2 -2
  15. package/src/core/tools/types.ts +1 -1
  16. package/src/frontend/factories.ts +1 -0
  17. package/src/frontend/telegram/commands/admin.ts +7 -0
  18. package/src/frontend/telegram/index.ts +5 -0
  19. package/src/frontend/telegram/middleware.ts +10 -0
  20. package/src/frontend/telegram/stale-command.ts +46 -0
  21. package/src/frontend/telegram/update-offset.ts +58 -0
  22. package/src/frontend/whatsapp/actions/chat-info.ts +228 -0
  23. package/src/frontend/whatsapp/actions/index.ts +78 -0
  24. package/src/frontend/whatsapp/actions/media.ts +248 -0
  25. package/src/frontend/whatsapp/actions/messaging.ts +281 -0
  26. package/src/frontend/whatsapp/actions/moderation.ts +231 -0
  27. package/src/frontend/whatsapp/actions/shared.ts +196 -0
  28. package/src/frontend/whatsapp/actions/types.ts +34 -0
  29. package/src/frontend/whatsapp/factory.ts +12 -0
  30. package/src/frontend/whatsapp/formatting.ts +172 -0
  31. package/src/frontend/whatsapp/identity.ts +150 -0
  32. package/src/frontend/whatsapp/index.ts +553 -0
  33. package/src/frontend/whatsapp/media-store.ts +97 -0
  34. package/src/frontend/whatsapp/message-store.ts +140 -0
  35. package/src/frontend/whatsapp/pins.ts +52 -0
  36. package/src/frontend/whatsapp/registry.ts +92 -0
  37. package/src/util/chat-id.ts +5 -0
  38. package/src/util/config.ts +43 -0
  39. package/src/util/log.ts +1 -0
  40. package/src/util/paths.ts +2 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Message-key store — the bridge between Talon's numeric message ids and
3
+ * WhatsApp's opaque string ones.
4
+ *
5
+ * Every Talon messaging tool addresses a message by number (`react`,
6
+ * `edit_message`, `delete_message`, `reply_to`, …), but WhatsApp ids look
7
+ * like `3EB0C767D26B8F3A1B2C` and the operations that act on a message
8
+ * need a whole `WAMessageKey` (id + remoteJid + fromMe + participant),
9
+ * not just the id. So each message Talon sees — inbound or sent — is
10
+ * assigned a monotonic numeric id here and remembered alongside its key.
11
+ *
12
+ * `forward_message` needs the full message, not only its key, so the
13
+ * proto is retained too. The map is bounded and evicts oldest-first: a
14
+ * long-running daemon must not accumulate every message it ever saw, and
15
+ * a model addressing a message thousands of turns back is not a case
16
+ * worth holding memory for.
17
+ */
18
+
19
+ import type { WAMessage, WAMessageKey } from "baileys";
20
+
21
+ export type StoredMessage = {
22
+ /** Talon-facing numeric id. */
23
+ msgId: number;
24
+ /** What WhatsApp needs to act on the message. */
25
+ key: WAMessageKey;
26
+ /** Talon chat id (wa_dm_… / wa_group_…). */
27
+ chatId: string;
28
+ /** Retained for forward_message, which re-sends the whole message. */
29
+ message?: WAMessage;
30
+ /** Plain text at the time it was stored (copy_message, previews). */
31
+ text: string;
32
+ senderName: string;
33
+ timestamp: number;
34
+ };
35
+
36
+ /**
37
+ * Ids start high so they can't be confused with a small ordinal the model
38
+ * might invent, and stay well inside 2^53.
39
+ */
40
+ const ID_BASE = 1_000_000;
41
+ const MAX_TRACKED = 2_000;
42
+
43
+ let nextId = ID_BASE;
44
+ const byMsgId = new Map<number, StoredMessage>();
45
+ const byWaId = new Map<string, number>();
46
+
47
+ function evictOldest(): void {
48
+ while (byMsgId.size > MAX_TRACKED) {
49
+ // Map iteration is insertion-ordered, so the first key is the oldest.
50
+ const oldest = byMsgId.keys().next();
51
+ if (oldest.done) return;
52
+ const stored = byMsgId.get(oldest.value);
53
+ byMsgId.delete(oldest.value);
54
+ if (stored?.key.id) byWaId.delete(stored.key.id);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Record a message and return its Talon numeric id. Re-recording a
60
+ * WhatsApp id already seen returns the original number, so the same
61
+ * message never gets two identities (Baileys can re-deliver on reconnect).
62
+ */
63
+ export function rememberMessage(entry: {
64
+ key: WAMessageKey;
65
+ chatId: string;
66
+ message?: WAMessage;
67
+ text?: string;
68
+ senderName?: string;
69
+ timestamp?: number;
70
+ }): number {
71
+ const waId = entry.key.id;
72
+ if (waId) {
73
+ const existing = byWaId.get(waId);
74
+ if (existing !== undefined) return existing;
75
+ }
76
+ const msgId = nextId++;
77
+ const stored: StoredMessage = {
78
+ msgId,
79
+ key: entry.key,
80
+ chatId: entry.chatId,
81
+ text: entry.text ?? "",
82
+ senderName: entry.senderName ?? "",
83
+ timestamp: entry.timestamp ?? Date.now(),
84
+ ...(entry.message ? { message: entry.message } : {}),
85
+ };
86
+ byMsgId.set(msgId, stored);
87
+ if (waId) byWaId.set(waId, msgId);
88
+ evictOldest();
89
+ return msgId;
90
+ }
91
+
92
+ /** Look up a stored message by its Talon numeric id. */
93
+ export function lookupMessage(msgId: number): StoredMessage | undefined {
94
+ return byMsgId.get(msgId);
95
+ }
96
+
97
+ /** Look up by WhatsApp's own id (reply resolution on inbound). */
98
+ export function lookupByWaId(waId: string): StoredMessage | undefined {
99
+ const msgId = byWaId.get(waId);
100
+ return msgId === undefined ? undefined : byMsgId.get(msgId);
101
+ }
102
+
103
+ /**
104
+ * Resolve a tool-supplied message id (number or digit string) to the
105
+ * WhatsApp key it names, scoped to one chat so a stale id from another
106
+ * conversation can't act on this one.
107
+ */
108
+ export function resolveKey(
109
+ value: unknown,
110
+ chatId: string,
111
+ ): { key: WAMessageKey; stored: StoredMessage } | { error: string } {
112
+ const msgId =
113
+ typeof value === "number"
114
+ ? value
115
+ : typeof value === "string"
116
+ ? Number(value)
117
+ : NaN;
118
+ if (!Number.isFinite(msgId)) {
119
+ return { error: `Invalid message_id: ${String(value)}` };
120
+ }
121
+ const stored = byMsgId.get(msgId);
122
+ if (!stored) {
123
+ return {
124
+ error:
125
+ `Unknown message_id ${msgId} — WhatsApp message ids are assigned ` +
126
+ `when Talon sees the message; only recent ones can be acted on.`,
127
+ };
128
+ }
129
+ if (stored.chatId !== chatId) {
130
+ return { error: `Message ${msgId} belongs to a different chat` };
131
+ }
132
+ return { key: stored.key, stored };
133
+ }
134
+
135
+ /** Test seam: drop every tracked message and restart the id counter. */
136
+ export function resetMessageStore(): void {
137
+ byMsgId.clear();
138
+ byWaId.clear();
139
+ nextId = ID_BASE;
140
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pinned-message bookkeeping.
3
+ *
4
+ * WhatsApp lets a client pin a message but exposes no query for what is
5
+ * pinned in a chat, so `get_pinned_messages` would have nothing to answer
6
+ * with. This records the pins Talon itself places — the set the model put
7
+ * there and may want to revisit or clear — and is deliberately honest
8
+ * about its scope: pins made from someone's phone are invisible here.
9
+ */
10
+
11
+ import type { StoredMessage } from "./message-store.js";
12
+
13
+ export type PinnedMessage = {
14
+ msgId: number;
15
+ text: string;
16
+ senderName: string;
17
+ pinnedAt: number;
18
+ };
19
+
20
+ /** Per-chat pins, newest last. Bounded — WhatsApp shows few pins anyway. */
21
+ const MAX_PINS_PER_CHAT = 25;
22
+ const pinsByChat = new Map<string, PinnedMessage[]>();
23
+
24
+ export function recordPin(chatId: string, message: StoredMessage): void {
25
+ const pins = pinsByChat.get(chatId) ?? [];
26
+ if (pins.some((pin) => pin.msgId === message.msgId)) return;
27
+ pins.push({
28
+ msgId: message.msgId,
29
+ text: message.text,
30
+ senderName: message.senderName,
31
+ pinnedAt: Date.now(),
32
+ });
33
+ while (pins.length > MAX_PINS_PER_CHAT) pins.shift();
34
+ pinsByChat.set(chatId, pins);
35
+ }
36
+
37
+ export function forgetPin(chatId: string, msgId: number): void {
38
+ const pins = pinsByChat.get(chatId);
39
+ if (!pins) return;
40
+ const remaining = pins.filter((pin) => pin.msgId !== msgId);
41
+ if (remaining.length) pinsByChat.set(chatId, remaining);
42
+ else pinsByChat.delete(chatId);
43
+ }
44
+
45
+ export function listPins(chatId: string): PinnedMessage[] {
46
+ return pinsByChat.get(chatId) ?? [];
47
+ }
48
+
49
+ /** Test seam: forget every recorded pin. */
50
+ export function resetPins(): void {
51
+ pinsByChat.clear();
52
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Chat-registry accessors — map between Talon chat ids (string and
3
+ * numeric) and the WhatsApp JID the socket needs to post back.
4
+ *
5
+ * Populated on first inbound message from a chat; consulted by the
6
+ * action handler (send_message routes by Talon chat id) and by the
7
+ * Frontend contract's numeric-keyed sendMessage/sendTyping (cron,
8
+ * pulse, heartbeat outbound).
9
+ */
10
+
11
+ import { isJidGroup, jidNormalizedUser } from "baileys";
12
+ import { deriveNumericChatId } from "../../util/chat-id.js";
13
+ import { bareId } from "./identity.js";
14
+
15
+ export type WhatsAppChatInfo = {
16
+ /** Talon chat id: wa_dm_<number> or wa_group_<id>. */
17
+ chatId: string;
18
+ /** Stable 32-bit hash of chatId (dispatcher/gateway key). */
19
+ numericChatId: number;
20
+ /** The JID the socket sends to: <number>@s.whatsapp.net or <id>@g.us. */
21
+ jid: string;
22
+ isGroup: boolean;
23
+ title?: string;
24
+ };
25
+
26
+ const byNumeric = new Map<number, WhatsAppChatInfo>();
27
+ const byString = new Map<string, WhatsAppChatInfo>();
28
+
29
+ /**
30
+ * Talon chat id for a WhatsApp JID.
31
+ *
32
+ * `canonical` is the identity's preferred id (phone number when known),
33
+ * so a DM keeps ONE chat id even when WhatsApp switches the conversation
34
+ * between phone-number and LID addressing mid-thread.
35
+ */
36
+ export function chatIdForJid(jid: string, canonical?: string): string {
37
+ // `||`, not `??`: an empty canonical id must fall back to the JID
38
+ // rather than produce the id every DM would share.
39
+ const bare = canonical || bareId(jidNormalizedUser(jid)) || "unknown";
40
+ return isJidGroup(jid) ? `wa_group_${bare}` : `wa_dm_${bare}`;
41
+ }
42
+
43
+ /**
44
+ * Record (or refresh) a chat's identity, returning its info.
45
+ *
46
+ * The send target (`jid`) is always the address the message arrived on —
47
+ * that is what WhatsApp routes — while the chat id comes from the stable
48
+ * canonical identity. A chat already known under one addressing form has
49
+ * its send target refreshed rather than being duplicated.
50
+ */
51
+ export function registerWhatsAppChat(
52
+ jid: string,
53
+ title?: string,
54
+ canonical?: string,
55
+ ): WhatsAppChatInfo {
56
+ const chatId = chatIdForJid(jid, canonical);
57
+ const sendJid = isJidGroup(jid) ? jid : jidNormalizedUser(jid);
58
+ const existing = byString.get(chatId);
59
+ if (existing) {
60
+ if (title) existing.title = title;
61
+ existing.jid = sendJid;
62
+ return existing;
63
+ }
64
+ const info: WhatsAppChatInfo = {
65
+ chatId,
66
+ numericChatId: deriveNumericChatId(chatId),
67
+ jid: sendJid,
68
+ isGroup: Boolean(isJidGroup(jid)),
69
+ ...(title ? { title } : {}),
70
+ };
71
+ byNumeric.set(info.numericChatId, info);
72
+ byString.set(info.chatId, info);
73
+ return info;
74
+ }
75
+
76
+ export function lookupWhatsAppChat(
77
+ numericChatId: number,
78
+ ): WhatsAppChatInfo | undefined {
79
+ return byNumeric.get(numericChatId);
80
+ }
81
+
82
+ export function lookupWhatsAppChatByString(
83
+ chatId: string,
84
+ ): WhatsAppChatInfo | undefined {
85
+ return byString.get(chatId);
86
+ }
87
+
88
+ /** Test seam: forget every registered chat. */
89
+ export function resetWhatsAppRegistry(): void {
90
+ byNumeric.clear();
91
+ byString.clear();
92
+ }
@@ -45,6 +45,11 @@ export function isTeamsChatId(chatId: string): boolean {
45
45
  return chatId.startsWith("teams_chat_");
46
46
  }
47
47
 
48
+ /** Check if a chat ID belongs to a WhatsApp session. */
49
+ export function isWhatsAppChatId(chatId: string): boolean {
50
+ return chatId.startsWith("wa_");
51
+ }
52
+
48
53
  /** Check if a chat ID belongs to a Discord session. */
49
54
  export function isDiscordChatId(chatId: string): boolean {
50
55
  return chatId.startsWith("discord_");
@@ -142,6 +142,7 @@ const frontendEnum = z.enum([
142
142
  "teams",
143
143
  "discord",
144
144
  "native",
145
+ "whatsapp",
145
146
  ]);
146
147
 
147
148
  /**
@@ -226,6 +227,41 @@ const discordConfigSchema = z
226
227
  })
227
228
  .strict();
228
229
 
230
+ const whatsappConfigSchema = z
231
+ .object({
232
+ /**
233
+ * JIDs or bare phone numbers (digits only, country code included)
234
+ * allowed to DM the bot. Empty array disables DM access.
235
+ */
236
+ allowedJids: z.array(z.string()).default([]),
237
+ /** Group JIDs (…@g.us) the bot may always respond in. */
238
+ allowedGroups: z.array(z.string()).default([]),
239
+ /**
240
+ * Which groups the bot serves beyond `allowedGroups`:
241
+ * - "listed" only the groups named above (default)
242
+ * - "with-allowed-user" any group containing someone from
243
+ * `allowedJids` — "the groups I'm in"
244
+ * - "all" every group the account belongs to
245
+ */
246
+ groupPolicy: z
247
+ .enum(["listed", "with-allowed-user", "all"])
248
+ .default("listed"),
249
+ /**
250
+ * In groups, when does the bot reply?
251
+ * - "mention" reply only when @mentioned or quoted (default)
252
+ * - "all" reply to every message in allowedGroups
253
+ */
254
+ respondMode: z.enum(["mention", "all"]).default("mention"),
255
+ /**
256
+ * Pair by phone-number code instead of QR: the account's number in
257
+ * E.164 digits without the plus (e.g. "353871234567"). Omit for QR.
258
+ */
259
+ pairingNumber: z.string().optional(),
260
+ /** Mark handled inbound messages as read (blue ticks). */
261
+ sendReadReceipts: z.boolean().default(true),
262
+ })
263
+ .strict();
264
+
229
265
  const playwrightConfigSchema = z.object({
230
266
  enabled: z.boolean().default(false),
231
267
  /** Browser engine: chromium (default), chrome, firefox, webkit, msedge */
@@ -502,6 +538,7 @@ const configSchema = z.object({
502
538
 
503
539
  // Discord — discord.js v14-based frontend
504
540
  discord: discordConfigSchema.optional(),
541
+ whatsapp: whatsappConfigSchema.optional(),
505
542
 
506
543
  // Native — local bridge for the Electron companion app (apps/desktop)
507
544
  native: nativeConfigSchema.optional(),
@@ -709,6 +746,12 @@ export function loadConfig(): TalonConfig {
709
746
  `Teams frontend requires "teamsWebhookUrl" in ${CONFIG_FILE}. Run "talon setup" to configure.`,
710
747
  );
711
748
  }
749
+ if (fe === "whatsapp" && !parsed.whatsapp) {
750
+ throw new Error(
751
+ `WhatsApp frontend requires a "whatsapp" config block in ${CONFIG_FILE} ` +
752
+ `(set allowedJids / allowedGroups; pairing happens interactively on first start).`,
753
+ );
754
+ }
712
755
  }
713
756
 
714
757
  const activeFrontend = frontends[0];
package/src/util/log.ts CHANGED
@@ -52,6 +52,7 @@ export type LogComponent =
52
52
  | "plugin"
53
53
  | "teams"
54
54
  | "discord"
55
+ | "whatsapp"
55
56
  | "native"
56
57
  | "mesh"
57
58
  | "config"
package/src/util/paths.ts CHANGED
@@ -56,6 +56,8 @@ export const dirs = {
56
56
  uploads: resolve(TALON_ROOT, "workspace", "uploads"),
57
57
  /** Daily logs: ~/.talon/workspace/logs/ */
58
58
  logs: resolve(TALON_ROOT, "workspace", "logs"),
59
+ /** Baileys multi-file auth state: ~/.talon/whatsapp-auth/ */
60
+ whatsappAuth: resolve(TALON_ROOT, "whatsapp-auth"),
59
61
  /** Memory: ~/.talon/workspace/memory/ */
60
62
  memory: resolve(TALON_ROOT, "workspace", "memory"),
61
63
  /** Daily memory notes: ~/.talon/workspace/memory/daily/ */