talon-agent 3.24.1 → 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/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
|
@@ -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
|
+
}
|
package/src/util/chat-id.ts
CHANGED
|
@@ -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_");
|
package/src/util/config.ts
CHANGED
|
@@ -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
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/ */
|