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.
Files changed (35) 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/whatsapp/actions/chat-info.ts +228 -0
  18. package/src/frontend/whatsapp/actions/index.ts +78 -0
  19. package/src/frontend/whatsapp/actions/media.ts +248 -0
  20. package/src/frontend/whatsapp/actions/messaging.ts +281 -0
  21. package/src/frontend/whatsapp/actions/moderation.ts +231 -0
  22. package/src/frontend/whatsapp/actions/shared.ts +196 -0
  23. package/src/frontend/whatsapp/actions/types.ts +34 -0
  24. package/src/frontend/whatsapp/factory.ts +12 -0
  25. package/src/frontend/whatsapp/formatting.ts +172 -0
  26. package/src/frontend/whatsapp/identity.ts +150 -0
  27. package/src/frontend/whatsapp/index.ts +553 -0
  28. package/src/frontend/whatsapp/media-store.ts +97 -0
  29. package/src/frontend/whatsapp/message-store.ts +140 -0
  30. package/src/frontend/whatsapp/pins.ts +52 -0
  31. package/src/frontend/whatsapp/registry.ts +92 -0
  32. package/src/util/chat-id.ts +5 -0
  33. package/src/util/config.ts +43 -0
  34. package/src/util/log.ts +1 -0
  35. package/src/util/paths.ts +2 -0
