talon-agent 3.21.0 → 3.22.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 +2 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/messaging.ts +154 -8
- package/src/core/tools/moderation.ts +131 -0
- package/src/core/tools/types.ts +1 -0
- package/src/frontend/telegram/actions/index.ts +2 -0
- package/src/frontend/telegram/actions/media.ts +143 -31
- package/src/frontend/telegram/actions/messaging.ts +78 -13
- package/src/frontend/telegram/actions/moderation.ts +255 -0
- package/src/frontend/telegram/actions/shared.ts +36 -0
- package/src/frontend/telegram/handlers/delivery.ts +6 -1
- package/src/frontend/telegram/index.ts +16 -8
- package/src/frontend/telegram/join-requests.ts +53 -0
- package/src/frontend/telegram/middleware.ts +25 -0
- package/src/frontend/telegram/topics.ts +63 -0
- package/src/frontend/telegram/userbot.ts +11 -0
|
@@ -9,9 +9,47 @@ import { expandFsPath } from "../../../util/fs-path.js";
|
|
|
9
9
|
import { markdownToTelegramHtml } from "../formatting.js";
|
|
10
10
|
import { withRetry } from "../../../core/engine/gateway.js";
|
|
11
11
|
import { resolveStickerByEmoji } from "../sticker-library.js";
|
|
12
|
-
import { replyParams } from "./shared.js";
|
|
12
|
+
import { replyParams, sendOpts } from "./shared.js";
|
|
13
13
|
import type { TelegramActionHandlers } from "./types.js";
|
|
14
14
|
|
|
15
|
+
type MediaSource = {
|
|
16
|
+
file_path?: unknown;
|
|
17
|
+
url?: unknown;
|
|
18
|
+
file_id?: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve one media input to what the Bot API accepts. Three sources, in
|
|
23
|
+
* priority order: a public URL or a Telegram file_id (both passed as a plain
|
|
24
|
+
* string — Telegram fetches/reuses server-side, no local bytes involved),
|
|
25
|
+
* else a workspace file path uploaded as multipart.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveMediaInput(
|
|
28
|
+
src: MediaSource,
|
|
29
|
+
label: string,
|
|
30
|
+
InputFileClass: typeof import("grammy").InputFile,
|
|
31
|
+
): { file: string | import("grammy").InputFile } | { error: string } {
|
|
32
|
+
const remote = src.url ?? src.file_id;
|
|
33
|
+
if (remote) return { file: String(remote) };
|
|
34
|
+
// Fail with guidance the model can act on, not a raw ENOENT: a
|
|
35
|
+
// mistyped path is the most common media-send error, and naming
|
|
36
|
+
// the alternatives (url / file_id) teaches the recovery path.
|
|
37
|
+
if (!src.file_path)
|
|
38
|
+
return {
|
|
39
|
+
error: `${label}: provide file_path (workspace file), url (public), or file_id (seen in chat)`,
|
|
40
|
+
};
|
|
41
|
+
const filePath = expandFsPath(String(src.file_path));
|
|
42
|
+
if (!existsSync(filePath))
|
|
43
|
+
return {
|
|
44
|
+
error: `File not found: ${filePath} — check the workspace path, or send by url/file_id instead`,
|
|
45
|
+
};
|
|
46
|
+
const stat = statSync(filePath);
|
|
47
|
+
if (stat.size > 49 * 1024 * 1024)
|
|
48
|
+
return { error: "File too large (max 49MB)" };
|
|
49
|
+
const data = readFileSync(filePath);
|
|
50
|
+
return { file: new InputFileClass(data, basename(filePath)) };
|
|
51
|
+
}
|
|
52
|
+
|
|
15
53
|
const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
16
54
|
body,
|
|
17
55
|
chatId,
|
|
@@ -25,36 +63,13 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
|
25
63
|
: undefined;
|
|
26
64
|
const captionParseMode = caption ? ("HTML" as const) : undefined;
|
|
27
65
|
gateway.incrementMessages(chatId);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// workspace file path uploaded as multipart.
|
|
32
|
-
const remote = body.url ?? body.file_id;
|
|
33
|
-
let file: string | import("grammy").InputFile;
|
|
34
|
-
if (remote) {
|
|
35
|
-
file = String(remote);
|
|
36
|
-
} else {
|
|
37
|
-
// Fail with guidance the model can act on, not a raw ENOENT: a
|
|
38
|
-
// mistyped path is the most common media-send error, and naming
|
|
39
|
-
// the alternatives (url / file_id) teaches the recovery path.
|
|
40
|
-
if (!body.file_path)
|
|
41
|
-
return {
|
|
42
|
-
ok: false,
|
|
43
|
-
error: `${action}: provide file_path (workspace file), url (public), or file_id (seen in chat)`,
|
|
44
|
-
};
|
|
45
|
-
const filePath = expandFsPath(String(body.file_path));
|
|
46
|
-
if (!existsSync(filePath))
|
|
47
|
-
return {
|
|
48
|
-
ok: false,
|
|
49
|
-
error: `File not found: ${filePath} — check the workspace path, or send by url/file_id instead`,
|
|
50
|
-
};
|
|
51
|
-
const stat = statSync(filePath);
|
|
52
|
-
if (stat.size > 49 * 1024 * 1024)
|
|
53
|
-
return { ok: false, error: "File too large (max 49MB)" };
|
|
54
|
-
const data = readFileSync(filePath);
|
|
55
|
-
file = new InputFileClass(data, basename(filePath));
|
|
56
|
-
}
|
|
66
|
+
const resolved = resolveMediaInput(body, action, InputFileClass);
|
|
67
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
68
|
+
const file = resolved.file;
|
|
57
69
|
const rp = replyParams(body);
|
|
70
|
+
const opts = sendOpts(body, chatId);
|
|
71
|
+
// Spoiler blur only exists for visual media; Telegram rejects it elsewhere.
|
|
72
|
+
const spoiler = body.spoiler === true || undefined;
|
|
58
73
|
let sent;
|
|
59
74
|
switch (action) {
|
|
60
75
|
case "send_file":
|
|
@@ -63,6 +78,7 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
|
63
78
|
caption,
|
|
64
79
|
parse_mode: captionParseMode,
|
|
65
80
|
reply_parameters: rp,
|
|
81
|
+
...opts,
|
|
66
82
|
}),
|
|
67
83
|
);
|
|
68
84
|
break;
|
|
@@ -72,6 +88,8 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
|
72
88
|
caption,
|
|
73
89
|
parse_mode: captionParseMode,
|
|
74
90
|
reply_parameters: rp,
|
|
91
|
+
has_spoiler: spoiler,
|
|
92
|
+
...opts,
|
|
75
93
|
}),
|
|
76
94
|
);
|
|
77
95
|
break;
|
|
@@ -81,6 +99,8 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
|
81
99
|
caption,
|
|
82
100
|
parse_mode: captionParseMode,
|
|
83
101
|
reply_parameters: rp,
|
|
102
|
+
has_spoiler: spoiler,
|
|
103
|
+
...opts,
|
|
84
104
|
}),
|
|
85
105
|
);
|
|
86
106
|
break;
|
|
@@ -90,6 +110,8 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
|
90
110
|
caption,
|
|
91
111
|
parse_mode: captionParseMode,
|
|
92
112
|
reply_parameters: rp,
|
|
113
|
+
has_spoiler: spoiler,
|
|
114
|
+
...opts,
|
|
93
115
|
}),
|
|
94
116
|
);
|
|
95
117
|
break;
|
|
@@ -101,6 +123,7 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
|
101
123
|
reply_parameters: rp,
|
|
102
124
|
title: body.title as string | undefined,
|
|
103
125
|
performer: body.performer as string | undefined,
|
|
126
|
+
...opts,
|
|
104
127
|
}),
|
|
105
128
|
);
|
|
106
129
|
break;
|
|
@@ -110,6 +133,7 @@ const sendMediaFile: TelegramActionHandlers[string] = async (
|
|
|
110
133
|
caption,
|
|
111
134
|
parse_mode: captionParseMode,
|
|
112
135
|
reply_parameters: rp,
|
|
136
|
+
...opts,
|
|
113
137
|
}),
|
|
114
138
|
);
|
|
115
139
|
break;
|
|
@@ -125,6 +149,90 @@ export const mediaHandlers: TelegramActionHandlers = {
|
|
|
125
149
|
send_voice: sendMediaFile,
|
|
126
150
|
send_audio: sendMediaFile,
|
|
127
151
|
|
|
152
|
+
send_media_group: async (body, chatId, { bot, InputFileClass, gateway }) => {
|
|
153
|
+
const items = Array.isArray(body.media) ? body.media : [];
|
|
154
|
+
if (items.length < 2 || items.length > 10)
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: `Albums need 2–10 media items (got ${items.length}).`,
|
|
158
|
+
};
|
|
159
|
+
const spoiler = body.spoiler === true || undefined;
|
|
160
|
+
type AlbumItem = {
|
|
161
|
+
type: "photo" | "video" | "document" | "audio";
|
|
162
|
+
media: string | import("grammy").InputFile;
|
|
163
|
+
caption?: string;
|
|
164
|
+
parse_mode?: "HTML";
|
|
165
|
+
has_spoiler?: boolean;
|
|
166
|
+
};
|
|
167
|
+
const media: AlbumItem[] = [];
|
|
168
|
+
for (const [i, raw] of items.entries()) {
|
|
169
|
+
const item = raw as MediaSource & { type?: unknown; caption?: unknown };
|
|
170
|
+
// Telegram albums mix photos and videos; documents/audio group only
|
|
171
|
+
// with their own kind. Pass the declared type through and let the API
|
|
172
|
+
// reject invalid mixes with its own (clear) error.
|
|
173
|
+
const type = String(item.type ?? "photo");
|
|
174
|
+
if (!["photo", "video", "document", "audio"].includes(type))
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: `media[${i}]: type must be photo, video, document, or audio`,
|
|
178
|
+
};
|
|
179
|
+
const resolved = resolveMediaInput(item, `media[${i}]`, InputFileClass);
|
|
180
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
181
|
+
const caption = item.caption
|
|
182
|
+
? markdownToTelegramHtml(String(item.caption))
|
|
183
|
+
: undefined;
|
|
184
|
+
media.push({
|
|
185
|
+
type: type as AlbumItem["type"],
|
|
186
|
+
media: resolved.file,
|
|
187
|
+
caption,
|
|
188
|
+
parse_mode: caption ? ("HTML" as const) : undefined,
|
|
189
|
+
...(type === "photo" || type === "video"
|
|
190
|
+
? { has_spoiler: spoiler }
|
|
191
|
+
: {}),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
gateway.incrementMessages(chatId);
|
|
195
|
+
// The wrapper types each album as homogeneous; runtime validation above
|
|
196
|
+
// plus Telegram's own mixed-group errors cover what the cast waives.
|
|
197
|
+
const group = media as unknown as Parameters<
|
|
198
|
+
import("grammy").Bot["api"]["sendMediaGroup"]
|
|
199
|
+
>[1];
|
|
200
|
+
const sent = await withRetry(() =>
|
|
201
|
+
bot.api.sendMediaGroup(chatId, group, {
|
|
202
|
+
reply_parameters: replyParams(body),
|
|
203
|
+
...sendOpts(body, chatId),
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
return { ok: true, message_ids: sent.map((m) => m.message_id) };
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
send_video_note: async (body, chatId, { bot, InputFileClass, gateway }) => {
|
|
210
|
+
const resolved = resolveMediaInput(body, "send_video_note", InputFileClass);
|
|
211
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
212
|
+
gateway.incrementMessages(chatId);
|
|
213
|
+
// Round video bubbles take no caption; Telegram wants square video ≤60s.
|
|
214
|
+
const sent = await withRetry(() =>
|
|
215
|
+
bot.api.sendVideoNote(chatId, resolved.file, {
|
|
216
|
+
reply_parameters: replyParams(body),
|
|
217
|
+
...sendOpts(body, chatId),
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
return { ok: true, message_id: sent.message_id };
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
send_venue: async (body, chatId, { bot, gateway }) => {
|
|
224
|
+
gateway.incrementMessages(chatId);
|
|
225
|
+
const sent = await bot.api.sendVenue(
|
|
226
|
+
chatId,
|
|
227
|
+
Number(body.latitude),
|
|
228
|
+
Number(body.longitude),
|
|
229
|
+
String(body.title ?? ""),
|
|
230
|
+
String(body.address ?? ""),
|
|
231
|
+
{ reply_parameters: replyParams(body), ...sendOpts(body, chatId) },
|
|
232
|
+
);
|
|
233
|
+
return { ok: true, message_id: sent.message_id };
|
|
234
|
+
},
|
|
235
|
+
|
|
128
236
|
send_sticker: async (body, chatId, { bot, gateway }) => {
|
|
129
237
|
// Three addressing modes: a concrete file_id, a public URL of a
|
|
130
238
|
// .webp (Telegram fetches it server-side, like other media), or an
|
|
@@ -155,6 +263,7 @@ export const mediaHandlers: TelegramActionHandlers = {
|
|
|
155
263
|
gateway.incrementMessages(chatId);
|
|
156
264
|
const sent = await bot.api.sendSticker(chatId, fileId, {
|
|
157
265
|
reply_parameters: replyParams(body),
|
|
266
|
+
...sendOpts(body, chatId),
|
|
158
267
|
});
|
|
159
268
|
return { ok: true, message_id: sent.message_id };
|
|
160
269
|
},
|
|
@@ -176,6 +285,7 @@ export const mediaHandlers: TelegramActionHandlers = {
|
|
|
176
285
|
: undefined,
|
|
177
286
|
explanation: body.explanation as string | undefined,
|
|
178
287
|
reply_parameters: replyParams(body),
|
|
288
|
+
...sendOpts(body, chatId),
|
|
179
289
|
},
|
|
180
290
|
);
|
|
181
291
|
return { ok: true, message_id: sent.message_id };
|
|
@@ -187,7 +297,7 @@ export const mediaHandlers: TelegramActionHandlers = {
|
|
|
187
297
|
chatId,
|
|
188
298
|
Number(body.latitude),
|
|
189
299
|
Number(body.longitude),
|
|
190
|
-
{ reply_parameters: replyParams(body) },
|
|
300
|
+
{ reply_parameters: replyParams(body), ...sendOpts(body, chatId) },
|
|
191
301
|
);
|
|
192
302
|
return { ok: true, message_id: sent.message_id };
|
|
193
303
|
},
|
|
@@ -201,6 +311,7 @@ export const mediaHandlers: TelegramActionHandlers = {
|
|
|
201
311
|
{
|
|
202
312
|
last_name: body.last_name as string | undefined,
|
|
203
313
|
reply_parameters: replyParams(body),
|
|
314
|
+
...sendOpts(body, chatId),
|
|
204
315
|
},
|
|
205
316
|
);
|
|
206
317
|
return { ok: true, message_id: sent.message_id };
|
|
@@ -213,6 +324,7 @@ export const mediaHandlers: TelegramActionHandlers = {
|
|
|
213
324
|
(body.emoji as string) || "🎲",
|
|
214
325
|
{
|
|
215
326
|
reply_parameters: replyParams(body),
|
|
327
|
+
...sendOpts(body, chatId),
|
|
216
328
|
},
|
|
217
329
|
);
|
|
218
330
|
return {
|
|
@@ -18,9 +18,11 @@ import {
|
|
|
18
18
|
import {
|
|
19
19
|
noteRichMessageFailure,
|
|
20
20
|
richMessagesAvailable,
|
|
21
|
+
sendOpts,
|
|
21
22
|
sendText,
|
|
22
23
|
toPositiveId,
|
|
23
24
|
} from "./shared.js";
|
|
25
|
+
import { resolveThreadId } from "../topics.js";
|
|
24
26
|
import { TELEGRAM_MAX_TEXT, type TelegramActionHandlers } from "./types.js";
|
|
25
27
|
|
|
26
28
|
// ── Inline keyboards ─────────────────────────────────────────────────────────
|
|
@@ -201,7 +203,14 @@ export const messagingHandlers: TelegramActionHandlers = {
|
|
|
201
203
|
const text = String(body.text ?? "");
|
|
202
204
|
const replyTo = toPositiveId(body.reply_to_message_id);
|
|
203
205
|
gateway.incrementMessages(chatId);
|
|
204
|
-
const
|
|
206
|
+
const extra = {
|
|
207
|
+
...sendOpts(body, chatId),
|
|
208
|
+
link_preview_options:
|
|
209
|
+
body.no_link_preview === true ? { is_disabled: true } : undefined,
|
|
210
|
+
};
|
|
211
|
+
const msgId = await withRetry(() =>
|
|
212
|
+
sendText(bot, chatId, text, replyTo, undefined, extra),
|
|
213
|
+
);
|
|
205
214
|
return { ok: true, message_id: msgId };
|
|
206
215
|
},
|
|
207
216
|
|
|
@@ -249,6 +258,23 @@ export const messagingHandlers: TelegramActionHandlers = {
|
|
|
249
258
|
ok: false,
|
|
250
259
|
error: `Text too long (max ${TELEGRAM_MAX_TEXT})`,
|
|
251
260
|
};
|
|
261
|
+
// Media messages have captions, not text — editMessageText on them fails
|
|
262
|
+
// with "there is no text in the message to edit".
|
|
263
|
+
if (body.is_caption === true) {
|
|
264
|
+
await withRetry(async () => {
|
|
265
|
+
try {
|
|
266
|
+
await bot.api.editMessageCaption(chatId, Number(body.message_id), {
|
|
267
|
+
caption: markdownToTelegramHtml(text),
|
|
268
|
+
parse_mode: "HTML",
|
|
269
|
+
});
|
|
270
|
+
} catch {
|
|
271
|
+
await bot.api.editMessageCaption(chatId, Number(body.message_id), {
|
|
272
|
+
caption: text,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
return { ok: true };
|
|
277
|
+
}
|
|
252
278
|
await withRetry(async () => {
|
|
253
279
|
if (richMessagesAvailable()) {
|
|
254
280
|
try {
|
|
@@ -275,6 +301,19 @@ export const messagingHandlers: TelegramActionHandlers = {
|
|
|
275
301
|
},
|
|
276
302
|
|
|
277
303
|
delete_message: async (body, chatId, { bot }) => {
|
|
304
|
+
// Bulk form: deleteMessages takes up to 100 ids per call and skips ids
|
|
305
|
+
// it can't delete instead of failing the batch.
|
|
306
|
+
if (Array.isArray(body.message_ids) && body.message_ids.length > 0) {
|
|
307
|
+
const ids = body.message_ids
|
|
308
|
+
.map(toPositiveId)
|
|
309
|
+
.filter((n): n is number => n !== undefined);
|
|
310
|
+
if (ids.length === 0)
|
|
311
|
+
return { ok: false, error: "message_ids contains no valid ids" };
|
|
312
|
+
for (let i = 0; i < ids.length; i += 100) {
|
|
313
|
+
await bot.api.deleteMessages(chatId, ids.slice(i, i + 100));
|
|
314
|
+
}
|
|
315
|
+
return { ok: true, deleted: ids.length };
|
|
316
|
+
}
|
|
278
317
|
await bot.api.deleteMessage(chatId, Number(body.message_id));
|
|
279
318
|
return { ok: true };
|
|
280
319
|
},
|
|
@@ -293,22 +332,42 @@ export const messagingHandlers: TelegramActionHandlers = {
|
|
|
293
332
|
},
|
|
294
333
|
|
|
295
334
|
forward_message: async (body, chatId, { bot }) => {
|
|
296
|
-
|
|
297
|
-
|
|
335
|
+
// Cross-chat by explicit override; both ends default to the current
|
|
336
|
+
// chat. The bot can only ever read from / deliver to chats it is in —
|
|
337
|
+
// Telegram enforces membership on both ids.
|
|
338
|
+
const from = Number(body.from_chat_id ?? chatId);
|
|
339
|
+
const to = Number(body.to_chat_id ?? chatId);
|
|
340
|
+
const ids = Array.isArray(body.message_ids)
|
|
341
|
+
? body.message_ids
|
|
342
|
+
.map(toPositiveId)
|
|
343
|
+
.filter((n): n is number => n !== undefined)
|
|
344
|
+
: [];
|
|
345
|
+
if (ids.length > 0) {
|
|
346
|
+
// forwardMessages keeps album grouping and skips unfindable ids.
|
|
347
|
+
const sent = await bot.api.forwardMessages(to, from, ids);
|
|
348
|
+
return { ok: true, message_ids: sent.map((m) => m.message_id) };
|
|
349
|
+
}
|
|
298
350
|
const sent = await bot.api.forwardMessage(
|
|
299
|
-
|
|
300
|
-
|
|
351
|
+
to,
|
|
352
|
+
from,
|
|
301
353
|
Number(body.message_id),
|
|
302
354
|
);
|
|
303
355
|
return { ok: true, message_id: sent.message_id };
|
|
304
356
|
},
|
|
305
357
|
|
|
306
358
|
copy_message: async (body, chatId, { bot }) => {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
359
|
+
const from = Number(body.from_chat_id ?? chatId);
|
|
360
|
+
const to = Number(body.to_chat_id ?? chatId);
|
|
361
|
+
const ids = Array.isArray(body.message_ids)
|
|
362
|
+
? body.message_ids
|
|
363
|
+
.map(toPositiveId)
|
|
364
|
+
.filter((n): n is number => n !== undefined)
|
|
365
|
+
: [];
|
|
366
|
+
if (ids.length > 0) {
|
|
367
|
+
const sent = await bot.api.copyMessages(to, from, ids);
|
|
368
|
+
return { ok: true, message_ids: sent.map((m) => m.message_id) };
|
|
369
|
+
}
|
|
370
|
+
const sent = await bot.api.copyMessage(to, from, Number(body.message_id));
|
|
312
371
|
return { ok: true, message_id: sent.message_id };
|
|
313
372
|
},
|
|
314
373
|
|
|
@@ -316,6 +375,7 @@ export const messagingHandlers: TelegramActionHandlers = {
|
|
|
316
375
|
await bot.api.sendChatAction(
|
|
317
376
|
chatId,
|
|
318
377
|
String(body.chat_action ?? "typing") as "typing",
|
|
378
|
+
{ message_thread_id: resolveThreadId(body, chatId) },
|
|
319
379
|
);
|
|
320
380
|
return { ok: true };
|
|
321
381
|
},
|
|
@@ -329,9 +389,14 @@ export const messagingHandlers: TelegramActionHandlers = {
|
|
|
329
389
|
if ("error" in built) return { ok: false, error: built.error };
|
|
330
390
|
const keyboard = built.keyboard;
|
|
331
391
|
gateway.incrementMessages(chatId);
|
|
332
|
-
const messageId = await sendText(
|
|
333
|
-
|
|
334
|
-
|
|
392
|
+
const messageId = await sendText(
|
|
393
|
+
bot,
|
|
394
|
+
chatId,
|
|
395
|
+
text,
|
|
396
|
+
toPositiveId(body.reply_to_message_id),
|
|
397
|
+
{ inline_keyboard: keyboard },
|
|
398
|
+
sendOpts(body, chatId),
|
|
399
|
+
);
|
|
335
400
|
return { ok: true, message_id: messageId };
|
|
336
401
|
},
|
|
337
402
|
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moderation actions — the `moderate` tool's member/chat/topic operations,
|
|
3
|
+
* plus profile-photo lookup.
|
|
4
|
+
*
|
|
5
|
+
* Handlers call the Bot API directly and let Telegram's own errors surface
|
|
6
|
+
* (missing admin rights come back as descriptive 400s the model can read);
|
|
7
|
+
* pre-checking rights here would just race the real check.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChatPermissions } from "grammy/types";
|
|
11
|
+
import { toPositiveId } from "./shared.js";
|
|
12
|
+
import { resolveMediaInput } from "./media.js";
|
|
13
|
+
import { clearJoinRequest, listJoinRequests } from "../join-requests.js";
|
|
14
|
+
import type { TelegramActionHandlers } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/** Seconds-from-now → Telegram until_date, honouring "omit = forever". */
|
|
17
|
+
function untilDate(minutes: unknown): number | undefined {
|
|
18
|
+
const n = Number(minutes);
|
|
19
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
20
|
+
return Math.floor(Date.now() / 1000) + Math.round(n * 60);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Friendly permission names → ChatPermissions. `send_media` fans out to all
|
|
25
|
+
* the per-kind media flags; raw `can_*` keys pass through untouched so the
|
|
26
|
+
* full API surface stays reachable.
|
|
27
|
+
*/
|
|
28
|
+
function toChatPermissions(raw: Record<string, boolean>): ChatPermissions {
|
|
29
|
+
const out: Record<string, boolean> = {};
|
|
30
|
+
const alias: Record<string, string[]> = {
|
|
31
|
+
send_messages: ["can_send_messages"],
|
|
32
|
+
send_media: [
|
|
33
|
+
"can_send_audios",
|
|
34
|
+
"can_send_documents",
|
|
35
|
+
"can_send_photos",
|
|
36
|
+
"can_send_videos",
|
|
37
|
+
"can_send_video_notes",
|
|
38
|
+
"can_send_voice_notes",
|
|
39
|
+
],
|
|
40
|
+
send_polls: ["can_send_polls"],
|
|
41
|
+
send_other: ["can_send_other_messages"],
|
|
42
|
+
web_previews: ["can_add_web_page_previews"],
|
|
43
|
+
change_info: ["can_change_info"],
|
|
44
|
+
invite_users: ["can_invite_users"],
|
|
45
|
+
pin_messages: ["can_pin_messages"],
|
|
46
|
+
manage_topics: ["can_manage_topics"],
|
|
47
|
+
};
|
|
48
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
49
|
+
if (typeof value !== "boolean") continue;
|
|
50
|
+
for (const target of alias[key] ?? (key.startsWith("can_") ? [key] : [])) {
|
|
51
|
+
out[target] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Everything a mute takes away; unmute grants it back. */
|
|
58
|
+
const FULL_MEMBER_PERMISSIONS: ChatPermissions = toChatPermissions({
|
|
59
|
+
send_messages: true,
|
|
60
|
+
send_media: true,
|
|
61
|
+
send_polls: true,
|
|
62
|
+
send_other: true,
|
|
63
|
+
web_previews: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** The workaday admin kit `promote` grants; demote sets all of these false. */
|
|
67
|
+
const DEFAULT_ADMIN_RIGHTS = {
|
|
68
|
+
can_manage_chat: true,
|
|
69
|
+
can_delete_messages: true,
|
|
70
|
+
can_restrict_members: true,
|
|
71
|
+
can_pin_messages: true,
|
|
72
|
+
can_invite_users: true,
|
|
73
|
+
can_change_info: true,
|
|
74
|
+
can_manage_topics: true,
|
|
75
|
+
can_manage_video_chats: true,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const moderationHandlers: TelegramActionHandlers = {
|
|
79
|
+
moderate: async (body, chatId, ctx) => {
|
|
80
|
+
const { bot, InputFileClass } = ctx;
|
|
81
|
+
const op = String(body.op ?? "");
|
|
82
|
+
const userId = toPositiveId(body.user_id);
|
|
83
|
+
const needsUser = [
|
|
84
|
+
"ban",
|
|
85
|
+
"unban",
|
|
86
|
+
"mute",
|
|
87
|
+
"unmute",
|
|
88
|
+
"promote",
|
|
89
|
+
"demote",
|
|
90
|
+
"set_admin_title",
|
|
91
|
+
"approve_join_request",
|
|
92
|
+
"decline_join_request",
|
|
93
|
+
];
|
|
94
|
+
if (needsUser.includes(op) && userId === undefined)
|
|
95
|
+
return { ok: false, error: `${op}: user_id is required` };
|
|
96
|
+
|
|
97
|
+
switch (op) {
|
|
98
|
+
case "ban":
|
|
99
|
+
await bot.api.banChatMember(chatId, userId!, {
|
|
100
|
+
until_date: untilDate(body.minutes),
|
|
101
|
+
revoke_messages: body.delete_messages === true || undefined,
|
|
102
|
+
});
|
|
103
|
+
return { ok: true };
|
|
104
|
+
case "unban":
|
|
105
|
+
// only_if_banned keeps this from kicking a current member.
|
|
106
|
+
await bot.api.unbanChatMember(chatId, userId!, {
|
|
107
|
+
only_if_banned: true,
|
|
108
|
+
});
|
|
109
|
+
return { ok: true };
|
|
110
|
+
case "mute":
|
|
111
|
+
await bot.api.restrictChatMember(
|
|
112
|
+
chatId,
|
|
113
|
+
userId!,
|
|
114
|
+
{ can_send_messages: false },
|
|
115
|
+
{ until_date: untilDate(body.minutes) },
|
|
116
|
+
);
|
|
117
|
+
return { ok: true };
|
|
118
|
+
case "unmute":
|
|
119
|
+
await bot.api.restrictChatMember(
|
|
120
|
+
chatId,
|
|
121
|
+
userId!,
|
|
122
|
+
FULL_MEMBER_PERMISSIONS,
|
|
123
|
+
);
|
|
124
|
+
return { ok: true };
|
|
125
|
+
case "promote":
|
|
126
|
+
await bot.api.promoteChatMember(chatId, userId!, DEFAULT_ADMIN_RIGHTS);
|
|
127
|
+
return { ok: true };
|
|
128
|
+
case "demote": {
|
|
129
|
+
const revoked = Object.fromEntries(
|
|
130
|
+
Object.keys(DEFAULT_ADMIN_RIGHTS).map((k) => [k, false]),
|
|
131
|
+
);
|
|
132
|
+
await bot.api.promoteChatMember(chatId, userId!, revoked);
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
135
|
+
case "set_admin_title":
|
|
136
|
+
await bot.api.setChatAdministratorCustomTitle(
|
|
137
|
+
chatId,
|
|
138
|
+
userId!,
|
|
139
|
+
String(body.title ?? ""),
|
|
140
|
+
);
|
|
141
|
+
return { ok: true };
|
|
142
|
+
case "set_permissions": {
|
|
143
|
+
const raw = body.permissions;
|
|
144
|
+
if (!raw || typeof raw !== "object")
|
|
145
|
+
return { ok: false, error: "set_permissions: permissions required" };
|
|
146
|
+
await bot.api.setChatPermissions(
|
|
147
|
+
chatId,
|
|
148
|
+
toChatPermissions(raw as Record<string, boolean>),
|
|
149
|
+
);
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
case "create_invite_link": {
|
|
153
|
+
const expireMin = Number(body.expire_minutes);
|
|
154
|
+
const link = await bot.api.createChatInviteLink(chatId, {
|
|
155
|
+
name: body.title ? String(body.title) : undefined,
|
|
156
|
+
expire_date:
|
|
157
|
+
Number.isFinite(expireMin) && expireMin > 0
|
|
158
|
+
? Math.floor(Date.now() / 1000) + Math.round(expireMin * 60)
|
|
159
|
+
: undefined,
|
|
160
|
+
member_limit: toPositiveId(body.member_limit),
|
|
161
|
+
});
|
|
162
|
+
return { ok: true, invite_link: link.invite_link };
|
|
163
|
+
}
|
|
164
|
+
case "revoke_invite_link": {
|
|
165
|
+
if (!body.link)
|
|
166
|
+
return { ok: false, error: "revoke_invite_link: link required" };
|
|
167
|
+
await bot.api.revokeChatInviteLink(chatId, String(body.link));
|
|
168
|
+
return { ok: true };
|
|
169
|
+
}
|
|
170
|
+
case "approve_join_request":
|
|
171
|
+
await bot.api.approveChatJoinRequest(chatId, userId!);
|
|
172
|
+
clearJoinRequest(chatId, userId!);
|
|
173
|
+
return { ok: true };
|
|
174
|
+
case "decline_join_request":
|
|
175
|
+
await bot.api.declineChatJoinRequest(chatId, userId!);
|
|
176
|
+
clearJoinRequest(chatId, userId!);
|
|
177
|
+
return { ok: true };
|
|
178
|
+
case "list_join_requests":
|
|
179
|
+
return { ok: true, requests: listJoinRequests(chatId) };
|
|
180
|
+
case "set_chat_photo": {
|
|
181
|
+
const resolved = resolveMediaInput(
|
|
182
|
+
body,
|
|
183
|
+
"set_chat_photo",
|
|
184
|
+
InputFileClass,
|
|
185
|
+
);
|
|
186
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
187
|
+
if (typeof resolved.file === "string")
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: "set_chat_photo needs a local file_path (no url/file_id)",
|
|
191
|
+
};
|
|
192
|
+
await bot.api.setChatPhoto(chatId, resolved.file);
|
|
193
|
+
return { ok: true };
|
|
194
|
+
}
|
|
195
|
+
case "delete_chat_photo":
|
|
196
|
+
await bot.api.deleteChatPhoto(chatId);
|
|
197
|
+
return { ok: true };
|
|
198
|
+
case "unpin_all":
|
|
199
|
+
await bot.api.unpinAllChatMessages(chatId);
|
|
200
|
+
return { ok: true };
|
|
201
|
+
case "leave_chat":
|
|
202
|
+
await bot.api.leaveChat(chatId);
|
|
203
|
+
return { ok: true };
|
|
204
|
+
case "create_topic": {
|
|
205
|
+
if (!body.title)
|
|
206
|
+
return { ok: false, error: "create_topic: title required" };
|
|
207
|
+
const topic = await bot.api.createForumTopic(
|
|
208
|
+
chatId,
|
|
209
|
+
String(body.title),
|
|
210
|
+
);
|
|
211
|
+
return { ok: true, thread_id: topic.message_thread_id };
|
|
212
|
+
}
|
|
213
|
+
case "edit_topic": {
|
|
214
|
+
const threadId = toPositiveId(body.thread_id);
|
|
215
|
+
if (threadId === undefined)
|
|
216
|
+
return { ok: false, error: "edit_topic: thread_id required" };
|
|
217
|
+
await bot.api.editForumTopic(chatId, threadId, {
|
|
218
|
+
name: body.title ? String(body.title) : undefined,
|
|
219
|
+
});
|
|
220
|
+
return { ok: true };
|
|
221
|
+
}
|
|
222
|
+
case "close_topic":
|
|
223
|
+
case "reopen_topic":
|
|
224
|
+
case "delete_topic": {
|
|
225
|
+
const threadId = toPositiveId(body.thread_id);
|
|
226
|
+
if (threadId === undefined)
|
|
227
|
+
return { ok: false, error: `${op}: thread_id required` };
|
|
228
|
+
if (op === "close_topic")
|
|
229
|
+
await bot.api.closeForumTopic(chatId, threadId);
|
|
230
|
+
else if (op === "reopen_topic")
|
|
231
|
+
await bot.api.reopenForumTopic(chatId, threadId);
|
|
232
|
+
else await bot.api.deleteForumTopic(chatId, threadId);
|
|
233
|
+
return { ok: true };
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
return { ok: false, error: `Unknown moderation op: ${op}` };
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
get_user_profile_photos: async (body, _chatId, { bot }) => {
|
|
241
|
+
const userId = toPositiveId(body.user_id);
|
|
242
|
+
if (userId === undefined)
|
|
243
|
+
return { ok: false, error: "user_id is required" };
|
|
244
|
+
const limit = toPositiveId(body.limit) ?? 5;
|
|
245
|
+
const photos = await bot.api.getUserProfilePhotos(userId, {
|
|
246
|
+
limit: Math.min(limit, 100),
|
|
247
|
+
});
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
total: photos.total_count,
|
|
251
|
+
// Largest size of each photo; its file_id sends/downloads like media.
|
|
252
|
+
file_ids: photos.photos.map((sizes) => sizes[sizes.length - 1].file_id),
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
};
|