talon-agent 3.24.0 → 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/telegram/commands/admin.ts +7 -0
- package/src/frontend/telegram/index.ts +5 -0
- package/src/frontend/telegram/middleware.ts +10 -0
- package/src/frontend/telegram/stale-command.ts +46 -0
- package/src/frontend/telegram/update-offset.ts +58 -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,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat- and member-inspection actions.
|
|
3
|
+
*
|
|
4
|
+
* Group data comes from `groupMetadata`, which WhatsApp serves per JID
|
|
5
|
+
* and Baileys caches; DMs have no metadata endpoint, so their answers are
|
|
6
|
+
* assembled from the registry and the profile endpoints.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { downloadMediaMessage } from "baileys";
|
|
10
|
+
import { lookupMessage } from "../message-store.js";
|
|
11
|
+
import { toUserJid, tryAction } from "./shared.js";
|
|
12
|
+
import type { WhatsAppActionHandlers } from "./types.js";
|
|
13
|
+
|
|
14
|
+
/** Render one participant the way the other frontends render members. */
|
|
15
|
+
function describeParticipant(p: {
|
|
16
|
+
id: string;
|
|
17
|
+
admin?: string | null;
|
|
18
|
+
name?: string | null;
|
|
19
|
+
}): string {
|
|
20
|
+
const role =
|
|
21
|
+
p.admin === "superadmin" ? " (owner)" : p.admin ? " (admin)" : "";
|
|
22
|
+
const handle = p.id.split("@")[0];
|
|
23
|
+
return p.name ? `${p.name} [${handle}]${role}` : `${handle}${role}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const chatInfoHandlers: WhatsAppActionHandlers = {
|
|
27
|
+
get_chat_info: (_body, chatId, ctx) =>
|
|
28
|
+
tryAction("get_chat_info", async () => {
|
|
29
|
+
const chat = ctx.chat!;
|
|
30
|
+
if (!chat.isGroup) {
|
|
31
|
+
return {
|
|
32
|
+
ok: true,
|
|
33
|
+
id: chatId,
|
|
34
|
+
type: "private",
|
|
35
|
+
title: chat.title ?? chat.jid.split("@")[0],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const meta = await ctx.sock.groupMetadata(chat.jid);
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
id: chatId,
|
|
42
|
+
type: "group",
|
|
43
|
+
title: meta.subject,
|
|
44
|
+
description: meta.desc ?? undefined,
|
|
45
|
+
member_count: meta.participants.length,
|
|
46
|
+
owner: meta.owner ?? undefined,
|
|
47
|
+
created: meta.creation ?? undefined,
|
|
48
|
+
announce_only: Boolean(meta.announce),
|
|
49
|
+
};
|
|
50
|
+
}),
|
|
51
|
+
|
|
52
|
+
get_chat_admins: (_body, _chatId, ctx) =>
|
|
53
|
+
tryAction("get_chat_admins", async () => {
|
|
54
|
+
const chat = ctx.chat!;
|
|
55
|
+
if (!chat.isGroup) {
|
|
56
|
+
return { ok: true, text: "Direct message — no admins." };
|
|
57
|
+
}
|
|
58
|
+
const meta = await ctx.sock.groupMetadata(chat.jid);
|
|
59
|
+
const admins = meta.participants.filter((p) => p.admin);
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
text: admins.length
|
|
63
|
+
? admins.map(describeParticipant).join("\n")
|
|
64
|
+
: "No admins.",
|
|
65
|
+
};
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
get_chat_member_count: (_body, _chatId, ctx) =>
|
|
69
|
+
tryAction("get_chat_member_count", async () => {
|
|
70
|
+
const chat = ctx.chat!;
|
|
71
|
+
if (!chat.isGroup) return { ok: true, count: 2, text: "2" };
|
|
72
|
+
const meta = await ctx.sock.groupMetadata(chat.jid);
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
count: meta.participants.length,
|
|
76
|
+
text: String(meta.participants.length),
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
list_chat_members: (body, _chatId, ctx) =>
|
|
81
|
+
tryAction("list_chat_members", async () => {
|
|
82
|
+
const chat = ctx.chat!;
|
|
83
|
+
if (!chat.isGroup) {
|
|
84
|
+
return { ok: true, text: "Direct message — just the two of you." };
|
|
85
|
+
}
|
|
86
|
+
const limit = Math.min(500, Number(body.limit ?? 100));
|
|
87
|
+
const meta = await ctx.sock.groupMetadata(chat.jid);
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
text: meta.participants
|
|
91
|
+
.slice(0, limit)
|
|
92
|
+
.map(describeParticipant)
|
|
93
|
+
.join("\n"),
|
|
94
|
+
};
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
get_member_info: (body, _chatId, ctx) =>
|
|
98
|
+
tryAction("get_member_info", async () => {
|
|
99
|
+
const jid = toUserJid(body.user_id ?? body.user_name);
|
|
100
|
+
if (!jid)
|
|
101
|
+
return { ok: false, error: "get_member_info: user_id required" };
|
|
102
|
+
const chat = ctx.chat!;
|
|
103
|
+
const lines: string[] = [`jid: ${jid}`];
|
|
104
|
+
if (chat.isGroup) {
|
|
105
|
+
const meta = await ctx.sock.groupMetadata(chat.jid);
|
|
106
|
+
const participant = meta.participants.find(
|
|
107
|
+
(p) => p.id.split("@")[0] === jid.split("@")[0],
|
|
108
|
+
);
|
|
109
|
+
if (!participant) {
|
|
110
|
+
return { ok: false, error: `${jid} is not in this group` };
|
|
111
|
+
}
|
|
112
|
+
lines.push(
|
|
113
|
+
`role: ${participant.admin === "superadmin" ? "owner" : (participant.admin ?? "member")}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
// Profile lookups are best-effort: privacy settings routinely hide
|
|
117
|
+
// both, and a missing status is not an error.
|
|
118
|
+
const status: unknown = await ctx.sock
|
|
119
|
+
.fetchStatus(jid)
|
|
120
|
+
.catch(() => undefined);
|
|
121
|
+
// fetchStatus has returned both a bare object and a one-element
|
|
122
|
+
// array across Baileys versions; accept either shape.
|
|
123
|
+
const entry = (Array.isArray(status) ? status[0] : status) as
|
|
124
|
+
{ status?: string | { status?: string } } | undefined;
|
|
125
|
+
const statusText =
|
|
126
|
+
typeof entry?.status === "string"
|
|
127
|
+
? entry.status
|
|
128
|
+
: entry?.status?.status;
|
|
129
|
+
if (statusText) lines.push(`about: ${statusText}`);
|
|
130
|
+
const picture = await ctx.sock
|
|
131
|
+
.profilePictureUrl(jid, "image")
|
|
132
|
+
.catch(() => undefined);
|
|
133
|
+
if (picture) lines.push(`photo: ${picture}`);
|
|
134
|
+
return { ok: true, text: lines.join("\n") };
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
get_user_profile_photos: (body, _chatId, ctx) =>
|
|
138
|
+
tryAction("get_user_profile_photos", async () => {
|
|
139
|
+
const jid = toUserJid(body.user_id) ?? ctx.chat!.jid;
|
|
140
|
+
const url = await ctx.sock
|
|
141
|
+
.profilePictureUrl(jid, "image")
|
|
142
|
+
.catch(() => undefined);
|
|
143
|
+
return url
|
|
144
|
+
? { ok: true, text: url }
|
|
145
|
+
: {
|
|
146
|
+
ok: true,
|
|
147
|
+
text: "No profile photo (or hidden by privacy settings).",
|
|
148
|
+
};
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
/** WhatsApp exposes no presence roster; presence is per-subscription. */
|
|
152
|
+
online_count: (_body, _chatId, ctx) =>
|
|
153
|
+
tryAction("online_count", async () => {
|
|
154
|
+
const chat = ctx.chat!;
|
|
155
|
+
if (!chat.isGroup) {
|
|
156
|
+
return { ok: true, text: "Presence is only visible per contact." };
|
|
157
|
+
}
|
|
158
|
+
const meta = await ctx.sock.groupMetadata(chat.jid);
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
text:
|
|
162
|
+
`WhatsApp does not report a group online count; ` +
|
|
163
|
+
`${meta.participants.length} members total.`,
|
|
164
|
+
};
|
|
165
|
+
}),
|
|
166
|
+
|
|
167
|
+
set_chat_title: (body, _chatId, ctx) =>
|
|
168
|
+
tryAction("set_chat_title", async () => {
|
|
169
|
+
const chat = ctx.chat!;
|
|
170
|
+
if (!chat.isGroup) {
|
|
171
|
+
return { ok: false, error: "Only group subjects can be changed" };
|
|
172
|
+
}
|
|
173
|
+
const title = String(body.title ?? "").trim();
|
|
174
|
+
if (!title) return { ok: false, error: "set_chat_title: title required" };
|
|
175
|
+
await ctx.sock.groupUpdateSubject(chat.jid, title);
|
|
176
|
+
chat.title = title;
|
|
177
|
+
return { ok: true };
|
|
178
|
+
}),
|
|
179
|
+
|
|
180
|
+
set_chat_description: (body, _chatId, ctx) =>
|
|
181
|
+
tryAction("set_chat_description", async () => {
|
|
182
|
+
const chat = ctx.chat!;
|
|
183
|
+
if (!chat.isGroup) {
|
|
184
|
+
return { ok: false, error: "Only groups have a description" };
|
|
185
|
+
}
|
|
186
|
+
await ctx.sock.groupUpdateDescription(
|
|
187
|
+
chat.jid,
|
|
188
|
+
String(body.description ?? ""),
|
|
189
|
+
);
|
|
190
|
+
return { ok: true };
|
|
191
|
+
}),
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Re-download a message's media on demand. Inbound media is already
|
|
195
|
+
* saved to the workspace as it arrives; this covers anything the model
|
|
196
|
+
* wants to pull again by id.
|
|
197
|
+
*/
|
|
198
|
+
download_media: (body, _chatId, ctx) =>
|
|
199
|
+
tryAction("download_media", async () => {
|
|
200
|
+
const raw = body.message_id;
|
|
201
|
+
const msgId = typeof raw === "number" ? raw : Number(raw);
|
|
202
|
+
const stored = Number.isFinite(msgId) ? lookupMessage(msgId) : undefined;
|
|
203
|
+
if (!stored || stored.chatId !== ctx.chat!.chatId) {
|
|
204
|
+
return { ok: false, error: `Unknown message_id ${String(raw)}` };
|
|
205
|
+
}
|
|
206
|
+
if (!stored.message) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
error: `Message ${msgId} is no longer retained in full`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const { saveInboundMedia } = await import("../media-store.js");
|
|
213
|
+
const saved = await saveInboundMedia(
|
|
214
|
+
stored.message,
|
|
215
|
+
ctx.chat!.chatId,
|
|
216
|
+
msgId,
|
|
217
|
+
stored.senderName,
|
|
218
|
+
);
|
|
219
|
+
return saved
|
|
220
|
+
? { ok: true, text: saved.filePath, file_path: saved.filePath }
|
|
221
|
+
: {
|
|
222
|
+
ok: false,
|
|
223
|
+
error: `Message ${msgId} carries no downloadable media`,
|
|
224
|
+
};
|
|
225
|
+
}),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export { downloadMediaMessage };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp-specific action handlers.
|
|
3
|
+
*
|
|
4
|
+
* Handles the MCP tool actions that need the WhatsApp socket.
|
|
5
|
+
* Platform-agnostic actions (cron, fetch_url, in-memory history) are
|
|
6
|
+
* served by core/engine/gateway-actions before this is reached, and any
|
|
7
|
+
* action this module doesn't claim returns null so that fallback still
|
|
8
|
+
* applies — which is how `read_history` and friends work here without a
|
|
9
|
+
* WhatsApp-specific implementation.
|
|
10
|
+
*
|
|
11
|
+
* Split by responsibility (mirrors the telegram/discord actions/ layout):
|
|
12
|
+
* - shared — tryAction, media/quote resolution, send helpers
|
|
13
|
+
* - messaging — text, replies, reactions, edits, deletes, pins, presence
|
|
14
|
+
* - media — images, video, audio, documents, polls, locations, …
|
|
15
|
+
* - chat-info — group metadata, members, titles, media download
|
|
16
|
+
* - moderation — the `moderate` op switch
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { WASocket } from "baileys";
|
|
20
|
+
import type { Gateway } from "../../../core/engine/gateway.js";
|
|
21
|
+
import type { ActionResult } from "../../../core/types.js";
|
|
22
|
+
import { lookupWhatsAppChat } from "../registry.js";
|
|
23
|
+
import { chatInfoHandlers } from "./chat-info.js";
|
|
24
|
+
import { mediaHandlers } from "./media.js";
|
|
25
|
+
import { messagingHandlers } from "./messaging.js";
|
|
26
|
+
import { moderationHandlers } from "./moderation.js";
|
|
27
|
+
import type { WhatsAppActionContext, WhatsAppActionHandlers } from "./types.js";
|
|
28
|
+
|
|
29
|
+
// Null-prototype so a request `action` of "toString" / "constructor" can't
|
|
30
|
+
// resolve an inherited Object.prototype method via `handlers[action]`.
|
|
31
|
+
const handlers: WhatsAppActionHandlers = Object.assign(Object.create(null), {
|
|
32
|
+
...messagingHandlers,
|
|
33
|
+
...mediaHandlers,
|
|
34
|
+
...chatInfoHandlers,
|
|
35
|
+
...moderationHandlers,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Actions that operate on the store alone and need no resolved chat. */
|
|
39
|
+
const CHATLESS_ACTIONS = new Set(["cancel_scheduled", "list_scheduled"]);
|
|
40
|
+
|
|
41
|
+
export function createWhatsAppActionHandler(
|
|
42
|
+
getSock: () => WASocket | null,
|
|
43
|
+
gateway: Gateway,
|
|
44
|
+
) {
|
|
45
|
+
const scheduledMessages = new Map<string, ReturnType<typeof setTimeout>>();
|
|
46
|
+
|
|
47
|
+
return async (
|
|
48
|
+
body: Record<string, unknown>,
|
|
49
|
+
chatId: number,
|
|
50
|
+
): Promise<ActionResult | null> => {
|
|
51
|
+
const action = body.action as string;
|
|
52
|
+
const handler = handlers[action];
|
|
53
|
+
if (!handler) return null; // not a WhatsApp action — let the core try
|
|
54
|
+
|
|
55
|
+
const sock = getSock();
|
|
56
|
+
if (!sock) {
|
|
57
|
+
return { ok: false, error: "WhatsApp socket is not connected" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const chat = lookupWhatsAppChat(chatId) ?? null;
|
|
61
|
+
if (!chat && !CHATLESS_ACTIONS.has(action)) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
error:
|
|
65
|
+
`No WhatsApp chat known for id ${chatId} — Talon learns a chat's ` +
|
|
66
|
+
`JID from its first message.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ctx: WhatsAppActionContext = {
|
|
71
|
+
sock,
|
|
72
|
+
gateway,
|
|
73
|
+
chat,
|
|
74
|
+
scheduledMessages,
|
|
75
|
+
};
|
|
76
|
+
return handler(body, chatId, ctx);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -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
|
+
};
|