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,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
|
+
}
|