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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messaging actions — text sends, replies, reactions, edits, deletes,
|
|
3
|
+
* forwards, pins, presence, and scheduled sends.
|
|
4
|
+
*
|
|
5
|
+
* Every message-addressed action goes through the message store: the
|
|
6
|
+
* model names a message by Talon's numeric id, WhatsApp needs the whole
|
|
7
|
+
* key, and `resolveKey` is the single place that translation happens.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { proto } from "baileys";
|
|
12
|
+
import { log } from "../../../util/log.js";
|
|
13
|
+
import { toWhatsAppText } from "../formatting.js";
|
|
14
|
+
import {
|
|
15
|
+
lookupMessage,
|
|
16
|
+
rememberMessage,
|
|
17
|
+
resolveKey,
|
|
18
|
+
} from "../message-store.js";
|
|
19
|
+
import { recordPin, listPins, forgetPin } from "../pins.js";
|
|
20
|
+
import { resolveQuoted, sendContent, sendText, tryAction } from "./shared.js";
|
|
21
|
+
import type { WhatsAppActionHandlers } from "./types.js";
|
|
22
|
+
|
|
23
|
+
/** WhatsApp keeps a pin for 24h, 7d, or 30d — no indefinite option. */
|
|
24
|
+
const PIN_DURATIONS = [86_400, 604_800, 2_592_000] as const;
|
|
25
|
+
|
|
26
|
+
/** Presence strings the `send_chat_action` verbs map onto. */
|
|
27
|
+
const PRESENCE_BY_ACTION: Record<string, "composing" | "recording" | "paused"> =
|
|
28
|
+
{
|
|
29
|
+
typing: "composing",
|
|
30
|
+
upload_photo: "composing",
|
|
31
|
+
upload_document: "composing",
|
|
32
|
+
upload_video: "composing",
|
|
33
|
+
record_voice: "recording",
|
|
34
|
+
record_audio: "recording",
|
|
35
|
+
upload_voice: "recording",
|
|
36
|
+
upload_audio: "recording",
|
|
37
|
+
cancel: "paused",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const messagingHandlers: WhatsAppActionHandlers = {
|
|
41
|
+
send_message: (body, _chatId, ctx) =>
|
|
42
|
+
tryAction("send_message", async () => {
|
|
43
|
+
const text = String(body.text ?? "");
|
|
44
|
+
if (!text.trim()) return { ok: true };
|
|
45
|
+
return sendText(
|
|
46
|
+
ctx,
|
|
47
|
+
ctx.chat!,
|
|
48
|
+
text,
|
|
49
|
+
resolveQuoted(body, ctx.chat!.chatId),
|
|
50
|
+
);
|
|
51
|
+
}),
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* WhatsApp reserves interactive buttons for Business API senders, so a
|
|
55
|
+
* personal account renders the choices as a numbered list. The user can
|
|
56
|
+
* still answer ("2"), which is what the buttons were for.
|
|
57
|
+
*/
|
|
58
|
+
send_message_with_buttons: (body, _chatId, ctx) =>
|
|
59
|
+
tryAction("send_message_with_buttons", async () => {
|
|
60
|
+
const text = String(body.text ?? "");
|
|
61
|
+
const rows = (body.rows ?? body.buttons) as
|
|
62
|
+
| Array<Array<{ text?: string; url?: string; callback_data?: string }>>
|
|
63
|
+
| undefined;
|
|
64
|
+
const options = (rows ?? [])
|
|
65
|
+
.flat()
|
|
66
|
+
.map((button, index) => {
|
|
67
|
+
const label = String(button.text ?? "").trim();
|
|
68
|
+
if (!label) return null;
|
|
69
|
+
return button.url
|
|
70
|
+
? `${index + 1}. ${label} — ${button.url}`
|
|
71
|
+
: `${index + 1}. ${label}`;
|
|
72
|
+
})
|
|
73
|
+
.filter((line): line is string => line !== null);
|
|
74
|
+
const composed = options.length
|
|
75
|
+
? `${text}\n\n${options.join("\n")}`
|
|
76
|
+
: text;
|
|
77
|
+
if (!composed.trim()) return { ok: true };
|
|
78
|
+
return sendText(
|
|
79
|
+
ctx,
|
|
80
|
+
ctx.chat!,
|
|
81
|
+
composed,
|
|
82
|
+
resolveQuoted(body, ctx.chat!.chatId),
|
|
83
|
+
);
|
|
84
|
+
}),
|
|
85
|
+
|
|
86
|
+
react: (body, _chatId, ctx) =>
|
|
87
|
+
tryAction("react", async () => {
|
|
88
|
+
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
89
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
90
|
+
// An empty emoji clears the reaction — WhatsApp's own semantics,
|
|
91
|
+
// and the only way to take one back.
|
|
92
|
+
await ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
93
|
+
react: { text: String(body.emoji ?? ""), key: resolved.key },
|
|
94
|
+
});
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
edit_message: (body, _chatId, ctx) =>
|
|
99
|
+
tryAction("edit_message", async () => {
|
|
100
|
+
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
101
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
102
|
+
const text = String(body.text ?? "");
|
|
103
|
+
if (!text.trim()) {
|
|
104
|
+
return { ok: false, error: "edit_message: text is required" };
|
|
105
|
+
}
|
|
106
|
+
await ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
107
|
+
text: toWhatsAppText(text),
|
|
108
|
+
edit: resolved.key,
|
|
109
|
+
});
|
|
110
|
+
return { ok: true, message_id: resolved.stored.msgId };
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
delete_message: (body, _chatId, ctx) =>
|
|
114
|
+
tryAction("delete_message", async () => {
|
|
115
|
+
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
116
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
117
|
+
await ctx.sock.sendMessage(ctx.chat!.jid, { delete: resolved.key });
|
|
118
|
+
forgetPin(ctx.chat!.chatId, resolved.stored.msgId);
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
forward_message: (body, _chatId, ctx) =>
|
|
123
|
+
tryAction("forward_message", async () => {
|
|
124
|
+
const resolved = resolveKey(
|
|
125
|
+
body.message_id ?? body.from_message_id,
|
|
126
|
+
ctx.chat!.chatId,
|
|
127
|
+
);
|
|
128
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
129
|
+
const message = resolved.stored.message;
|
|
130
|
+
if (!message) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `Message ${resolved.stored.msgId} is no longer retained in full — cannot forward`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return sendContent(ctx, ctx.chat!, { forward: message });
|
|
137
|
+
}),
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* WhatsApp has no server-side copy, so a copy is a fresh send of the
|
|
141
|
+
* stored text — which is what "copy without attribution" means anyway.
|
|
142
|
+
*/
|
|
143
|
+
copy_message: (body, _chatId, ctx) =>
|
|
144
|
+
tryAction("copy_message", async () => {
|
|
145
|
+
const resolved = resolveKey(
|
|
146
|
+
body.message_id ?? body.from_message_id,
|
|
147
|
+
ctx.chat!.chatId,
|
|
148
|
+
);
|
|
149
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
150
|
+
const text = String(body.caption ?? resolved.stored.text ?? "");
|
|
151
|
+
if (!text.trim()) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
error: `Message ${resolved.stored.msgId} has no text to copy`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return sendText(ctx, ctx.chat!, text);
|
|
158
|
+
}),
|
|
159
|
+
|
|
160
|
+
pin_message: (body, _chatId, ctx) =>
|
|
161
|
+
tryAction("pin_message", async () => {
|
|
162
|
+
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
163
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
164
|
+
// WhatsApp pins expire; pick the longest window unless asked for less.
|
|
165
|
+
const requested = Number(body.seconds ?? 0);
|
|
166
|
+
const time =
|
|
167
|
+
PIN_DURATIONS.find((d) => d >= requested) ??
|
|
168
|
+
PIN_DURATIONS[PIN_DURATIONS.length - 1];
|
|
169
|
+
await ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
170
|
+
pin: resolved.key,
|
|
171
|
+
type: proto.PinInChat.Type.PIN_FOR_ALL,
|
|
172
|
+
time,
|
|
173
|
+
});
|
|
174
|
+
recordPin(ctx.chat!.chatId, resolved.stored);
|
|
175
|
+
return { ok: true };
|
|
176
|
+
}),
|
|
177
|
+
|
|
178
|
+
unpin_message: (body, _chatId, ctx) =>
|
|
179
|
+
tryAction("unpin_message", async () => {
|
|
180
|
+
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
181
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
182
|
+
await ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
183
|
+
pin: resolved.key,
|
|
184
|
+
type: proto.PinInChat.Type.UNPIN_FOR_ALL,
|
|
185
|
+
});
|
|
186
|
+
forgetPin(ctx.chat!.chatId, resolved.stored.msgId);
|
|
187
|
+
return { ok: true };
|
|
188
|
+
}),
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* WhatsApp exposes no "list pinned" query, so this reports the pins
|
|
192
|
+
* this process placed — the ones the model itself is responsible for.
|
|
193
|
+
*/
|
|
194
|
+
get_pinned_messages: (_body, _chatId, ctx) => {
|
|
195
|
+
const pins = listPins(ctx.chat!.chatId);
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
text: pins.length
|
|
199
|
+
? pins
|
|
200
|
+
.map(
|
|
201
|
+
(pin) =>
|
|
202
|
+
`[${pin.senderName || "?"}] msg_id:${pin.msgId}: ${pin.text.slice(0, 200)}`,
|
|
203
|
+
)
|
|
204
|
+
.join("\n")
|
|
205
|
+
: "No messages pinned by Talon in this chat.",
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
send_chat_action: (body, _chatId, ctx) =>
|
|
210
|
+
tryAction("send_chat_action", async () => {
|
|
211
|
+
const requested = String(
|
|
212
|
+
body.action_type ?? body.chat_action ?? "typing",
|
|
213
|
+
);
|
|
214
|
+
const presence = PRESENCE_BY_ACTION[requested] ?? "composing";
|
|
215
|
+
await ctx.sock.sendPresenceUpdate(presence, ctx.chat!.jid);
|
|
216
|
+
return { ok: true };
|
|
217
|
+
}),
|
|
218
|
+
|
|
219
|
+
// ── Scheduled sends ──────────────────────────────────────────────────
|
|
220
|
+
// WhatsApp has no server-side scheduling, so the daemon holds the timer
|
|
221
|
+
// (same shape as the telegram/discord frontends).
|
|
222
|
+
schedule_message: (body, _chatId, ctx) =>
|
|
223
|
+
tryAction("schedule_message", async () => {
|
|
224
|
+
const text = String(body.text ?? "");
|
|
225
|
+
const seconds = Number(body.delay_seconds ?? 0);
|
|
226
|
+
if (!text.trim()) {
|
|
227
|
+
return { ok: false, error: "schedule_message: text is required" };
|
|
228
|
+
}
|
|
229
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: "schedule_message: delay_seconds must be a positive number",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const id = randomUUID();
|
|
236
|
+
const chat = ctx.chat!;
|
|
237
|
+
const timer = setTimeout(() => {
|
|
238
|
+
ctx.scheduledMessages.delete(id);
|
|
239
|
+
void sendText(ctx, chat, text).catch(() => {});
|
|
240
|
+
}, seconds * 1000);
|
|
241
|
+
timer.unref?.();
|
|
242
|
+
ctx.scheduledMessages.set(id, timer);
|
|
243
|
+
log(
|
|
244
|
+
"whatsapp",
|
|
245
|
+
`Scheduled message ${id} for ${chat.chatId} in ${seconds}s`,
|
|
246
|
+
);
|
|
247
|
+
return { ok: true, id, text: `Scheduled in ${seconds}s (id ${id})` };
|
|
248
|
+
}),
|
|
249
|
+
|
|
250
|
+
cancel_scheduled: (body, _chatId, ctx) => {
|
|
251
|
+
const id = String(body.id ?? "");
|
|
252
|
+
const timer = ctx.scheduledMessages.get(id);
|
|
253
|
+
if (!timer) return { ok: false, error: `No scheduled message ${id}` };
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
ctx.scheduledMessages.delete(id);
|
|
256
|
+
return { ok: true };
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
list_scheduled: (_body, _chatId, ctx) => ({
|
|
260
|
+
ok: true,
|
|
261
|
+
text: ctx.scheduledMessages.size
|
|
262
|
+
? [...ctx.scheduledMessages.keys()].join("\n")
|
|
263
|
+
: "No scheduled messages.",
|
|
264
|
+
}),
|
|
265
|
+
|
|
266
|
+
/** Served from the message store — WhatsApp has no fetch-by-id API. */
|
|
267
|
+
get_message_by_id: (body, _chatId, ctx) => {
|
|
268
|
+
const raw = body.message_id;
|
|
269
|
+
const msgId = typeof raw === "number" ? raw : Number(raw);
|
|
270
|
+
const stored = Number.isFinite(msgId) ? lookupMessage(msgId) : undefined;
|
|
271
|
+
if (!stored || stored.chatId !== ctx.chat!.chatId) {
|
|
272
|
+
return { ok: false, error: `Unknown message_id ${String(raw)}` };
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
text: `[${stored.senderName || "?"}] msg_id:${stored.msgId}: ${stored.text}`,
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export { rememberMessage };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group administration — the `moderate` tool's op switch.
|
|
3
|
+
*
|
|
4
|
+
* WhatsApp's admin model is narrower than Telegram's: there is no ban
|
|
5
|
+
* list (removal is the only eviction), no per-member mute (a group is
|
|
6
|
+
* either open or admins-only), and no forum topics. Ops that have a
|
|
7
|
+
* faithful WhatsApp equivalent are mapped to it; the rest report what
|
|
8
|
+
* WhatsApp actually offers instead, so the model can pick another route
|
|
9
|
+
* rather than retrying something the platform cannot do.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { toUserJid, tryAction } from "./shared.js";
|
|
13
|
+
import type { WhatsAppActionHandlers } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Ops that act on one member. */
|
|
16
|
+
const PARTICIPANT_OPS: Record<string, "remove" | "add" | "promote" | "demote"> =
|
|
17
|
+
{
|
|
18
|
+
ban: "remove",
|
|
19
|
+
kick: "remove",
|
|
20
|
+
remove: "remove",
|
|
21
|
+
unban: "add",
|
|
22
|
+
add: "add",
|
|
23
|
+
promote: "promote",
|
|
24
|
+
demote: "demote",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const moderationHandlers: WhatsAppActionHandlers = {
|
|
28
|
+
moderate: (body, _chatId, ctx) =>
|
|
29
|
+
tryAction("moderate", async () => {
|
|
30
|
+
const op = String(body.op ?? "");
|
|
31
|
+
const chat = ctx.chat!;
|
|
32
|
+
const { sock } = ctx;
|
|
33
|
+
|
|
34
|
+
const participantOp = PARTICIPANT_OPS[op];
|
|
35
|
+
if (participantOp) {
|
|
36
|
+
if (!chat.isGroup) {
|
|
37
|
+
return { ok: false, error: `moderate(${op}) only applies to groups` };
|
|
38
|
+
}
|
|
39
|
+
const jid = toUserJid(body.user_id);
|
|
40
|
+
if (!jid) {
|
|
41
|
+
return { ok: false, error: `moderate(${op}) requires user_id` };
|
|
42
|
+
}
|
|
43
|
+
const [result] = await sock.groupParticipantsUpdate(
|
|
44
|
+
chat.jid,
|
|
45
|
+
[jid],
|
|
46
|
+
participantOp,
|
|
47
|
+
);
|
|
48
|
+
// Baileys reports per-participant status codes rather than throwing:
|
|
49
|
+
// 403 is "privacy settings blocked the add", 408 "not on WhatsApp".
|
|
50
|
+
const status = String(result?.status ?? "200");
|
|
51
|
+
if (status !== "200") {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: `WhatsApp refused ${op} for ${jid} (status ${status})`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
text:
|
|
60
|
+
op === "ban"
|
|
61
|
+
? `Removed ${jid} — WhatsApp has no ban list, so they can rejoin via an invite.`
|
|
62
|
+
: `${op} applied to ${jid}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
switch (op) {
|
|
67
|
+
case "mute":
|
|
68
|
+
case "set_permissions": {
|
|
69
|
+
if (!chat.isGroup) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `moderate(${op}) only applies to groups`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (body.user_id) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error:
|
|
79
|
+
"WhatsApp has no per-member mute — mute the whole group " +
|
|
80
|
+
"(admins-only mode) or remove the member.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Announcement mode is WhatsApp's whole-group mute.
|
|
84
|
+
const permissions = (body.permissions ?? {}) as {
|
|
85
|
+
send_messages?: boolean;
|
|
86
|
+
};
|
|
87
|
+
const open =
|
|
88
|
+
op === "mute" ? false : permissions.send_messages !== false;
|
|
89
|
+
await sock.groupSettingUpdate(
|
|
90
|
+
chat.jid,
|
|
91
|
+
open ? "not_announcement" : "announcement",
|
|
92
|
+
);
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
text: open
|
|
96
|
+
? "All members can send messages."
|
|
97
|
+
: "Only admins can send messages.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case "unmute": {
|
|
102
|
+
if (!chat.isGroup) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: "moderate(unmute) only applies to groups",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
await sock.groupSettingUpdate(chat.jid, "not_announcement");
|
|
109
|
+
return { ok: true, text: "All members can send messages." };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case "create_invite_link": {
|
|
113
|
+
if (!chat.isGroup) {
|
|
114
|
+
return { ok: false, error: "Only groups have invite links" };
|
|
115
|
+
}
|
|
116
|
+
const code = await sock.groupInviteCode(chat.jid);
|
|
117
|
+
return { ok: true, text: `https://chat.whatsapp.com/${code}` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "revoke_invite_link": {
|
|
121
|
+
if (!chat.isGroup) {
|
|
122
|
+
return { ok: false, error: "Only groups have invite links" };
|
|
123
|
+
}
|
|
124
|
+
const code = await sock.groupRevokeInvite(chat.jid);
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
text: `Old link revoked. New link: https://chat.whatsapp.com/${code}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case "list_join_requests": {
|
|
132
|
+
if (!chat.isGroup) {
|
|
133
|
+
return { ok: false, error: "Only groups have join requests" };
|
|
134
|
+
}
|
|
135
|
+
const requests = await sock.groupRequestParticipantsList(chat.jid);
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
text: requests.length
|
|
139
|
+
? requests
|
|
140
|
+
.map(
|
|
141
|
+
(r) =>
|
|
142
|
+
`${String(r.jid).split("@")[0]} (${r.request_method ?? "?"})`,
|
|
143
|
+
)
|
|
144
|
+
.join("\n")
|
|
145
|
+
: "No pending join requests.",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case "approve_join_request":
|
|
150
|
+
case "decline_join_request": {
|
|
151
|
+
if (!chat.isGroup) {
|
|
152
|
+
return { ok: false, error: "Only groups have join requests" };
|
|
153
|
+
}
|
|
154
|
+
const jid = toUserJid(body.user_id);
|
|
155
|
+
if (!jid) {
|
|
156
|
+
return { ok: false, error: `moderate(${op}) requires user_id` };
|
|
157
|
+
}
|
|
158
|
+
await sock.groupRequestParticipantsUpdate(
|
|
159
|
+
chat.jid,
|
|
160
|
+
[jid],
|
|
161
|
+
op === "approve_join_request" ? "approve" : "reject",
|
|
162
|
+
);
|
|
163
|
+
return { ok: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case "set_chat_photo": {
|
|
167
|
+
const source = body.file_path ?? body.url;
|
|
168
|
+
if (!source) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
error: "moderate(set_chat_photo) requires file_path or url",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
await sock.updateProfilePicture(chat.jid, { url: String(source) });
|
|
175
|
+
return { ok: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case "delete_chat_photo":
|
|
179
|
+
await sock.removeProfilePicture(chat.jid);
|
|
180
|
+
return { ok: true };
|
|
181
|
+
|
|
182
|
+
case "leave_chat": {
|
|
183
|
+
if (!chat.isGroup) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
error:
|
|
187
|
+
"Cannot leave a direct message — block the contact instead",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
await sock.groupLeave(chat.jid);
|
|
191
|
+
return { ok: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case "unpin_all": {
|
|
195
|
+
// WhatsApp unpins individually; clear the ones Talon placed.
|
|
196
|
+
const { listPins } = await import("../pins.js");
|
|
197
|
+
const pins = listPins(chat.chatId);
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
text:
|
|
201
|
+
pins.length === 0
|
|
202
|
+
? "No Talon-placed pins to clear."
|
|
203
|
+
: `WhatsApp unpins one message at a time — call unpin_message for: ${pins
|
|
204
|
+
.map((p) => p.msgId)
|
|
205
|
+
.join(", ")}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case "set_admin_title":
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: "WhatsApp has no custom admin titles",
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
case "create_topic":
|
|
216
|
+
case "edit_topic":
|
|
217
|
+
case "close_topic":
|
|
218
|
+
case "reopen_topic":
|
|
219
|
+
case "delete_topic":
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error:
|
|
223
|
+
"WhatsApp groups have no topics — use separate groups, or a " +
|
|
224
|
+
"community with sub-groups (not manageable from this account).",
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
default:
|
|
228
|
+
return { ok: false, error: `Unknown moderate op: ${op}` };
|
|
229
|
+
}
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the WhatsApp action handlers: uniform error
|
|
3
|
+
* capture, media-source resolution, quoted-reply resolution, and the
|
|
4
|
+
* JID coercion the member-facing actions need.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, statSync } from "node:fs";
|
|
8
|
+
import { basename, extname } from "node:path";
|
|
9
|
+
import type { AnyMessageContent, WAMessage, WASocket } from "baileys";
|
|
10
|
+
import { expandFsPath } from "../../../util/fs-path.js";
|
|
11
|
+
import { log, logError } from "../../../util/log.js";
|
|
12
|
+
import type { ActionResult } from "../../../core/types.js";
|
|
13
|
+
import { toWhatsAppChunks } from "../formatting.js";
|
|
14
|
+
import { lookupMessage, rememberMessage } from "../message-store.js";
|
|
15
|
+
import type { WhatsAppChatInfo } from "../registry.js";
|
|
16
|
+
|
|
17
|
+
/** WhatsApp's own ceiling for a media upload. */
|
|
18
|
+
const MAX_MEDIA_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run an action, converting a throw into a structured failure. WhatsApp
|
|
22
|
+
* errors arrive as Boom objects whose message is the useful part; the
|
|
23
|
+
* model gets that text so it can adapt rather than retry blindly.
|
|
24
|
+
*/
|
|
25
|
+
export async function tryAction(
|
|
26
|
+
label: string,
|
|
27
|
+
fn: () => Promise<ActionResult>,
|
|
28
|
+
): Promise<ActionResult> {
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
33
|
+
logError("whatsapp", `${label} failed: ${msg}`);
|
|
34
|
+
return { ok: false, error: `${label}: ${msg}` };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Baileys accepts a Buffer, a stream, or `{ url }` for local paths and HTTP. */
|
|
39
|
+
export type MediaUpload = { url: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve one media input to something Baileys can upload. Two sources:
|
|
43
|
+
* a public URL (WhatsApp's uploader fetches it) or a workspace file path
|
|
44
|
+
* (streamed from disk). `file_id` is a Telegram concept with no WhatsApp
|
|
45
|
+
* equivalent — say so instead of failing obscurely.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveMediaUpload(
|
|
48
|
+
src: { file_path?: unknown; url?: unknown; file_id?: unknown },
|
|
49
|
+
label: string,
|
|
50
|
+
): { media: MediaUpload; fileName: string } | { error: string } {
|
|
51
|
+
if (src.url) {
|
|
52
|
+
const url = String(src.url);
|
|
53
|
+
return {
|
|
54
|
+
media: { url },
|
|
55
|
+
fileName: basename(new URL(url).pathname) || "file",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (src.file_id) {
|
|
59
|
+
return {
|
|
60
|
+
error:
|
|
61
|
+
`${label}: WhatsApp has no file_id — re-send by url (public) or ` +
|
|
62
|
+
`file_path (workspace file)`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (!src.file_path) {
|
|
66
|
+
return {
|
|
67
|
+
error: `${label}: provide file_path (workspace file) or url (public)`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const filePath = expandFsPath(String(src.file_path));
|
|
71
|
+
if (!existsSync(filePath)) {
|
|
72
|
+
return {
|
|
73
|
+
error: `File not found: ${filePath} — check the workspace path, or send by url instead`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (statSync(filePath).size > MAX_MEDIA_BYTES) {
|
|
77
|
+
return { error: `${label}: file exceeds WhatsApp's 64MB limit` };
|
|
78
|
+
}
|
|
79
|
+
return { media: { url: filePath }, fileName: basename(filePath) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Extension → mimetype for the document/audio paths that require one. */
|
|
83
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
84
|
+
".pdf": "application/pdf",
|
|
85
|
+
".txt": "text/plain",
|
|
86
|
+
".md": "text/markdown",
|
|
87
|
+
".json": "application/json",
|
|
88
|
+
".csv": "text/csv",
|
|
89
|
+
".zip": "application/zip",
|
|
90
|
+
".png": "image/png",
|
|
91
|
+
".jpg": "image/jpeg",
|
|
92
|
+
".jpeg": "image/jpeg",
|
|
93
|
+
".gif": "image/gif",
|
|
94
|
+
".webp": "image/webp",
|
|
95
|
+
".mp4": "video/mp4",
|
|
96
|
+
".mov": "video/quicktime",
|
|
97
|
+
".mp3": "audio/mpeg",
|
|
98
|
+
".m4a": "audio/mp4",
|
|
99
|
+
".ogg": "audio/ogg",
|
|
100
|
+
".opus": "audio/ogg; codecs=opus",
|
|
101
|
+
".wav": "audio/wav",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function guessMimetype(fileName: string, fallback: string): string {
|
|
105
|
+
return MIME_BY_EXT[extname(fileName).toLowerCase()] ?? fallback;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a `reply_to` message id into the quoted message Baileys wants.
|
|
110
|
+
* An unknown id quotes nothing rather than failing the send — the reply
|
|
111
|
+
* link is a nicety, the message itself is the point.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveQuoted(
|
|
114
|
+
body: Record<string, unknown>,
|
|
115
|
+
chatId: string,
|
|
116
|
+
): WAMessage | undefined {
|
|
117
|
+
const raw = body.reply_to ?? body.reply_to_message_id;
|
|
118
|
+
if (raw === undefined || raw === null) return undefined;
|
|
119
|
+
const msgId = typeof raw === "number" ? raw : Number(raw);
|
|
120
|
+
if (!Number.isFinite(msgId)) return undefined;
|
|
121
|
+
const stored = lookupMessage(msgId);
|
|
122
|
+
if (!stored || stored.chatId !== chatId) return undefined;
|
|
123
|
+
return (
|
|
124
|
+
stored.message ?? {
|
|
125
|
+
key: stored.key,
|
|
126
|
+
message: { conversation: stored.text },
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Send one content payload, remember the resulting message so later
|
|
133
|
+
* tool calls can address it, and report its Talon numeric id.
|
|
134
|
+
*/
|
|
135
|
+
export async function sendContent(
|
|
136
|
+
ctx: { sock: WASocket; gateway: { incrementMessages: (id: number) => void } },
|
|
137
|
+
chat: WhatsAppChatInfo,
|
|
138
|
+
content: AnyMessageContent,
|
|
139
|
+
options: { quoted?: WAMessage } = {},
|
|
140
|
+
): Promise<ActionResult> {
|
|
141
|
+
const sent = await ctx.sock.sendMessage(chat.jid, content, options);
|
|
142
|
+
ctx.gateway.incrementMessages(chat.numericChatId);
|
|
143
|
+
if (!sent?.key) return { ok: true };
|
|
144
|
+
const msgId = rememberMessage({
|
|
145
|
+
key: sent.key,
|
|
146
|
+
chatId: chat.chatId,
|
|
147
|
+
message: sent,
|
|
148
|
+
text: "text" in content ? String(content.text ?? "") : "",
|
|
149
|
+
senderName: "bot",
|
|
150
|
+
});
|
|
151
|
+
return { ok: true, message_id: msgId };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Send text, split across bubbles when it exceeds WhatsApp's limit. The
|
|
156
|
+
* reported message id is the FIRST chunk's: it is the one a reply or
|
|
157
|
+
* reaction should attach to, and the one Talon's callers treat as "the"
|
|
158
|
+
* message.
|
|
159
|
+
*/
|
|
160
|
+
export async function sendText(
|
|
161
|
+
ctx: { sock: WASocket; gateway: { incrementMessages: (id: number) => void } },
|
|
162
|
+
chat: WhatsAppChatInfo,
|
|
163
|
+
text: string,
|
|
164
|
+
quoted?: WAMessage,
|
|
165
|
+
): Promise<ActionResult> {
|
|
166
|
+
const chunks = toWhatsAppChunks(text);
|
|
167
|
+
let first: ActionResult | undefined;
|
|
168
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
169
|
+
const result = await sendContent(
|
|
170
|
+
ctx,
|
|
171
|
+
chat,
|
|
172
|
+
{ text: chunk },
|
|
173
|
+
// Only the first chunk quotes — a quoted block on every bubble of a
|
|
174
|
+
// long answer is noise.
|
|
175
|
+
index === 0 && quoted ? { quoted } : {},
|
|
176
|
+
);
|
|
177
|
+
first ??= result;
|
|
178
|
+
}
|
|
179
|
+
log(
|
|
180
|
+
"whatsapp",
|
|
181
|
+
`Sent ${chunks.length} chunk(s) to ${chat.chatId} (${text.length} chars)`,
|
|
182
|
+
);
|
|
183
|
+
return first ?? { ok: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Coerce a user reference the model supplied — bare number, JID, or the
|
|
188
|
+
* numeric id from a member listing — into a WhatsApp user JID.
|
|
189
|
+
*/
|
|
190
|
+
export function toUserJid(value: unknown): string | null {
|
|
191
|
+
const raw = String(value ?? "").trim();
|
|
192
|
+
if (!raw) return null;
|
|
193
|
+
if (raw.includes("@")) return raw;
|
|
194
|
+
const digits = raw.replace(/[^0-9]/g, "");
|
|
195
|
+
return digits ? `${digits}@s.whatsapp.net` : null;
|
|
196
|
+
}
|