mercury-agent 0.4.5

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.
Files changed (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. package/src/types.ts +295 -0
@@ -0,0 +1,571 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { Adapter, Message } from "chat";
4
+ import {
5
+ downloadMediaFromUrl,
6
+ mimeToExt,
7
+ mimeToMediaType,
8
+ } from "../core/media.js";
9
+ import {
10
+ escapeHtml,
11
+ markdownToTelegramHtml,
12
+ TELEGRAM_MESSAGE_LIMIT,
13
+ truncateTelegramHtml,
14
+ } from "../core/telegram-format.js";
15
+ import { logger } from "../logger.js";
16
+ import { normalizeChatMarkdown } from "../text/markdown.js";
17
+ import { applyRtlDirection } from "../text/rtl.js";
18
+ import type {
19
+ EgressFile,
20
+ IngressMessage,
21
+ MessageAttachment,
22
+ NormalizeContext,
23
+ PlatformBridge,
24
+ } from "../types.js";
25
+
26
+ const TELEGRAM_API_BASE = "https://api.telegram.org";
27
+
28
+ interface TelegramFileRef {
29
+ fileId: string;
30
+ mimeType: string;
31
+ size?: number;
32
+ name?: string;
33
+ }
34
+
35
+ /** Telegram Bot API message payload — we only read media fields. */
36
+ function telegramRawFileRefs(raw: unknown): TelegramFileRef[] {
37
+ if (!raw || typeof raw !== "object") return [];
38
+ const r = raw as Record<string, Record<string, unknown> | undefined>;
39
+ const out: TelegramFileRef[] = [];
40
+
41
+ const voice = r.voice as
42
+ | { file_id?: string; mime_type?: string; file_size?: number }
43
+ | undefined;
44
+ if (voice?.file_id) {
45
+ out.push({
46
+ fileId: voice.file_id,
47
+ mimeType:
48
+ typeof voice.mime_type === "string" ? voice.mime_type : "audio/ogg",
49
+ size: voice.file_size,
50
+ });
51
+ }
52
+
53
+ const audio = r.audio as
54
+ | {
55
+ file_id?: string;
56
+ mime_type?: string;
57
+ file_size?: number;
58
+ file_name?: string;
59
+ }
60
+ | undefined;
61
+ if (audio?.file_id) {
62
+ out.push({
63
+ fileId: audio.file_id,
64
+ mimeType:
65
+ typeof audio.mime_type === "string" ? audio.mime_type : "audio/mpeg",
66
+ size: audio.file_size,
67
+ name: typeof audio.file_name === "string" ? audio.file_name : undefined,
68
+ });
69
+ }
70
+
71
+ const videoNote = r.video_note as
72
+ | { file_id?: string; mime_type?: string; file_size?: number }
73
+ | undefined;
74
+ if (videoNote?.file_id) {
75
+ out.push({
76
+ fileId: videoNote.file_id,
77
+ mimeType:
78
+ typeof videoNote.mime_type === "string"
79
+ ? videoNote.mime_type
80
+ : "video/mp4",
81
+ size: videoNote.file_size,
82
+ name: "telegram-video-note.mp4",
83
+ });
84
+ }
85
+
86
+ return out;
87
+ }
88
+
89
+ const TELEGRAM_REPLY_SNIPPET_MAX = 12_000;
90
+
91
+ /**
92
+ * Extract human-visible body from a Telegram Bot API Message (or reply fragment).
93
+ */
94
+ function extractTelegramMessageVisibleText(
95
+ message: unknown,
96
+ ): string | undefined {
97
+ if (!message || typeof message !== "object") return undefined;
98
+ const m = message as Record<string, unknown>;
99
+ const text = typeof m.text === "string" ? m.text.trim() : "";
100
+ if (text) return text;
101
+ const caption = typeof m.caption === "string" ? m.caption.trim() : "";
102
+ if (caption) return caption;
103
+ if (m.voice) return "[voice message]";
104
+ if (m.audio) return "[audio message]";
105
+ if (m.video_note) return "[video note]";
106
+ if (m.photo && Array.isArray(m.photo) && m.photo.length > 0) return "[photo]";
107
+ if (m.video) return "[video]";
108
+ if (m.document) return "[document]";
109
+ if (m.sticker) return "[sticker]";
110
+ if (m.animation) return "[animation]";
111
+ return undefined;
112
+ }
113
+
114
+ function truncateReplySnippet(s: string): string {
115
+ if (s.length <= TELEGRAM_REPLY_SNIPPET_MAX) return s;
116
+ return `${s.slice(0, TELEGRAM_REPLY_SNIPPET_MAX)}\n… [truncated]`;
117
+ }
118
+
119
+ /**
120
+ * When the user replies to another message, embed quoted content for the model
121
+ * (same idea as WhatsApp inbound `buildReplyContext` in `adapters/whatsapp.ts`).
122
+ */
123
+ function buildTelegramReplyContext(
124
+ replyTo: unknown,
125
+ botUserId: string | undefined,
126
+ ): string | undefined {
127
+ if (!replyTo || typeof replyTo !== "object") return undefined;
128
+ const r = replyTo as Record<string, unknown>;
129
+ const bodyRaw = extractTelegramMessageVisibleText(r);
130
+ if (!bodyRaw) return undefined;
131
+ const body = truncateReplySnippet(bodyRaw);
132
+
133
+ const from = r.from as { id?: number } | undefined;
134
+ const messageId =
135
+ typeof r.message_id === "number" ? String(r.message_id) : "unknown";
136
+ const fromUserId = from?.id != null ? String(from.id) : "unknown";
137
+ const fromBot =
138
+ botUserId != null && from?.id != null && String(from.id) === botUserId;
139
+
140
+ const attrs = [
141
+ `platform="telegram"`,
142
+ `message_id="${messageId}"`,
143
+ `from_user_id="${fromUserId}"`,
144
+ ];
145
+ if (fromBot) attrs.push(`from_bot="true"`);
146
+
147
+ return `<reply_to ${attrs.join(" ")}>\n${body}\n</reply_to>`;
148
+ }
149
+
150
+ /**
151
+ * True if this Telegram message likely carries downloadable media (adapter
152
+ * attachments and/or raw voice/audio/video_note).
153
+ */
154
+ export function telegramInboundLooksLikeMedia(message: {
155
+ attachments?: unknown[] | null;
156
+ raw?: unknown;
157
+ }): boolean {
158
+ if ((message.attachments?.length ?? 0) > 0) return true;
159
+ return telegramRawFileRefs(message.raw).length > 0;
160
+ }
161
+
162
+ export class TelegramBridge implements PlatformBridge {
163
+ readonly platform = "telegram";
164
+
165
+ constructor(
166
+ private readonly adapter: Adapter,
167
+ private readonly botToken: string,
168
+ private readonly formatEnabled = true,
169
+ ) {}
170
+
171
+ parseThread(threadId: string): { externalId: string; isDM: boolean } {
172
+ const parts = threadId.split(":");
173
+ const externalId = parts.slice(1).join(":");
174
+ const chatId = parts[1] ?? "";
175
+ const isDM = !chatId.startsWith("-");
176
+ return { externalId, isDM };
177
+ }
178
+
179
+ async normalize(
180
+ threadId: string,
181
+ message: unknown,
182
+ ctx: NormalizeContext,
183
+ spaceId: string,
184
+ ): Promise<IngressMessage | null> {
185
+ const msg = message as Message;
186
+ if (msg.author.isMe) return null;
187
+
188
+ const text = msg.text.trim();
189
+ const rawAttachments = msg.attachments ?? [];
190
+ const rawRefs = telegramRawFileRefs(msg.raw);
191
+ const hadIncomingAttachments =
192
+ rawAttachments.length > 0 || rawRefs.length > 0;
193
+ if (!text && !hadIncomingAttachments) return null;
194
+
195
+ type AttSource = {
196
+ url?: string;
197
+ name?: string;
198
+ size?: number;
199
+ mimeType?: string;
200
+ fetchData?: () => Promise<Buffer>;
201
+ };
202
+
203
+ let sources: AttSource[] = rawAttachments as AttSource[];
204
+ if (
205
+ ctx.media.enabled &&
206
+ rawAttachments.length === 0 &&
207
+ rawRefs.length > 0
208
+ ) {
209
+ sources = rawRefs.map((ref) => ({
210
+ mimeType: ref.mimeType,
211
+ name: ref.name,
212
+ size: ref.size,
213
+ fetchData: () => this.downloadTelegramFile(ref.fileId),
214
+ }));
215
+ }
216
+
217
+ const attachments: MessageAttachment[] = [];
218
+ if (ctx.media.enabled && sources.length > 0) {
219
+ if (await ctx.isOverQuota()) {
220
+ logger.warn("Skipping media download — storage quota exceeded", {
221
+ spaceId,
222
+ });
223
+ } else {
224
+ const workspace = ctx.getWorkspace(spaceId);
225
+ const inboxDir = path.join(workspace, "inbox");
226
+ for (const att of sources) {
227
+ const mimeType = att.mimeType || "application/octet-stream";
228
+ const type = mimeToMediaType(mimeType);
229
+
230
+ if (att.url) {
231
+ const result = await downloadMediaFromUrl(att.url, {
232
+ type,
233
+ mimeType,
234
+ filename: att.name,
235
+ expectedSizeBytes: att.size,
236
+ maxSizeBytes: ctx.media.maxSizeBytes,
237
+ outputDir: inboxDir,
238
+ });
239
+ if (result) attachments.push(result);
240
+ } else if (att.fetchData) {
241
+ try {
242
+ const buffer = await att.fetchData();
243
+ if (buffer.length > ctx.media.maxSizeBytes) {
244
+ logger.warn("Telegram attachment exceeds size limit", {
245
+ type,
246
+ sizeBytes: buffer.length,
247
+ maxBytes: ctx.media.maxSizeBytes,
248
+ });
249
+ continue;
250
+ }
251
+ fs.mkdirSync(inboxDir, { recursive: true });
252
+ const ext = mimeToExt(mimeType);
253
+ const filename = att.name
254
+ ? `${Date.now()}-${att.name}`
255
+ : `${Date.now()}-${type}.${ext}`;
256
+ const filePath = path.join(inboxDir, filename);
257
+ fs.writeFileSync(filePath, buffer);
258
+ attachments.push({
259
+ path: filePath,
260
+ type,
261
+ mimeType,
262
+ filename: att.name || filename,
263
+ sizeBytes: buffer.length,
264
+ });
265
+ } catch (err) {
266
+ logger.error("Telegram attachment download failed", {
267
+ type,
268
+ error: err instanceof Error ? err.message : String(err),
269
+ });
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ const { externalId, isDM } = this.parseThread(threadId);
277
+
278
+ // @chat-adapter/telegram does not set isReplyToBot in metadata. Derive it from
279
+ // the raw Telegram message: reply_to_message.from.id === botUserId.
280
+ const metadataIsReply = (msg.metadata as { isReplyToBot?: boolean })
281
+ ?.isReplyToBot;
282
+ const raw = msg.raw as
283
+ | {
284
+ message_id?: number;
285
+ reply_to_message?: { from?: { id?: number }; message_id?: number };
286
+ }
287
+ | undefined;
288
+ const botUserId = (this.adapter as { botUserId?: string }).botUserId;
289
+ const derivedReplyToBot =
290
+ raw?.reply_to_message?.from?.id != null &&
291
+ botUserId != null &&
292
+ String(raw.reply_to_message.from.id) === botUserId;
293
+ const isReplyToBot = metadataIsReply ?? derivedReplyToBot ?? false;
294
+
295
+ const replyContext = buildTelegramReplyContext(
296
+ raw?.reply_to_message,
297
+ botUserId,
298
+ );
299
+ const combinedText = [text, replyContext]
300
+ .filter(Boolean)
301
+ .join("\n\n")
302
+ .trim();
303
+
304
+ return {
305
+ platform: "telegram",
306
+ spaceId,
307
+ conversationExternalId: externalId,
308
+ callerId: `telegram:${msg.author.userId || "unknown"}`,
309
+ authorName: msg.author.userName,
310
+ text: combinedText,
311
+ isDM,
312
+ isReplyToBot,
313
+ attachments,
314
+ hadIncomingAttachments,
315
+ replyToPlatformMessageId:
316
+ raw?.reply_to_message?.message_id != null
317
+ ? String(raw.reply_to_message.message_id)
318
+ : undefined,
319
+ platformMessageId:
320
+ raw?.message_id != null ? String(raw.message_id) : undefined,
321
+ };
322
+ }
323
+
324
+ private async downloadTelegramFile(fileId: string): Promise<Buffer> {
325
+ const apiUrl = `${TELEGRAM_API_BASE}/bot${this.botToken}/getFile`;
326
+ const gf = await fetch(apiUrl, {
327
+ method: "POST",
328
+ headers: { "Content-Type": "application/json" },
329
+ body: JSON.stringify({ file_id: fileId }),
330
+ });
331
+ if (!gf.ok) {
332
+ throw new Error(`getFile HTTP ${gf.status}`);
333
+ }
334
+ const body = (await gf.json()) as {
335
+ ok?: boolean;
336
+ result?: { file_path?: string };
337
+ };
338
+ const filePath = body.result?.file_path;
339
+ if (!body.ok || !filePath) {
340
+ throw new Error("getFile: missing file_path");
341
+ }
342
+ const fileUrl = `${TELEGRAM_API_BASE}/file/bot${this.botToken}/${filePath}`;
343
+ const fileResp = await fetch(fileUrl);
344
+ if (!fileResp.ok) {
345
+ throw new Error(`file download HTTP ${fileResp.status}`);
346
+ }
347
+ return Buffer.from(await fileResp.arrayBuffer());
348
+ }
349
+
350
+ async sendReply(
351
+ threadId: string,
352
+ text: string,
353
+ files?: EgressFile[],
354
+ ): Promise<string | undefined> {
355
+ let sentPlatformId: string | undefined;
356
+ if (text) {
357
+ if (this.formatEnabled) {
358
+ sentPlatformId = await this.sendTextMessage(threadId, text);
359
+ } else {
360
+ const sent = await this.adapter.postMessage(
361
+ threadId,
362
+ applyRtlDirection(normalizeChatMarkdown(text)),
363
+ );
364
+ sentPlatformId = sent.id;
365
+ }
366
+ }
367
+
368
+ if (files && files.length > 0) {
369
+ await this.uploadFiles(threadId, files);
370
+ }
371
+
372
+ return sentPlatformId;
373
+ }
374
+
375
+ private async sendTextMessage(
376
+ threadId: string,
377
+ text: string,
378
+ ): Promise<string | undefined> {
379
+ const { chatId, messageThreadId } = this.parseThreadId(threadId);
380
+ let formatted: string;
381
+ try {
382
+ formatted = markdownToTelegramHtml(text);
383
+ } catch {
384
+ formatted = escapeHtml(text);
385
+ }
386
+ const truncated = truncateTelegramHtml(formatted, TELEGRAM_MESSAGE_LIMIT);
387
+
388
+ const apiUrl = `${TELEGRAM_API_BASE}/bot${this.botToken}/sendMessage`;
389
+ // RLM injection runs AFTER HTML format + truncation: the per-line split must
390
+ // operate on the lines Telegram actually receives, and the prefix bytes must
391
+ // not push the message past TELEGRAM_MESSAGE_LIMIT before truncation applies.
392
+ const body: Record<string, string | number> = {
393
+ chat_id: chatId,
394
+ text: applyRtlDirection(truncated),
395
+ parse_mode: "HTML",
396
+ };
397
+ if (messageThreadId !== undefined) {
398
+ body.message_thread_id = messageThreadId;
399
+ }
400
+
401
+ try {
402
+ const resp = await fetch(apiUrl, {
403
+ method: "POST",
404
+ headers: { "Content-Type": "application/json" },
405
+ body: JSON.stringify(body),
406
+ });
407
+
408
+ if (!resp.ok) {
409
+ const errText = await resp.text();
410
+ logger.error("Telegram sendMessage HTTP error", {
411
+ status: resp.status,
412
+ error: errText,
413
+ });
414
+ return undefined;
415
+ }
416
+ const result = (await resp.json()) as {
417
+ ok?: boolean;
418
+ description?: string;
419
+ result?: { message_id?: number };
420
+ };
421
+ if (!result.ok) {
422
+ logger.error("Telegram sendMessage API error", {
423
+ error: result.description,
424
+ });
425
+ return undefined;
426
+ }
427
+ return result.result?.message_id != null
428
+ ? String(result.result.message_id)
429
+ : undefined;
430
+ } catch (err) {
431
+ logger.error("Telegram sendMessage failed", {
432
+ error: err instanceof Error ? err.message : String(err),
433
+ });
434
+ return undefined;
435
+ }
436
+ }
437
+
438
+ private parseThreadId(threadId: string): {
439
+ chatId: string;
440
+ messageThreadId?: number;
441
+ } {
442
+ const parts = threadId.split(":");
443
+ const chatId = parts[1] ?? "";
444
+ const messageThreadPart = parts[2];
445
+ const messageThreadId = messageThreadPart
446
+ ? Number.parseInt(messageThreadPart, 10)
447
+ : undefined;
448
+ return {
449
+ chatId,
450
+ messageThreadId: Number.isFinite(messageThreadId)
451
+ ? messageThreadId
452
+ : undefined,
453
+ };
454
+ }
455
+
456
+ /**
457
+ * Pick Bot API method so audio shows as voice / music in Telegram instead of a generic file.
458
+ * OGG (typical voice notes) → sendVoice; other audio/* → sendAudio; everything else → sendDocument.
459
+ */
460
+ private telegramUploadTarget(file: EgressFile): {
461
+ method: "sendVoice" | "sendAudio" | "sendDocument";
462
+ field: "voice" | "audio" | "document";
463
+ } {
464
+ const mime = file.mimeType.split(";")[0].trim().toLowerCase();
465
+ if (mime === "audio/ogg" || mime === "audio/opus") {
466
+ return { method: "sendVoice", field: "voice" };
467
+ }
468
+ if (mime.startsWith("audio/")) {
469
+ return { method: "sendAudio", field: "audio" };
470
+ }
471
+ return { method: "sendDocument", field: "document" };
472
+ }
473
+
474
+ async editMessage(
475
+ threadId: string,
476
+ messageId: string,
477
+ text: string,
478
+ ): Promise<boolean> {
479
+ const { chatId } = this.parseThreadId(threadId);
480
+ const apiUrl = `${TELEGRAM_API_BASE}/bot${this.botToken}/editMessageText`;
481
+ try {
482
+ const resp = await fetch(apiUrl, {
483
+ method: "POST",
484
+ headers: { "Content-Type": "application/json" },
485
+ body: JSON.stringify({
486
+ chat_id: chatId,
487
+ message_id: Number(messageId),
488
+ text: applyRtlDirection(normalizeChatMarkdown(text)),
489
+ }),
490
+ });
491
+ if (!resp.ok) return false;
492
+ const result = (await resp.json()) as { ok?: boolean };
493
+ return result.ok === true;
494
+ } catch {
495
+ return false;
496
+ }
497
+ }
498
+
499
+ async deleteMessages(threadId: string, messageIds: string[]): Promise<void> {
500
+ const { chatId } = this.parseThreadId(threadId);
501
+ const apiUrl = `${TELEGRAM_API_BASE}/bot${this.botToken}/deleteMessage`;
502
+ for (const id of messageIds) {
503
+ try {
504
+ await fetch(apiUrl, {
505
+ method: "POST",
506
+ headers: { "Content-Type": "application/json" },
507
+ body: JSON.stringify({ chat_id: chatId, message_id: Number(id) }),
508
+ });
509
+ } catch {
510
+ // best-effort
511
+ }
512
+ }
513
+ }
514
+
515
+ private async uploadFiles(
516
+ threadId: string,
517
+ files: EgressFile[],
518
+ ): Promise<void> {
519
+ const { chatId, messageThreadId } = this.parseThreadId(threadId);
520
+
521
+ for (const file of files) {
522
+ try {
523
+ const buffer = fs.readFileSync(file.path);
524
+ const { method, field } = this.telegramUploadTarget(file);
525
+ const apiUrl = `${TELEGRAM_API_BASE}/bot${this.botToken}/${method}`;
526
+ const form = new FormData();
527
+ form.append("chat_id", chatId);
528
+ form.append(
529
+ field,
530
+ new Blob([buffer], { type: file.mimeType }),
531
+ file.filename,
532
+ );
533
+ if (messageThreadId !== undefined) {
534
+ form.append("message_thread_id", String(messageThreadId));
535
+ }
536
+
537
+ const resp = await fetch(apiUrl, {
538
+ method: "POST",
539
+ body: form,
540
+ });
541
+
542
+ if (!resp.ok) {
543
+ const errText = await resp.text();
544
+ logger.error("Telegram file upload HTTP error", {
545
+ filename: file.filename,
546
+ method,
547
+ status: resp.status,
548
+ error: errText,
549
+ });
550
+ } else {
551
+ const body = (await resp.json()) as {
552
+ ok?: boolean;
553
+ description?: string;
554
+ };
555
+ if (!body.ok) {
556
+ logger.error("Telegram file upload API error", {
557
+ filename: file.filename,
558
+ method,
559
+ error: body.description,
560
+ });
561
+ }
562
+ }
563
+ } catch (err) {
564
+ logger.error("Telegram file upload failed", {
565
+ filename: file.filename,
566
+ error: err instanceof Error ? err.message : String(err),
567
+ });
568
+ }
569
+ }
570
+ }
571
+ }