talon-agent 3.24.1 → 3.25.1
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/README.md +123 -25
- 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,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media actions — images, video, GIFs, voice notes, audio, documents,
|
|
3
|
+
* stickers, round video notes, albums, polls, locations, venues,
|
|
4
|
+
* contacts, and dice.
|
|
5
|
+
*
|
|
6
|
+
* Every Telegram/Discord send type has a WhatsApp equivalent except the
|
|
7
|
+
* two noted inline (dice is synthesised, albums are sent as a run of
|
|
8
|
+
* messages because WhatsApp groups them client-side by timing).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AnyMessageContent } from "baileys";
|
|
12
|
+
import { toWhatsAppText } from "../formatting.js";
|
|
13
|
+
import {
|
|
14
|
+
guessMimetype,
|
|
15
|
+
resolveMediaUpload,
|
|
16
|
+
resolveQuoted,
|
|
17
|
+
sendContent,
|
|
18
|
+
sendText,
|
|
19
|
+
tryAction,
|
|
20
|
+
} from "./shared.js";
|
|
21
|
+
import type { WhatsAppActionHandlers } from "./types.js";
|
|
22
|
+
|
|
23
|
+
/** Optional caption, translated to WhatsApp's dialect. */
|
|
24
|
+
function caption(body: Record<string, unknown>): { caption?: string } {
|
|
25
|
+
const raw = body.caption ?? body.text;
|
|
26
|
+
const text = raw === undefined || raw === null ? "" : String(raw);
|
|
27
|
+
return text.trim() ? { caption: toWhatsAppText(text) } : {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the media payload for one send action. Returns an error string
|
|
32
|
+
* when the source can't be resolved, so callers report it verbatim.
|
|
33
|
+
*/
|
|
34
|
+
function buildMediaContent(
|
|
35
|
+
action: string,
|
|
36
|
+
body: Record<string, unknown>,
|
|
37
|
+
): AnyMessageContent | { error: string } {
|
|
38
|
+
const resolved = resolveMediaUpload(body, action);
|
|
39
|
+
if ("error" in resolved) return resolved;
|
|
40
|
+
const { media, fileName } = resolved;
|
|
41
|
+
|
|
42
|
+
switch (action) {
|
|
43
|
+
case "send_photo":
|
|
44
|
+
return { image: media, ...caption(body) };
|
|
45
|
+
case "send_video":
|
|
46
|
+
return { video: media, ...caption(body) };
|
|
47
|
+
case "send_animation":
|
|
48
|
+
// WhatsApp has no GIF type — a looping muted video is what its own
|
|
49
|
+
// client sends when you pick a GIF.
|
|
50
|
+
return { video: media, gifPlayback: true, ...caption(body) };
|
|
51
|
+
case "send_video_note":
|
|
52
|
+
// ptv = "picture-in-video": the round bubble.
|
|
53
|
+
return { video: media, ptv: true };
|
|
54
|
+
case "send_voice":
|
|
55
|
+
return {
|
|
56
|
+
audio: media,
|
|
57
|
+
ptt: true,
|
|
58
|
+
mimetype: guessMimetype(fileName, "audio/ogg; codecs=opus"),
|
|
59
|
+
};
|
|
60
|
+
case "send_audio":
|
|
61
|
+
return {
|
|
62
|
+
audio: media,
|
|
63
|
+
ptt: false,
|
|
64
|
+
mimetype: guessMimetype(fileName, "audio/mpeg"),
|
|
65
|
+
};
|
|
66
|
+
case "send_sticker":
|
|
67
|
+
return { sticker: media };
|
|
68
|
+
case "send_file":
|
|
69
|
+
default:
|
|
70
|
+
return {
|
|
71
|
+
document: media,
|
|
72
|
+
mimetype: guessMimetype(fileName, "application/octet-stream"),
|
|
73
|
+
fileName: String(body.file_name ?? fileName),
|
|
74
|
+
...caption(body),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sendMedia: WhatsAppActionHandlers[string] = (body, _chatId, ctx) => {
|
|
80
|
+
const action = String(body.action);
|
|
81
|
+
return tryAction(action, async () => {
|
|
82
|
+
const content = buildMediaContent(action, body);
|
|
83
|
+
if ("error" in content) return { ok: false, error: content.error };
|
|
84
|
+
const quoted = resolveQuoted(body, ctx.chat!.chatId);
|
|
85
|
+
return sendContent(ctx, ctx.chat!, content, quoted ? { quoted } : {});
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const mediaHandlers: WhatsAppActionHandlers = {
|
|
90
|
+
send_photo: sendMedia,
|
|
91
|
+
send_video: sendMedia,
|
|
92
|
+
send_animation: sendMedia,
|
|
93
|
+
send_video_note: sendMedia,
|
|
94
|
+
send_voice: sendMedia,
|
|
95
|
+
send_audio: sendMedia,
|
|
96
|
+
send_file: sendMedia,
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A sticker needs a WhatsApp-format .webp, addressed by file_path or
|
|
100
|
+
* url. Talon's saved sticker packs are Telegram file ids, which this
|
|
101
|
+
* account cannot upload, so an emoji-only request sends the emoji as
|
|
102
|
+
* its own message instead — WhatsApp renders a lone emoji large, which
|
|
103
|
+
* is the native form of the same gesture.
|
|
104
|
+
*/
|
|
105
|
+
send_sticker: (body, _chatId, ctx) =>
|
|
106
|
+
tryAction("send_sticker", async () => {
|
|
107
|
+
if (!body.file_path && !body.url) {
|
|
108
|
+
const emoji = String(body.emoji ?? "").trim();
|
|
109
|
+
if (!emoji) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error:
|
|
113
|
+
"send_sticker: provide file_path or url to a .webp sticker, or an emoji",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return sendText(ctx, ctx.chat!, emoji);
|
|
117
|
+
}
|
|
118
|
+
const content = buildMediaContent("send_sticker", body);
|
|
119
|
+
if ("error" in content) return { ok: false, error: content.error };
|
|
120
|
+
return sendContent(ctx, ctx.chat!, content);
|
|
121
|
+
}),
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* WhatsApp has no album envelope on the send path — its client groups
|
|
125
|
+
* consecutive media from one sender itself, so a run of sends is the
|
|
126
|
+
* native shape. The first item's id is reported, matching how the other
|
|
127
|
+
* frontends treat a media group.
|
|
128
|
+
*/
|
|
129
|
+
send_media_group: (body, _chatId, ctx) =>
|
|
130
|
+
tryAction("send_media_group", async () => {
|
|
131
|
+
const items = (body.media ?? []) as Array<Record<string, unknown>>;
|
|
132
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
133
|
+
return { ok: false, error: "send_media_group: media[] is required" };
|
|
134
|
+
}
|
|
135
|
+
let first: Awaited<ReturnType<typeof sendContent>> | undefined;
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
const action =
|
|
138
|
+
String(item.type ?? "photo") === "video"
|
|
139
|
+
? "send_video"
|
|
140
|
+
: "send_photo";
|
|
141
|
+
const content = buildMediaContent(action, item);
|
|
142
|
+
if ("error" in content) return { ok: false, error: content.error };
|
|
143
|
+
const result = await sendContent(ctx, ctx.chat!, content);
|
|
144
|
+
first ??= result;
|
|
145
|
+
}
|
|
146
|
+
return first ?? { ok: true };
|
|
147
|
+
}),
|
|
148
|
+
|
|
149
|
+
send_poll: (body, _chatId, ctx) =>
|
|
150
|
+
tryAction("send_poll", async () => {
|
|
151
|
+
const values = (body.options ?? []) as unknown[];
|
|
152
|
+
const question = String(body.question ?? "");
|
|
153
|
+
if (!question || !Array.isArray(values) || values.length < 2) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: "send_poll: question and at least 2 options are required",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return sendContent(ctx, ctx.chat!, {
|
|
160
|
+
poll: {
|
|
161
|
+
name: question,
|
|
162
|
+
values: values.map((v) => String(v)),
|
|
163
|
+
selectableCount: body.allows_multiple_answers ? values.length : 1,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}),
|
|
167
|
+
|
|
168
|
+
send_location: (body, _chatId, ctx) =>
|
|
169
|
+
tryAction("send_location", async () => {
|
|
170
|
+
const latitude = Number(body.latitude);
|
|
171
|
+
const longitude = Number(body.longitude);
|
|
172
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: "send_location: latitude and longitude are required",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return sendContent(ctx, ctx.chat!, {
|
|
179
|
+
location: { degreesLatitude: latitude, degreesLongitude: longitude },
|
|
180
|
+
});
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
send_venue: (body, _chatId, ctx) =>
|
|
184
|
+
tryAction("send_venue", async () => {
|
|
185
|
+
const latitude = Number(body.latitude);
|
|
186
|
+
const longitude = Number(body.longitude);
|
|
187
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: "send_venue: latitude and longitude are required",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// A venue is a location carrying a name and address — WhatsApp's
|
|
194
|
+
// location message has both fields.
|
|
195
|
+
return sendContent(ctx, ctx.chat!, {
|
|
196
|
+
location: {
|
|
197
|
+
degreesLatitude: latitude,
|
|
198
|
+
degreesLongitude: longitude,
|
|
199
|
+
name: body.title ? String(body.title) : undefined,
|
|
200
|
+
address: body.address ? String(body.address) : undefined,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}),
|
|
204
|
+
|
|
205
|
+
send_contact: (body, _chatId, ctx) =>
|
|
206
|
+
tryAction("send_contact", async () => {
|
|
207
|
+
const phone = String(body.phone_number ?? "").replace(/[^0-9+]/g, "");
|
|
208
|
+
const first = String(body.first_name ?? "").trim();
|
|
209
|
+
const last = String(body.last_name ?? "").trim();
|
|
210
|
+
const displayName = [first, last].filter(Boolean).join(" ") || phone;
|
|
211
|
+
if (!phone) {
|
|
212
|
+
return { ok: false, error: "send_contact: phone_number is required" };
|
|
213
|
+
}
|
|
214
|
+
// vCard 3.0 is what WhatsApp's own client emits for a shared contact.
|
|
215
|
+
const vcard = [
|
|
216
|
+
"BEGIN:VCARD",
|
|
217
|
+
"VERSION:3.0",
|
|
218
|
+
`FN:${displayName}`,
|
|
219
|
+
`TEL;type=CELL;waid=${phone.replace(/\D/g, "")}:${phone}`,
|
|
220
|
+
"END:VCARD",
|
|
221
|
+
].join("\n");
|
|
222
|
+
return sendContent(ctx, ctx.chat!, {
|
|
223
|
+
contacts: { displayName, contacts: [{ vcard }] },
|
|
224
|
+
});
|
|
225
|
+
}),
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* WhatsApp has no dice message. Rolling here and sending the result
|
|
229
|
+
* keeps the tool honest — the model asked for a random throw and gets
|
|
230
|
+
* one, rendered the way a person would type it.
|
|
231
|
+
*/
|
|
232
|
+
send_dice: (body, _chatId, ctx) =>
|
|
233
|
+
tryAction("send_dice", async () => {
|
|
234
|
+
const emoji = String(body.emoji ?? "🎲");
|
|
235
|
+
const faces: Record<string, number> = {
|
|
236
|
+
"🎲": 6,
|
|
237
|
+
"🎯": 6,
|
|
238
|
+
"🏀": 5,
|
|
239
|
+
"⚽": 5,
|
|
240
|
+
"🎳": 6,
|
|
241
|
+
"🎰": 64,
|
|
242
|
+
};
|
|
243
|
+
const max = faces[emoji] ?? 6;
|
|
244
|
+
const value = 1 + Math.floor(Math.random() * max);
|
|
245
|
+
const result = await sendText(ctx, ctx.chat!, `${emoji} ${value}`);
|
|
246
|
+
return { ...result, value };
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
@@ -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 };
|