@@ -0,0 +1,34 @@
1
+ /**
2
+ * WhatsApp action-handler types.
3
+ *
4
+ * Each domain module exports a `WhatsAppActionHandlers` map keyed by
5
+ * action name. Handlers receive the request body, the numeric chat id,
6
+ * and a shared context carrying the live socket, the gateway, the chat
7
+ * resolved from the id, and the per-handler scheduled-send timers.
8
+ *
9
+ * `chat` is non-null for every action the dispatcher routes here except
10
+ * the store-only scheduling ones, so handlers can use `ctx.chat!`.
11
+ */
12
+
13
+ import type { WASocket } from "baileys";
14
+ import type { Gateway } from "../../../core/engine/gateway.js";
15
+ import type { ActionResult } from "../../../core/types.js";
16
+ import type { WhatsAppChatInfo } from "../registry.js";
17
+
18
+ export interface WhatsAppActionContext {
19
+ /** The connected socket. Reconnects replace it, so never capture it. */
20
+ sock: WASocket;
21
+ gateway: Gateway;
22
+ /** Chat resolved from the numeric id; null only for store-only actions. */
23
+ chat: WhatsAppChatInfo | null;
24
+ /** Active scheduled-message timers, keyed by schedule id. */
25
+ scheduledMessages: Map<string, ReturnType<typeof setTimeout>>;
26
+ }
27
+
28
+ export type WhatsAppActionHandler = (
29
+ body: Record<string, unknown>,
30
+ chatId: number,
31
+ ctx: WhatsAppActionContext,
32
+ ) => Promise<ActionResult | null> | ActionResult | null;
33
+
34
+ export type WhatsAppActionHandlers = Record<string, WhatsAppActionHandler>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * WhatsApp frontend factory — attaches the create half of the registry
3
+ * entry (descriptor registered in core/frontend-runtime). The
4
+ * implementation (Baileys socket) loads only when created.
5
+ */
6
+
7
+ import { attachFrontendCreate } from "../../core/frontend-runtime/index.js";
8
+
9
+ attachFrontendCreate("whatsapp", async (config, gateway) => {
10
+ const { createWhatsAppFrontend } = await import("./index.js");
11
+ return createWhatsAppFrontend(config, gateway);
12
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Markdown → WhatsApp text.
3
+ *
4
+ * The model writes standard Markdown; WhatsApp renders its own dialect
5
+ * (*bold*, _italic_, ~strike~, `code`, ```blocks```, "> " quotes, and
6
+ * "- "/"1. " lists). Translating between them by running regexes over the
7
+ * raw string is how you end up bolding the asterisks inside a code block
8
+ * or eating the underscores in a URL, so this walks marked's token tree
9
+ * instead: code spans and fenced blocks are separate token types by the
10
+ * time we see them, and emphasis is structural rather than textual.
11
+ *
12
+ * WhatsApp has no escape syntax, so literal delimiters in the source
13
+ * survive as themselves — there is nothing to escape into.
14
+ */
15
+
16
+ import { marked, type Token, type Tokens } from "marked";
17
+ import { splitMessage } from "../telegram/formatting.js";
18
+
19
+ /** WhatsApp accepts far more, but long bubbles read badly on a phone. */
20
+ export const WHATSAPP_MAX_TEXT = 4096;
21
+
22
+ /** Rendered width of a table cell, for the monospace fallback. */
23
+ const MAX_TABLE_CELL = 24;
24
+
25
+ function renderInline(tokens: Token[] | undefined): string {
26
+ if (!tokens) return "";
27
+ return tokens.map(renderInlineToken).join("");
28
+ }
29
+
30
+ function renderInlineToken(token: Token): string {
31
+ switch (token.type) {
32
+ case "strong":
33
+ return `*${renderInline((token as Tokens.Strong).tokens)}*`;
34
+ case "em":
35
+ return `_${renderInline((token as Tokens.Em).tokens)}_`;
36
+ case "del":
37
+ return `~${renderInline((token as Tokens.Del).tokens)}~`;
38
+ case "codespan":
39
+ // WhatsApp renders single-backtick spans as inline monospace.
40
+ return `\`${(token as Tokens.Codespan).text}\``;
41
+ case "link": {
42
+ const link = token as Tokens.Link;
43
+ const label = renderInline(link.tokens);
44
+ // WhatsApp auto-links bare URLs but renders no anchor text, so a
45
+ // labelled link has to show both halves or the destination is lost.
46
+ return !label || label === link.href
47
+ ? link.href
48
+ : `${label} (${link.href})`;
49
+ }
50
+ case "image": {
51
+ const image = token as Tokens.Image;
52
+ return image.text ? `${image.text} (${image.href})` : image.href;
53
+ }
54
+ case "br":
55
+ return "\n";
56
+ case "escape":
57
+ return (token as Tokens.Escape).text;
58
+ case "html":
59
+ // Inline HTML has no WhatsApp equivalent — emit the source text.
60
+ return (token as Tokens.HTML).raw;
61
+ default:
62
+ return (token as { text?: string }).text ?? "";
63
+ }
64
+ }
65
+
66
+ function renderListItems(list: Tokens.List, depth: number): string {
67
+ const indent = " ".repeat(depth);
68
+ return list.items
69
+ .map((item, index) => {
70
+ const marker = list.ordered ? `${Number(list.start || 1) + index}.` : "-";
71
+ // A task item's checkbox is structural in Markdown and cosmetic in
72
+ // WhatsApp; render it as a box so the state still reads.
73
+ const check = item.task ? (item.checked ? "[x] " : "[ ] ") : "";
74
+ const body = renderBlocks(item.tokens ?? [], depth + 1).trimEnd();
75
+ const [first = "", ...rest] = body.split("\n");
76
+ const head = `${indent}${marker} ${check}${first}`;
77
+ // Continuation lines of a wrapped item line up under its text.
78
+ const tail = rest.map((line) => `${indent} ${line}`);
79
+ return [head, ...tail].join("\n");
80
+ })
81
+ .join("\n");
82
+ }
83
+
84
+ /**
85
+ * Tables have no WhatsApp equivalent. A monospace block keeps the columns
86
+ * aligned, which is the part that carries the meaning.
87
+ */
88
+ function renderTable(table: Tokens.Table): string {
89
+ const clip = (s: string): string =>
90
+ s.length > MAX_TABLE_CELL ? `${s.slice(0, MAX_TABLE_CELL - 1)}…` : s;
91
+ const header = table.header.map((cell) => clip(renderInline(cell.tokens)));
92
+ const rows = table.rows.map((row) =>
93
+ row.map((cell) => clip(renderInline(cell.tokens))),
94
+ );
95
+ const widths = header.map((cell, i) =>
96
+ Math.max(cell.length, ...rows.map((row) => (row[i] ?? "").length)),
97
+ );
98
+ const line = (cells: string[]): string =>
99
+ cells
100
+ .map((cell, i) => cell.padEnd(widths[i] ?? 0))
101
+ .join(" ")
102
+ .trimEnd();
103
+ const separator = widths.map((width) => "─".repeat(width)).join(" ");
104
+ return ["```", line(header), separator, ...rows.map(line), "```"].join("\n");
105
+ }
106
+
107
+ function renderBlockToken(token: Token, depth: number): string {
108
+ switch (token.type) {
109
+ case "heading":
110
+ // WhatsApp has no headings; bold is the only emphasis that reads
111
+ // as a title in a chat bubble.
112
+ return `*${renderInline((token as Tokens.Heading).tokens)}*`;
113
+ case "paragraph":
114
+ return renderInline((token as Tokens.Paragraph).tokens);
115
+ case "text": {
116
+ const text = token as Tokens.Text;
117
+ return text.tokens ? renderInline(text.tokens) : text.text;
118
+ }
119
+ case "code": {
120
+ const code = token as Tokens.Code;
121
+ return `\`\`\`\n${code.text}\n\`\`\``;
122
+ }
123
+ case "blockquote": {
124
+ const quote = renderBlocks((token as Tokens.Blockquote).tokens, depth);
125
+ return quote
126
+ .split("\n")
127
+ .map((line) => `> ${line}`.trimEnd())
128
+ .join("\n");
129
+ }
130
+ case "list":
131
+ return renderListItems(token as Tokens.List, depth);
132
+ case "table":
133
+ return renderTable(token as Tokens.Table);
134
+ case "hr":
135
+ return "──────────";
136
+ case "html":
137
+ return (token as Tokens.HTML).raw.trim();
138
+ case "space":
139
+ return "";
140
+ default:
141
+ return (token as { text?: string }).text ?? "";
142
+ }
143
+ }
144
+
145
+ function renderBlocks(tokens: Token[], depth = 0): string {
146
+ const parts: string[] = [];
147
+ for (const token of tokens) {
148
+ const rendered = renderBlockToken(token, depth);
149
+ if (rendered !== "") parts.push(rendered);
150
+ }
151
+ // Inside a list item, blocks stack tightly; at top level they get the
152
+ // blank line that separates paragraphs in a bubble.
153
+ return parts.join(depth > 0 ? "\n" : "\n\n");
154
+ }
155
+
156
+ /**
157
+ * Translate Markdown into WhatsApp's formatting dialect. Falls back to
158
+ * the original text if the Markdown can't be parsed — an unformatted
159
+ * message beats a dropped one.
160
+ */
161
+ export function toWhatsAppText(text: string): string {
162
+ try {
163
+ return renderBlocks(marked.lexer(text)).trim();
164
+ } catch {
165
+ return text;
166
+ }
167
+ }
168
+
169
+ /** Translate to the WhatsApp dialect, then split into sendable chunks. */
170
+ export function toWhatsAppChunks(text: string): string[] {
171
+ return splitMessage(toWhatsAppText(text), WHATSAPP_MAX_TEXT);
172
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Identity resolution — the LID/phone-number duality.
3
+ *
4
+ * WhatsApp addresses people two ways. The historical form is the phone
5
+ * number (`353834733284@s.whatsapp.net`, "PN"); the newer privacy form
6
+ * is a linked identity (`180753715482747@lid`, "LID") that hides the
7
+ * number. Which one arrives depends on the sender's privacy settings and
8
+ * the chat's addressing mode, and neither is derivable from the other —
9
+ * they are looked up.
10
+ *
11
+ * This matters because every allowlist a human writes is phone numbers.
12
+ * Matching those against a raw `remoteJid` silently ignores anyone whose
13
+ * messages arrive as a LID — which is exactly what happened on the first
14
+ * real message to the live account.
15
+ *
16
+ * So every identity resolves to BOTH forms and matches on either.
17
+ * Baileys carries the counterpart on the message key (`remoteJidAlt` /
18
+ * `participantAlt`) when it knows it, and its signal store can look one
19
+ * up otherwise; results are cached because the mapping is stable.
20
+ */
21
+
22
+ import { isLidUser, jidNormalizedUser, type WASocket } from "baileys";
23
+
24
+ export type Identity = {
25
+ /** Phone-number form, digits only, when known: "353834733284". */
26
+ phone?: string;
27
+ /** LID form, digits only, when known: "180753715482747". */
28
+ lid?: string;
29
+ /** Every bare id this person is known by — what allowlists match on. */
30
+ ids: string[];
31
+ };
32
+
33
+ /**
34
+ * Bare identity of a JID or phone string: strips server, device, and
35
+ * "+". Returns undefined for anything that leaves no digits — WhatsApp
36
+ * hands out empty `participant` fields on DMs, and an empty id that
37
+ * flows onward silently collapses every conversation into one.
38
+ */
39
+ export function bareId(jidOrNumber: string | null | undefined): string {
40
+ return jidOrNumber?.split("@")[0].split(":")[0].replace(/^\+/, "") ?? "";
41
+ }
42
+
43
+ /** Bare id, or undefined when there is nothing usable to key on. */
44
+ function usableId(jidOrNumber: string | null | undefined): string | undefined {
45
+ const bare = bareId(jidOrNumber).trim();
46
+ return bare.length > 0 ? bare : undefined;
47
+ }
48
+
49
+ /** LID ↔ PN is stable for the life of an account; cache both directions. */
50
+ const cache = new Map<string, Identity>();
51
+
52
+ function remember(identity: Identity): Identity {
53
+ // Nothing to key on means nothing worth caching — the next message
54
+ // gets a fresh attempt at resolving this person.
55
+ for (const id of identity.ids) cache.set(id, identity);
56
+ return identity;
57
+ }
58
+
59
+ type LidStore = {
60
+ getPNForLID(lid: string): Promise<string | null>;
61
+ getLIDForPN(pn: string): Promise<string | null>;
62
+ };
63
+
64
+ function lidStore(
65
+ sock: Pick<WASocket, "signalRepository"> | null,
66
+ ): LidStore | undefined {
67
+ return (
68
+ sock?.signalRepository as unknown as { lidMapping?: LidStore } | undefined
69
+ )?.lidMapping;
70
+ }
71
+
72
+ /**
73
+ * Resolve a user JID into every form they can be addressed by.
74
+ *
75
+ * `altJid` is Baileys' counterpart hint from the message key — free when
76
+ * present. Otherwise the signal store is asked, a local lookup that can
77
+ * still miss (the mapping is learned, not computed). A one-sided answer
78
+ * is returned rather than failing, so matching degrades to the form we
79
+ * do have instead of dropping the message.
80
+ */
81
+ export async function resolveIdentity(
82
+ sock: Pick<WASocket, "signalRepository"> | null,
83
+ jid: string,
84
+ altJid?: string | null,
85
+ ): Promise<Identity> {
86
+ const bare = usableId(jid);
87
+ const cached = bare ? cache.get(bare) : undefined;
88
+ if (cached) return cached;
89
+
90
+ const isLid = Boolean(isLidUser(jid));
91
+ const identity: Identity = isLid
92
+ ? { lid: bare, ids: [] }
93
+ : { phone: bare, ids: [] };
94
+
95
+ const altBare = usableId(altJid);
96
+ if (altBare) {
97
+ if (isLid) identity.phone = altBare;
98
+ else identity.lid = altBare;
99
+ } else {
100
+ const store = lidStore(sock);
101
+ if (store) {
102
+ try {
103
+ const counterpart = isLid
104
+ ? await store.getPNForLID(jidNormalizedUser(jid))
105
+ : await store.getLIDForPN(jidNormalizedUser(jid));
106
+ const counterpartId = usableId(counterpart);
107
+ if (counterpartId) {
108
+ if (isLid) identity.phone = counterpartId;
109
+ else identity.lid = counterpartId;
110
+ }
111
+ } catch {
112
+ // A miss is normal before the mapping is learned — match on the
113
+ // form we have rather than failing the message.
114
+ }
115
+ }
116
+ }
117
+
118
+ identity.ids = [identity.phone, identity.lid].filter((id): id is string =>
119
+ Boolean(id),
120
+ );
121
+ return remember(identity);
122
+ }
123
+
124
+ /**
125
+ * True when an identity is on an allowlist of bare ids. Either form
126
+ * counts: the allowlist is written in phone numbers, the message may
127
+ * arrive as a LID, and both name the same person.
128
+ */
129
+ export function identityAllowed(
130
+ identity: Identity,
131
+ allowed: ReadonlySet<string>,
132
+ ): boolean {
133
+ return identity.ids.some((id) => allowed.has(id));
134
+ }
135
+
136
+ /**
137
+ * The stable, human-meaningful id for a person: their phone number when
138
+ * known, else the LID. Chat ids are built from this so a conversation
139
+ * keeps one identity even as WhatsApp switches addressing form.
140
+ */
141
+ export function canonicalId(identity: Identity): string | undefined {
142
+ // Deliberately not `??`: an empty string is "no id", not a value, and
143
+ // letting one through builds a chat id every DM would share.
144
+ return identity.phone || identity.lid || undefined;
145
+ }
146
+
147
+ /** Test seam: forget every resolved identity. */
148
+ export function resetIdentityCache(): void {
149
+ cache.clear();
150
+ }