@llblab/pi-telegram 0.46.0 → 0.48.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/AGENTS.md +4 -2
- package/BACKLOG.md +0 -6
- package/CHANGELOG.md +14 -0
- package/README.md +5 -2
- package/dist/api/activity.d.ts +6 -0
- package/dist/api/activity.js +6 -0
- package/dist/api/commands.d.ts +6 -0
- package/dist/api/commands.js +6 -0
- package/dist/api/delivery.d.ts +6 -0
- package/dist/api/delivery.js +6 -0
- package/dist/api/inbound.d.ts +6 -0
- package/dist/api/inbound.js +6 -0
- package/dist/api/keyboard.d.ts +6 -0
- package/dist/api/keyboard.js +6 -0
- package/dist/api/outbound.d.ts +6 -0
- package/dist/api/outbound.js +6 -0
- package/dist/api/sections.d.ts +7 -0
- package/dist/api/sections.js +6 -0
- package/dist/api/status.d.ts +6 -0
- package/dist/api/status.js +6 -0
- package/dist/api/updates.d.ts +6 -0
- package/dist/api/updates.js +6 -0
- package/dist/api/voice.d.ts +6 -0
- package/dist/api/voice.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lib/activity-verbosity.d.ts +52 -0
- package/dist/lib/activity-verbosity.js +596 -0
- package/dist/lib/activity.d.ts +220 -0
- package/dist/lib/activity.js +574 -0
- package/dist/lib/agent-messages.d.ts +28 -0
- package/dist/lib/agent-messages.js +86 -0
- package/dist/lib/bindings.d.ts +250 -0
- package/dist/lib/bindings.js +936 -0
- package/dist/lib/bus-api.d.ts +18 -0
- package/dist/lib/bus-api.js +254 -0
- package/dist/lib/bus-follower.d.ts +464 -0
- package/dist/lib/bus-follower.js +1686 -0
- package/dist/lib/bus-leader.d.ts +292 -0
- package/dist/lib/bus-leader.js +2242 -0
- package/dist/lib/bus-transport.d.ts +64 -0
- package/dist/lib/bus-transport.js +140 -0
- package/dist/lib/bus.d.ts +518 -0
- package/dist/lib/bus.js +2055 -0
- package/dist/lib/channel-posts.d.ts +170 -0
- package/dist/lib/channel-posts.js +611 -0
- package/dist/lib/command-templates.d.ts +70 -0
- package/dist/lib/command-templates.js +744 -0
- package/dist/lib/commands.d.ts +541 -0
- package/dist/lib/commands.js +1155 -0
- package/dist/lib/config.d.ts +252 -0
- package/dist/lib/config.js +889 -0
- package/dist/lib/delivery.d.ts +163 -0
- package/dist/lib/delivery.js +542 -0
- package/dist/lib/extension.d.ts +7 -0
- package/dist/lib/extension.js +1604 -0
- package/dist/lib/generative-app-worker.mjs +104 -0
- package/dist/lib/generative-apps.d.ts +212 -0
- package/dist/lib/generative-apps.js +1006 -0
- package/dist/lib/inbound.d.ts +91 -0
- package/dist/lib/inbound.js +502 -0
- package/dist/lib/journal.d.ts +665 -0
- package/dist/lib/journal.js +3701 -0
- package/dist/lib/keyboard.d.ts +23 -0
- package/dist/lib/keyboard.js +37 -0
- package/dist/lib/lifecycle.d.ts +157 -0
- package/dist/lib/lifecycle.js +395 -0
- package/dist/lib/locks.d.ts +164 -0
- package/dist/lib/locks.js +1208 -0
- package/dist/lib/logging.d.ts +50 -0
- package/dist/lib/logging.js +274 -0
- package/dist/lib/media.d.ts +181 -0
- package/dist/lib/media.js +602 -0
- package/dist/lib/menu-model.d.ts +217 -0
- package/dist/lib/menu-model.js +663 -0
- package/dist/lib/menu-queue.d.ts +37 -0
- package/dist/lib/menu-queue.js +408 -0
- package/dist/lib/menu-settings.d.ts +115 -0
- package/dist/lib/menu-settings.js +595 -0
- package/dist/lib/menu-status.d.ts +32 -0
- package/dist/lib/menu-status.js +112 -0
- package/dist/lib/menu-thinking.d.ts +29 -0
- package/dist/lib/menu-thinking.js +82 -0
- package/dist/lib/menu.d.ts +171 -0
- package/dist/lib/menu.js +323 -0
- package/dist/lib/model.d.ts +127 -0
- package/dist/lib/model.js +409 -0
- package/dist/lib/outbound-attachments.d.ts +241 -0
- package/dist/lib/outbound-attachments.js +639 -0
- package/dist/lib/outbound-buttons.d.ts +69 -0
- package/dist/lib/outbound-buttons.js +248 -0
- package/dist/lib/outbound-markup.d.ts +42 -0
- package/dist/lib/outbound-markup.js +678 -0
- package/dist/lib/outbound-voice.d.ts +55 -0
- package/dist/lib/outbound-voice.js +152 -0
- package/dist/lib/outbound.d.ts +185 -0
- package/dist/lib/outbound.js +516 -0
- package/dist/lib/ownership.d.ts +77 -0
- package/dist/lib/ownership.js +174 -0
- package/dist/lib/paths.d.ts +40 -0
- package/dist/lib/paths.js +94 -0
- package/dist/lib/pi.d.ts +72 -0
- package/dist/lib/pi.js +121 -0
- package/dist/lib/polling.d.ts +351 -0
- package/dist/lib/polling.js +1196 -0
- package/dist/lib/preview.d.ts +192 -0
- package/dist/lib/preview.js +614 -0
- package/dist/lib/prompt-templates.d.ts +24 -0
- package/dist/lib/prompt-templates.js +118 -0
- package/dist/lib/prompts.d.ts +57 -0
- package/dist/lib/prompts.js +167 -0
- package/dist/lib/queue.d.ts +766 -0
- package/dist/lib/queue.js +1965 -0
- package/dist/lib/recovery.d.ts +86 -0
- package/dist/lib/recovery.js +285 -0
- package/dist/lib/rendering.d.ts +20 -0
- package/dist/lib/rendering.js +983 -0
- package/dist/lib/replies.d.ts +214 -0
- package/dist/lib/replies.js +737 -0
- package/dist/lib/routing.d.ts +207 -0
- package/dist/lib/routing.js +2282 -0
- package/dist/lib/runtime.d.ts +172 -0
- package/dist/lib/runtime.js +402 -0
- package/dist/lib/sections.d.ts +165 -0
- package/dist/lib/sections.js +327 -0
- package/dist/lib/setup.d.ts +82 -0
- package/dist/lib/setup.js +150 -0
- package/dist/lib/skills.d.ts +8 -0
- package/dist/lib/skills.js +12 -0
- package/dist/lib/status.d.ts +442 -0
- package/dist/lib/status.js +1008 -0
- package/dist/lib/sync.d.ts +179 -0
- package/dist/lib/sync.js +782 -0
- package/dist/lib/target.d.ts +20 -0
- package/dist/lib/target.js +27 -0
- package/dist/lib/telegram-api.d.ts +541 -0
- package/dist/lib/telegram-api.js +1159 -0
- package/dist/lib/text-groups.d.ts +89 -0
- package/dist/lib/text-groups.js +317 -0
- package/dist/lib/thread-cleanup-manager.d.ts +288 -0
- package/dist/lib/thread-cleanup-manager.js +560 -0
- package/dist/lib/thread-display.d.ts +46 -0
- package/dist/lib/thread-display.js +257 -0
- package/dist/lib/thread-naming.d.ts +46 -0
- package/dist/lib/thread-naming.js +78 -0
- package/dist/lib/thread-reconciler.d.ts +239 -0
- package/dist/lib/thread-reconciler.js +644 -0
- package/dist/lib/threads.d.ts +621 -0
- package/dist/lib/threads.js +3679 -0
- package/dist/lib/time-injection.d.ts +15 -0
- package/dist/lib/time-injection.js +56 -0
- package/dist/lib/turns.d.ts +109 -0
- package/dist/lib/turns.js +500 -0
- package/dist/lib/updates.d.ts +1290 -0
- package/dist/lib/updates.js +3634 -0
- package/dist/lib/voice.d.ts +117 -0
- package/dist/lib/voice.js +174 -0
- package/dist/lib/workspace-admission.d.ts +264 -0
- package/dist/lib/workspace-admission.js +1136 -0
- package/dist/lib/workspace-retirement.d.ts +219 -0
- package/dist/lib/workspace-retirement.js +587 -0
- package/dist/lib/workspace-slots.d.ts +30 -0
- package/dist/lib/workspace-slots.js +54 -0
- package/dist/package.json +126 -0
- package/dist/pi-telegram/index.js +1 -0
- package/dist/skills/generated-control-surface/SKILL.md +107 -0
- package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
- package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
- package/dist/skills/generative-apps/SKILL.md +115 -0
- package/dist/skills/show-me/SKILL.md +166 -0
- package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
- package/dist/skills/telegram-bridge/SKILL.md +129 -0
- package/dist/skills/telegram-bridge/references/configuration.md +15 -0
- package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
- package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
- package/docs/README.md +2 -0
- package/docs/architecture.md +1 -1
- package/docs/compact-matrix-literal.md +10 -3
- package/docs/generative-apps.md +15 -13
- package/docs/multi-instance-bus.md +6 -4
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +3 -3
- package/lib/bindings.ts +89 -6
- package/lib/bus-follower.ts +7 -3
- package/lib/bus-leader.ts +44 -19
- package/lib/bus.ts +4 -1
- package/lib/commands.ts +2 -2
- package/lib/config.ts +14 -7
- package/lib/delivery.ts +38 -6
- package/lib/extension.ts +7 -0
- package/lib/generative-apps.ts +379 -13
- package/lib/menu-settings.ts +20 -6
- package/lib/outbound-markup.ts +16 -7
- package/lib/status.ts +15 -12
- package/lib/telegram-api.ts +12 -1
- package/lib/thread-display.ts +96 -18
- package/package.json +56 -13
- package/scripts/build-dist.mjs +44 -0
- package/scripts/measure-bus.mjs +8 -1
- package/scripts/measure-workspace.mjs +8 -1
- package/skills/generative-apps/SKILL.md +1 -1
- package/skills/show-me/SKILL.md +1 -1
- package/skills/show-me/references/telegram-surfaces.md +1 -1
- package/skills/telegram-bridge/SKILL.md +1 -1
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram media and text extraction helpers
|
|
3
|
+
* Zones: telegram inbound, media groups, filesystem paths
|
|
4
|
+
* Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
|
|
5
|
+
*/
|
|
6
|
+
import { basename, dirname } from "node:path";
|
|
7
|
+
const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
|
|
8
|
+
const TELEGRAM_REPLY_CONTEXT_MAX_LENGTH = 1000;
|
|
9
|
+
export function guessExtensionFromMime(mimeType, fallback) {
|
|
10
|
+
if (!mimeType)
|
|
11
|
+
return fallback;
|
|
12
|
+
const normalized = mimeType.toLowerCase();
|
|
13
|
+
if (normalized === "image/jpeg")
|
|
14
|
+
return ".jpg";
|
|
15
|
+
if (normalized === "image/png")
|
|
16
|
+
return ".png";
|
|
17
|
+
if (normalized === "image/webp")
|
|
18
|
+
return ".webp";
|
|
19
|
+
if (normalized === "image/gif")
|
|
20
|
+
return ".gif";
|
|
21
|
+
if (normalized === "audio/ogg")
|
|
22
|
+
return ".ogg";
|
|
23
|
+
if (normalized === "audio/mpeg")
|
|
24
|
+
return ".mp3";
|
|
25
|
+
if (normalized === "audio/wav")
|
|
26
|
+
return ".wav";
|
|
27
|
+
if (normalized === "video/mp4")
|
|
28
|
+
return ".mp4";
|
|
29
|
+
if (normalized === "application/pdf")
|
|
30
|
+
return ".pdf";
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
export function guessMediaType(path) {
|
|
34
|
+
const normalized = path.toLowerCase();
|
|
35
|
+
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) {
|
|
36
|
+
return "image/jpeg";
|
|
37
|
+
}
|
|
38
|
+
if (normalized.endsWith(".png"))
|
|
39
|
+
return "image/png";
|
|
40
|
+
if (normalized.endsWith(".webp"))
|
|
41
|
+
return "image/webp";
|
|
42
|
+
if (normalized.endsWith(".gif"))
|
|
43
|
+
return "image/gif";
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function isImageMimeType(mimeType) {
|
|
47
|
+
return mimeType?.toLowerCase().startsWith("image/") ?? false;
|
|
48
|
+
}
|
|
49
|
+
function getObjectField(value, field) {
|
|
50
|
+
if (typeof value !== "object" || value === null || !(field in value)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return Reflect.get(value, field);
|
|
54
|
+
}
|
|
55
|
+
function joinRichTextParts(parts, separator = "") {
|
|
56
|
+
return parts.filter(Boolean).join(separator).trim();
|
|
57
|
+
}
|
|
58
|
+
function extractTelegramRichText(value) {
|
|
59
|
+
if (typeof value === "string")
|
|
60
|
+
return value;
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
return joinRichTextParts(value.map(extractTelegramRichText));
|
|
63
|
+
}
|
|
64
|
+
if (typeof value !== "object" || value === null)
|
|
65
|
+
return "";
|
|
66
|
+
const text = getObjectField(value, "text");
|
|
67
|
+
if (text !== undefined)
|
|
68
|
+
return extractTelegramRichText(text);
|
|
69
|
+
const expression = getObjectField(value, "expression");
|
|
70
|
+
if (typeof expression === "string")
|
|
71
|
+
return expression;
|
|
72
|
+
const alternativeText = getObjectField(value, "alternative_text");
|
|
73
|
+
if (typeof alternativeText === "string")
|
|
74
|
+
return alternativeText;
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
function extractTelegramRichBlockText(block) {
|
|
78
|
+
if (typeof block !== "object" || block === null)
|
|
79
|
+
return "";
|
|
80
|
+
const directText = extractTelegramRichText(getObjectField(block, "text"));
|
|
81
|
+
if (directText)
|
|
82
|
+
return directText;
|
|
83
|
+
const summary = extractTelegramRichText(getObjectField(block, "summary"));
|
|
84
|
+
const nestedBlocks = extractTelegramRichMessageBlocksText(getObjectField(block, "blocks"));
|
|
85
|
+
const items = getObjectField(block, "items");
|
|
86
|
+
const itemText = Array.isArray(items)
|
|
87
|
+
? items
|
|
88
|
+
.map((item) => {
|
|
89
|
+
const label = getObjectField(item, "label");
|
|
90
|
+
const body = extractTelegramRichMessageBlocksText(getObjectField(item, "blocks"));
|
|
91
|
+
return typeof label === "string" && body ? `${label} ${body}` : body;
|
|
92
|
+
})
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.join("\n")
|
|
95
|
+
: "";
|
|
96
|
+
const cells = getObjectField(block, "cells");
|
|
97
|
+
const cellText = Array.isArray(cells)
|
|
98
|
+
? cells
|
|
99
|
+
.map((row) => Array.isArray(row)
|
|
100
|
+
? row
|
|
101
|
+
.map((cell) => extractTelegramRichText(getObjectField(cell, "text")))
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join(" | ")
|
|
104
|
+
: "")
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join("\n")
|
|
107
|
+
: "";
|
|
108
|
+
const caption = extractTelegramRichText(getObjectField(block, "caption"));
|
|
109
|
+
return joinRichTextParts([summary, nestedBlocks, itemText, cellText, caption], "\n");
|
|
110
|
+
}
|
|
111
|
+
function extractTelegramRichMessageBlocksText(blocks) {
|
|
112
|
+
if (!Array.isArray(blocks))
|
|
113
|
+
return "";
|
|
114
|
+
return joinRichTextParts(blocks.map(extractTelegramRichBlockText), "\n\n");
|
|
115
|
+
}
|
|
116
|
+
function extractTelegramRichMessageText(richMessage) {
|
|
117
|
+
return extractTelegramRichMessageBlocksText(richMessage?.blocks);
|
|
118
|
+
}
|
|
119
|
+
export function extractTelegramMessageText(message) {
|
|
120
|
+
return (extractTelegramRichMessageText(message.rich_message) ||
|
|
121
|
+
message.text ||
|
|
122
|
+
message.caption ||
|
|
123
|
+
"").trim();
|
|
124
|
+
}
|
|
125
|
+
function truncateTelegramReplyContextText(text) {
|
|
126
|
+
if (text.length <= TELEGRAM_REPLY_CONTEXT_MAX_LENGTH)
|
|
127
|
+
return text;
|
|
128
|
+
return `${text.slice(0, TELEGRAM_REPLY_CONTEXT_MAX_LENGTH).trimEnd()}…`;
|
|
129
|
+
}
|
|
130
|
+
function formatTelegramUser(user) {
|
|
131
|
+
if (!user)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (user.username)
|
|
134
|
+
return user.username;
|
|
135
|
+
if (typeof user.id === "number")
|
|
136
|
+
return String(user.id);
|
|
137
|
+
const name = [user.first_name, user.last_name]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(" ")
|
|
140
|
+
.trim();
|
|
141
|
+
return name || undefined;
|
|
142
|
+
}
|
|
143
|
+
function formatTelegramForwardOriginIdentifier(message) {
|
|
144
|
+
const origin = message.forward_origin;
|
|
145
|
+
const user = origin?.sender_user ?? message.forward_from;
|
|
146
|
+
if (user?.username)
|
|
147
|
+
return user.username;
|
|
148
|
+
if (typeof user?.id === "number")
|
|
149
|
+
return String(user.id);
|
|
150
|
+
const chat = origin?.sender_chat ?? origin?.chat;
|
|
151
|
+
if (chat?.username)
|
|
152
|
+
return chat.username;
|
|
153
|
+
if (typeof chat?.id === "number")
|
|
154
|
+
return String(chat.id);
|
|
155
|
+
return origin?.sender_user_name ?? message.forward_sender_name;
|
|
156
|
+
}
|
|
157
|
+
export function extractTelegramForwardContextText(message, allowedUserId) {
|
|
158
|
+
const originUser = message.forward_origin?.sender_user ?? message.forward_from;
|
|
159
|
+
const isOwnerOrigin = typeof allowedUserId === "number" && originUser?.id === allowedUserId;
|
|
160
|
+
const origin = formatTelegramForwardOriginIdentifier(message);
|
|
161
|
+
if (!origin || isOwnerOrigin)
|
|
162
|
+
return "";
|
|
163
|
+
return `from: ${origin}`;
|
|
164
|
+
}
|
|
165
|
+
export function extractTelegramReplyContextText(message) {
|
|
166
|
+
const quoted = (extractTelegramRichMessageText(message.reply_to_message?.rich_message) ||
|
|
167
|
+
message.reply_to_message?.text ||
|
|
168
|
+
message.reply_to_message?.caption ||
|
|
169
|
+
"").trim();
|
|
170
|
+
return quoted ? truncateTelegramReplyContextText(quoted) : "";
|
|
171
|
+
}
|
|
172
|
+
export function buildTelegramReplyContextBlock(message, replyFiles = [], replyOutputs = []) {
|
|
173
|
+
const from = formatTelegramUser(message.reply_to_message?.from);
|
|
174
|
+
const header = from ? `[reply|from:${from}]` : "[reply]";
|
|
175
|
+
const text = extractTelegramReplyContextText(message);
|
|
176
|
+
const dirs = [...new Set(replyFiles.map((file) => dirname(file.path)))];
|
|
177
|
+
const sameDir = dirs.length === 1;
|
|
178
|
+
const attachmentHeader = sameDir
|
|
179
|
+
? `[attachments${from ? `|from:${from}` : ""}] ${dirs[0]}`
|
|
180
|
+
: `[attachments${from ? `|from:${from}` : ""}]`;
|
|
181
|
+
const fileLines = sameDir
|
|
182
|
+
? replyFiles.map((file) => `- /${basename(file.path)}`)
|
|
183
|
+
: replyFiles.map((file) => `- ${file.path}`);
|
|
184
|
+
const replyBlock = text ? `${header} ${text}` : header;
|
|
185
|
+
const sections = [replyBlock];
|
|
186
|
+
if (fileLines.length > 0) {
|
|
187
|
+
sections.push(`${attachmentHeader}\n${fileLines.join("\n")}`);
|
|
188
|
+
}
|
|
189
|
+
if (replyOutputs.length > 0) {
|
|
190
|
+
const outputHeader = `[outputs${from ? `|from:${from}` : ""}]`;
|
|
191
|
+
sections.push(`${outputHeader}\n${replyOutputs.map((output) => `- ${output}`).join("\n")}`);
|
|
192
|
+
}
|
|
193
|
+
if (text || fileLines.length > 0 || replyOutputs.length > 0) {
|
|
194
|
+
return sections.join("\n\n");
|
|
195
|
+
}
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
export function appendTelegramReplyContext(text, replyContext) {
|
|
199
|
+
if (!replyContext)
|
|
200
|
+
return text;
|
|
201
|
+
return text ? `${text}\n\n${replyContext}` : `_\n\n${replyContext}`;
|
|
202
|
+
}
|
|
203
|
+
export function extractTelegramMessagePromptText(message) {
|
|
204
|
+
return appendTelegramReplyContext(extractTelegramMessageText(message), buildTelegramReplyContextBlock(message));
|
|
205
|
+
}
|
|
206
|
+
export function extractTelegramMessagesText(messages) {
|
|
207
|
+
return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
|
|
208
|
+
}
|
|
209
|
+
export function extractTelegramMessagesPromptText(messages) {
|
|
210
|
+
const text = extractTelegramMessagesText(messages);
|
|
211
|
+
const firstMessage = messages[0];
|
|
212
|
+
if (!firstMessage)
|
|
213
|
+
return text;
|
|
214
|
+
return appendTelegramReplyContext(text, buildTelegramReplyContextBlock(firstMessage));
|
|
215
|
+
}
|
|
216
|
+
export function extractFirstTelegramMessageText(messages) {
|
|
217
|
+
return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
|
|
218
|
+
}
|
|
219
|
+
export function hasTelegramMessagePromptContent(message) {
|
|
220
|
+
return (!!extractTelegramMessageText(message) ||
|
|
221
|
+
(Array.isArray(message.photo) && message.photo.length > 0) ||
|
|
222
|
+
!!message.document ||
|
|
223
|
+
!!message.video ||
|
|
224
|
+
!!message.audio ||
|
|
225
|
+
!!message.voice ||
|
|
226
|
+
!!message.animation ||
|
|
227
|
+
!!message.sticker);
|
|
228
|
+
}
|
|
229
|
+
export function hasTelegramMessagesPromptContent(messages) {
|
|
230
|
+
return messages.some(hasTelegramMessagePromptContent);
|
|
231
|
+
}
|
|
232
|
+
export function collectTelegramMessageIds(messages) {
|
|
233
|
+
return [...new Set(messages.map((message) => message.message_id))];
|
|
234
|
+
}
|
|
235
|
+
export function getTelegramMediaGroupKey(message) {
|
|
236
|
+
if (!message.media_group_id)
|
|
237
|
+
return undefined;
|
|
238
|
+
const threadKey = typeof message.message_thread_id === "number"
|
|
239
|
+
? `thread:${message.message_thread_id}`
|
|
240
|
+
: "private";
|
|
241
|
+
return `${message.chat.id}:${threadKey}:${message.media_group_id}`;
|
|
242
|
+
}
|
|
243
|
+
export function removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer) {
|
|
244
|
+
if (messageIds.length === 0 || groups.size === 0)
|
|
245
|
+
return 0;
|
|
246
|
+
const deletedMessageIds = new Set(messageIds);
|
|
247
|
+
let removedGroups = 0;
|
|
248
|
+
for (const [key, state] of groups.entries()) {
|
|
249
|
+
if (!state.messages.some((message) => deletedMessageIds.has(message.message_id))) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (state.flushTimer)
|
|
253
|
+
clearTimer(state.flushTimer);
|
|
254
|
+
groups.delete(key);
|
|
255
|
+
removedGroups += 1;
|
|
256
|
+
}
|
|
257
|
+
return removedGroups;
|
|
258
|
+
}
|
|
259
|
+
export function queueTelegramMediaGroupMessage(options) {
|
|
260
|
+
const key = getTelegramMediaGroupKey(options.message);
|
|
261
|
+
if (!key)
|
|
262
|
+
return false;
|
|
263
|
+
const existing = options.groups.get(key) ?? { messages: [] };
|
|
264
|
+
const duplicateIndex = existing.messages.findIndex((message) => message.message_id === options.message.message_id);
|
|
265
|
+
if (duplicateIndex >= 0) {
|
|
266
|
+
existing.messages[duplicateIndex] = options.message;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
existing.messages.push(options.message);
|
|
270
|
+
}
|
|
271
|
+
existing.context = options.context;
|
|
272
|
+
const dispatchQueued = () => {
|
|
273
|
+
existing.flushTimer = undefined;
|
|
274
|
+
const state = options.groups.get(key);
|
|
275
|
+
if (!state)
|
|
276
|
+
return Promise.resolve();
|
|
277
|
+
if (state.dispatching)
|
|
278
|
+
return state.dispatchPromise ?? Promise.resolve();
|
|
279
|
+
const dispatchedMessages = [...state.messages];
|
|
280
|
+
const dispatchedIds = new Set(dispatchedMessages.map((message) => message.message_id));
|
|
281
|
+
state.dispatching = true;
|
|
282
|
+
const operation = Promise.resolve(options.dispatchMessages(dispatchedMessages, state.context)).then(() => {
|
|
283
|
+
if (options.groups.get(key) !== state)
|
|
284
|
+
return;
|
|
285
|
+
state.messages = state.messages.filter((message) => !dispatchedIds.has(message.message_id));
|
|
286
|
+
state.dispatching = false;
|
|
287
|
+
state.dispatchPromise = undefined;
|
|
288
|
+
if (state.messages.length === 0)
|
|
289
|
+
options.groups.delete(key);
|
|
290
|
+
else if (!state.flushTimer)
|
|
291
|
+
scheduleDispatch();
|
|
292
|
+
}, (error) => {
|
|
293
|
+
if (options.groups.get(key) === state) {
|
|
294
|
+
state.dispatching = false;
|
|
295
|
+
state.dispatchPromise = undefined;
|
|
296
|
+
if (!state.flushTimer)
|
|
297
|
+
scheduleDispatch();
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
});
|
|
301
|
+
state.dispatchPromise = operation;
|
|
302
|
+
return operation;
|
|
303
|
+
};
|
|
304
|
+
const scheduleDispatch = () => {
|
|
305
|
+
if (existing.suspended)
|
|
306
|
+
return;
|
|
307
|
+
existing.flushTimer = options.setTimer(() => {
|
|
308
|
+
void dispatchQueued().catch(() => undefined);
|
|
309
|
+
}, options.debounceMs);
|
|
310
|
+
existing.flushTimer.unref?.();
|
|
311
|
+
};
|
|
312
|
+
existing.reschedule = scheduleDispatch;
|
|
313
|
+
existing.dispatchNow = dispatchQueued;
|
|
314
|
+
if (existing.flushTimer)
|
|
315
|
+
options.clearTimer(existing.flushTimer);
|
|
316
|
+
scheduleDispatch();
|
|
317
|
+
options.groups.set(key, existing);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
export function createTelegramMediaGroupController(options = {}) {
|
|
321
|
+
const groups = new Map();
|
|
322
|
+
const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
|
|
323
|
+
const setTimer = options.setTimer ??
|
|
324
|
+
((callback, ms) => setTimeout(callback, ms));
|
|
325
|
+
const clearTimer = options.clearTimer ?? clearTimeout;
|
|
326
|
+
return {
|
|
327
|
+
queueMessage: ({ message, context, dispatchMessages }) => queueTelegramMediaGroupMessage({
|
|
328
|
+
message,
|
|
329
|
+
context,
|
|
330
|
+
groups,
|
|
331
|
+
debounceMs,
|
|
332
|
+
setTimer,
|
|
333
|
+
clearTimer,
|
|
334
|
+
dispatchMessages,
|
|
335
|
+
}),
|
|
336
|
+
removeMessages: (messageIds) => removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
|
|
337
|
+
async flushMessage(messageId) {
|
|
338
|
+
for (const state of groups.values()) {
|
|
339
|
+
if (!state.messages.some((message) => message.message_id === messageId)) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (state.flushTimer)
|
|
343
|
+
clearTimer(state.flushTimer);
|
|
344
|
+
state.flushTimer = undefined;
|
|
345
|
+
await state.dispatchNow?.();
|
|
346
|
+
if (state.messages.some((message) => message.message_id === messageId) &&
|
|
347
|
+
!state.dispatching) {
|
|
348
|
+
await state.dispatchNow?.();
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
return false;
|
|
353
|
+
},
|
|
354
|
+
suspend: () => {
|
|
355
|
+
for (const state of groups.values()) {
|
|
356
|
+
state.suspended = true;
|
|
357
|
+
if (state.flushTimer)
|
|
358
|
+
clearTimer(state.flushTimer);
|
|
359
|
+
state.flushTimer = undefined;
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
resume: (context) => {
|
|
363
|
+
for (const state of groups.values()) {
|
|
364
|
+
state.context = context;
|
|
365
|
+
state.suspended = false;
|
|
366
|
+
if (!state.dispatching && !state.flushTimer)
|
|
367
|
+
state.reschedule?.();
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
clear: () => {
|
|
371
|
+
for (const state of groups.values()) {
|
|
372
|
+
if (state.flushTimer)
|
|
373
|
+
clearTimer(state.flushTimer);
|
|
374
|
+
}
|
|
375
|
+
groups.clear();
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
export function createTelegramMediaGroupDispatchRuntime(deps) {
|
|
380
|
+
return {
|
|
381
|
+
handleMessage: async (message, ctx) => {
|
|
382
|
+
const queuedMediaGroup = deps.mediaGroups.queueMessage({
|
|
383
|
+
message,
|
|
384
|
+
context: ctx,
|
|
385
|
+
dispatchMessages: (messages, queuedCtx) => queuedCtx === undefined
|
|
386
|
+
? Promise.resolve()
|
|
387
|
+
: deps.dispatchMessages(messages, queuedCtx),
|
|
388
|
+
});
|
|
389
|
+
if (queuedMediaGroup) {
|
|
390
|
+
deps.onDeferredMessage?.(message);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
await deps.dispatchMessages([message], ctx);
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function appendTelegramListSection(text, title, items) {
|
|
398
|
+
if (items.length === 0)
|
|
399
|
+
return text;
|
|
400
|
+
const prefix = text.length > 0 ? `${text}\n\n` : "";
|
|
401
|
+
return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
|
|
402
|
+
}
|
|
403
|
+
function appendTelegramAttachmentSection(text, files) {
|
|
404
|
+
if (files.length === 0)
|
|
405
|
+
return text;
|
|
406
|
+
const dirs = [...new Set(files.map((file) => dirname(file.path)))];
|
|
407
|
+
const sameDir = dirs.length === 1;
|
|
408
|
+
const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
|
|
409
|
+
const items = sameDir
|
|
410
|
+
? files.map((file) => `/${basename(file.path)}`)
|
|
411
|
+
: files.map((file) => file.path);
|
|
412
|
+
const prefix = text.length > 0 ? `${text}\n\n` : "";
|
|
413
|
+
return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
|
|
414
|
+
}
|
|
415
|
+
export function formatTelegramHistoryText(rawText, files, handlerOutputs = []) {
|
|
416
|
+
let summary = rawText.length > 0 ? rawText : "(no text)";
|
|
417
|
+
summary = appendTelegramAttachmentSection(summary, files);
|
|
418
|
+
summary = appendTelegramListSection(summary, "outputs", handlerOutputs);
|
|
419
|
+
return summary;
|
|
420
|
+
}
|
|
421
|
+
export async function downloadTelegramMessageFiles(messages, deps) {
|
|
422
|
+
const downloaded = [];
|
|
423
|
+
for (const file of collectTelegramFileInfos(messages)) {
|
|
424
|
+
downloaded.push({
|
|
425
|
+
path: await deps.downloadFile(file.file_id, file.fileName),
|
|
426
|
+
fileName: file.fileName,
|
|
427
|
+
isImage: file.isImage,
|
|
428
|
+
mimeType: file.mimeType,
|
|
429
|
+
kind: file.kind,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return downloaded;
|
|
433
|
+
}
|
|
434
|
+
function collectTelegramRichBlockFileInfos(blocks, messageId) {
|
|
435
|
+
if (!Array.isArray(blocks))
|
|
436
|
+
return [];
|
|
437
|
+
const files = [];
|
|
438
|
+
let mediaIndex = 0;
|
|
439
|
+
const visit = (entries) => {
|
|
440
|
+
if (!Array.isArray(entries))
|
|
441
|
+
return;
|
|
442
|
+
for (const entry of entries) {
|
|
443
|
+
if (typeof entry !== "object" || entry === null)
|
|
444
|
+
continue;
|
|
445
|
+
const type = getObjectField(entry, "type");
|
|
446
|
+
if (type === "photo" ||
|
|
447
|
+
type === "animation" ||
|
|
448
|
+
type === "audio" ||
|
|
449
|
+
type === "video" ||
|
|
450
|
+
type === "voice_note") {
|
|
451
|
+
mediaIndex += 1;
|
|
452
|
+
}
|
|
453
|
+
if (type === "photo") {
|
|
454
|
+
const photos = getObjectField(entry, "photo");
|
|
455
|
+
if (Array.isArray(photos)) {
|
|
456
|
+
const photo = photos
|
|
457
|
+
.filter((value) => typeof value === "object" &&
|
|
458
|
+
value !== null &&
|
|
459
|
+
typeof getObjectField(value, "file_id") === "string")
|
|
460
|
+
.sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
|
|
461
|
+
.at(-1);
|
|
462
|
+
if (photo) {
|
|
463
|
+
files.push({
|
|
464
|
+
file_id: photo.file_id,
|
|
465
|
+
fileName: `photo-${messageId}-${mediaIndex}.jpg`,
|
|
466
|
+
mimeType: "image/jpeg",
|
|
467
|
+
kind: "photo",
|
|
468
|
+
isImage: true,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const fileField = type === "animation"
|
|
474
|
+
? "animation"
|
|
475
|
+
: type === "audio"
|
|
476
|
+
? "audio"
|
|
477
|
+
: type === "video"
|
|
478
|
+
? "video"
|
|
479
|
+
: type === "voice_note"
|
|
480
|
+
? "voice_note"
|
|
481
|
+
: undefined;
|
|
482
|
+
if (fileField) {
|
|
483
|
+
const media = getObjectField(entry, fileField);
|
|
484
|
+
const fileId = getObjectField(media, "file_id");
|
|
485
|
+
const mimeType = getObjectField(media, "mime_type");
|
|
486
|
+
const fileName = getObjectField(media, "file_name");
|
|
487
|
+
if (typeof fileId === "string") {
|
|
488
|
+
const kind = type === "voice_note" ? "voice" : type;
|
|
489
|
+
const fallbackExtension = kind === "voice" ? ".ogg" : kind === "audio" ? ".mp3" : ".mp4";
|
|
490
|
+
files.push({
|
|
491
|
+
file_id: fileId,
|
|
492
|
+
fileName: typeof fileName === "string"
|
|
493
|
+
? fileName
|
|
494
|
+
: `${kind}-${messageId}-${mediaIndex}${guessExtensionFromMime(typeof mimeType === "string" ? mimeType : undefined, fallbackExtension)}`,
|
|
495
|
+
mimeType: typeof mimeType === "string" ? mimeType : undefined,
|
|
496
|
+
kind,
|
|
497
|
+
isImage: false,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
visit(getObjectField(entry, "blocks"));
|
|
502
|
+
const items = getObjectField(entry, "items");
|
|
503
|
+
if (Array.isArray(items)) {
|
|
504
|
+
for (const item of items)
|
|
505
|
+
visit(getObjectField(item, "blocks"));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
visit(blocks);
|
|
510
|
+
return files;
|
|
511
|
+
}
|
|
512
|
+
export function collectTelegramFileInfos(messages) {
|
|
513
|
+
const files = [];
|
|
514
|
+
for (const message of messages) {
|
|
515
|
+
files.push(...collectTelegramRichBlockFileInfos(message.rich_message?.blocks, message.message_id));
|
|
516
|
+
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
|
517
|
+
const photo = [...message.photo]
|
|
518
|
+
.sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
|
|
519
|
+
.pop();
|
|
520
|
+
if (photo) {
|
|
521
|
+
files.push({
|
|
522
|
+
file_id: photo.file_id,
|
|
523
|
+
fileName: `photo-${message.message_id}.jpg`,
|
|
524
|
+
mimeType: "image/jpeg",
|
|
525
|
+
kind: "photo",
|
|
526
|
+
isImage: true,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (message.document) {
|
|
531
|
+
const fileName = message.document.file_name ||
|
|
532
|
+
`document-${message.message_id}${guessExtensionFromMime(message.document.mime_type, "")}`;
|
|
533
|
+
files.push({
|
|
534
|
+
file_id: message.document.file_id,
|
|
535
|
+
fileName,
|
|
536
|
+
mimeType: message.document.mime_type,
|
|
537
|
+
kind: "document",
|
|
538
|
+
isImage: isImageMimeType(message.document.mime_type),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
if (message.video) {
|
|
542
|
+
const fileName = message.video.file_name ||
|
|
543
|
+
`video-${message.message_id}${guessExtensionFromMime(message.video.mime_type, ".mp4")}`;
|
|
544
|
+
files.push({
|
|
545
|
+
file_id: message.video.file_id,
|
|
546
|
+
fileName,
|
|
547
|
+
mimeType: message.video.mime_type,
|
|
548
|
+
kind: "video",
|
|
549
|
+
isImage: false,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
// Generic audio files (e.g. MP3 uploads) — can also trigger voice replies in "mirror" mode
|
|
553
|
+
if (message.audio) {
|
|
554
|
+
const fileName = message.audio.file_name ||
|
|
555
|
+
`audio-${message.message_id}${guessExtensionFromMime(message.audio.mime_type, ".mp3")}`;
|
|
556
|
+
files.push({
|
|
557
|
+
file_id: message.audio.file_id,
|
|
558
|
+
fileName,
|
|
559
|
+
mimeType: message.audio.mime_type,
|
|
560
|
+
kind: "audio",
|
|
561
|
+
isImage: false,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
// Voice messages (recorded via microphone) — primary trigger for "mirror" voice reply mode
|
|
565
|
+
if (message.voice) {
|
|
566
|
+
files.push({
|
|
567
|
+
file_id: message.voice.file_id,
|
|
568
|
+
fileName: `voice-${message.message_id}${guessExtensionFromMime(message.voice.mime_type, ".ogg")}`,
|
|
569
|
+
mimeType: message.voice.mime_type,
|
|
570
|
+
kind: "voice",
|
|
571
|
+
isImage: false,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
if (message.animation) {
|
|
575
|
+
const fileName = message.animation.file_name ||
|
|
576
|
+
`animation-${message.message_id}${guessExtensionFromMime(message.animation.mime_type, ".mp4")}`;
|
|
577
|
+
files.push({
|
|
578
|
+
file_id: message.animation.file_id,
|
|
579
|
+
fileName,
|
|
580
|
+
mimeType: message.animation.mime_type,
|
|
581
|
+
kind: "animation",
|
|
582
|
+
isImage: false,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
if (message.sticker) {
|
|
586
|
+
files.push({
|
|
587
|
+
file_id: message.sticker.file_id,
|
|
588
|
+
fileName: `sticker-${message.message_id}.webp`,
|
|
589
|
+
mimeType: "image/webp",
|
|
590
|
+
kind: "sticker",
|
|
591
|
+
isImage: true,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const seenFileIds = new Set();
|
|
596
|
+
return files.filter((file) => {
|
|
597
|
+
if (seenFileIds.has(file.file_id))
|
|
598
|
+
return false;
|
|
599
|
+
seenFileIds.add(file.file_id);
|
|
600
|
+
return true;
|
|
601
|
+
});
|
|
602
|
+
}
|