@jungjaehoon/mama-os 0.25.0 → 0.27.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +37 -17
  3. package/dist/agent/agent-loop.d.ts.map +1 -1
  4. package/dist/agent/agent-loop.js +8 -0
  5. package/dist/agent/agent-loop.js.map +1 -1
  6. package/dist/agent/attachment-text-extractor.d.ts +2 -0
  7. package/dist/agent/attachment-text-extractor.d.ts.map +1 -0
  8. package/dist/agent/attachment-text-extractor.js +128 -0
  9. package/dist/agent/attachment-text-extractor.js.map +1 -0
  10. package/dist/agent/code-act/constants.d.ts.map +1 -1
  11. package/dist/agent/code-act/constants.js +17 -2
  12. package/dist/agent/code-act/constants.js.map +1 -1
  13. package/dist/agent/code-act/host-bridge.d.ts.map +1 -1
  14. package/dist/agent/code-act/host-bridge.js +107 -0
  15. package/dist/agent/code-act/host-bridge.js.map +1 -1
  16. package/dist/agent/code-act/tool-policy.d.ts +9 -1
  17. package/dist/agent/code-act/tool-policy.d.ts.map +1 -1
  18. package/dist/agent/code-act/tool-policy.js +30 -1
  19. package/dist/agent/code-act/tool-policy.js.map +1 -1
  20. package/dist/agent/codex-app-server-process.d.ts +1 -0
  21. package/dist/agent/codex-app-server-process.d.ts.map +1 -1
  22. package/dist/agent/codex-app-server-process.js +16 -0
  23. package/dist/agent/codex-app-server-process.js.map +1 -1
  24. package/dist/agent/drive-tools.d.ts +55 -0
  25. package/dist/agent/drive-tools.d.ts.map +1 -0
  26. package/dist/agent/drive-tools.js +263 -0
  27. package/dist/agent/drive-tools.js.map +1 -0
  28. package/dist/agent/gateway-tool-executor.d.ts +11 -3
  29. package/dist/agent/gateway-tool-executor.d.ts.map +1 -1
  30. package/dist/agent/gateway-tool-executor.js +249 -19
  31. package/dist/agent/gateway-tool-executor.js.map +1 -1
  32. package/dist/agent/gateway-tools.md +9 -0
  33. package/dist/agent/image-translation-tools.d.ts +48 -0
  34. package/dist/agent/image-translation-tools.d.ts.map +1 -0
  35. package/dist/agent/image-translation-tools.js +274 -0
  36. package/dist/agent/image-translation-tools.js.map +1 -0
  37. package/dist/agent/role-manager.d.ts.map +1 -1
  38. package/dist/agent/role-manager.js +9 -1
  39. package/dist/agent/role-manager.js.map +1 -1
  40. package/dist/agent/tool-registry.d.ts.map +1 -1
  41. package/dist/agent/tool-registry.js +53 -0
  42. package/dist/agent/tool-registry.js.map +1 -1
  43. package/dist/agent/types.d.ts +22 -33
  44. package/dist/agent/types.d.ts.map +1 -1
  45. package/dist/agent/types.js.map +1 -1
  46. package/dist/cli/commands/start.d.ts.map +1 -1
  47. package/dist/cli/commands/start.js +18 -8
  48. package/dist/cli/commands/start.js.map +1 -1
  49. package/dist/cli/config/types.d.ts.map +1 -1
  50. package/dist/cli/config/types.js +12 -4
  51. package/dist/cli/config/types.js.map +1 -1
  52. package/dist/cli/runtime/envelope-bootstrap.d.ts.map +1 -1
  53. package/dist/cli/runtime/envelope-bootstrap.js +8 -1
  54. package/dist/cli/runtime/envelope-bootstrap.js.map +1 -1
  55. package/dist/connectors/drive/index.d.ts +1 -1
  56. package/dist/connectors/drive/index.d.ts.map +1 -1
  57. package/dist/connectors/drive/index.js +100 -56
  58. package/dist/connectors/drive/index.js.map +1 -1
  59. package/dist/connectors/framework/gws-utils.d.ts +3 -0
  60. package/dist/connectors/framework/gws-utils.d.ts.map +1 -1
  61. package/dist/connectors/framework/gws-utils.js +17 -0
  62. package/dist/connectors/framework/gws-utils.js.map +1 -1
  63. package/dist/envelope/enforcer.d.ts.map +1 -1
  64. package/dist/envelope/enforcer.js +18 -3
  65. package/dist/envelope/enforcer.js.map +1 -1
  66. package/dist/envelope/reactive-config.d.ts +1 -1
  67. package/dist/envelope/reactive-config.d.ts.map +1 -1
  68. package/dist/envelope/reactive-config.js +10 -2
  69. package/dist/envelope/reactive-config.js.map +1 -1
  70. package/dist/envelope/types.d.ts +1 -1
  71. package/dist/envelope/types.d.ts.map +1 -1
  72. package/dist/envelope/types.js.map +1 -1
  73. package/dist/gateways/message-router.d.ts +4 -0
  74. package/dist/gateways/message-router.d.ts.map +1 -1
  75. package/dist/gateways/message-router.js +433 -335
  76. package/dist/gateways/message-router.js.map +1 -1
  77. package/dist/gateways/session-store.d.ts +2 -0
  78. package/dist/gateways/session-store.d.ts.map +1 -1
  79. package/dist/gateways/session-store.js +41 -1
  80. package/dist/gateways/session-store.js.map +1 -1
  81. package/dist/gateways/telegram-media.d.ts +41 -0
  82. package/dist/gateways/telegram-media.d.ts.map +1 -0
  83. package/dist/gateways/telegram-media.js +242 -0
  84. package/dist/gateways/telegram-media.js.map +1 -0
  85. package/dist/gateways/telegram-message-ledger.d.ts +49 -0
  86. package/dist/gateways/telegram-message-ledger.d.ts.map +1 -0
  87. package/dist/gateways/telegram-message-ledger.js +266 -0
  88. package/dist/gateways/telegram-message-ledger.js.map +1 -0
  89. package/dist/gateways/telegram-response-presenter.d.ts +41 -0
  90. package/dist/gateways/telegram-response-presenter.d.ts.map +1 -0
  91. package/dist/gateways/telegram-response-presenter.js +250 -0
  92. package/dist/gateways/telegram-response-presenter.js.map +1 -0
  93. package/dist/gateways/telegram.d.ts +30 -7
  94. package/dist/gateways/telegram.d.ts.map +1 -1
  95. package/dist/gateways/telegram.js +444 -124
  96. package/dist/gateways/telegram.js.map +1 -1
  97. package/dist/gateways/types.d.ts +4 -2
  98. package/dist/gateways/types.d.ts.map +1 -1
  99. package/dist/operator/operator-interfaces.d.ts +1 -1
  100. package/dist/operator/operator-interfaces.d.ts.map +1 -1
  101. package/dist/operator/operator-trigger-loop.d.ts +12 -0
  102. package/dist/operator/operator-trigger-loop.d.ts.map +1 -1
  103. package/dist/operator/operator-trigger-loop.js +184 -38
  104. package/dist/operator/operator-trigger-loop.js.map +1 -1
  105. package/dist/operator/pending-report-store.d.ts +35 -0
  106. package/dist/operator/pending-report-store.d.ts.map +1 -0
  107. package/dist/operator/pending-report-store.js +155 -0
  108. package/dist/operator/pending-report-store.js.map +1 -0
  109. package/dist/operator/runtime-config.d.ts +10 -0
  110. package/dist/operator/runtime-config.d.ts.map +1 -0
  111. package/dist/operator/runtime-config.js +21 -0
  112. package/dist/operator/runtime-config.js.map +1 -0
  113. package/dist/operator/situation-report.d.ts +37 -0
  114. package/dist/operator/situation-report.d.ts.map +1 -1
  115. package/dist/operator/situation-report.js +122 -9
  116. package/dist/operator/situation-report.js.map +1 -1
  117. package/package.json +3 -1
  118. package/scripts/attachment/extract-office-text.js +179 -0
  119. package/scripts/attachment/extract-pdf-text.swift +36 -0
  120. package/scripts/image/fb-overlay.py +128 -0
  121. package/scripts/image/ocr-image.py +38 -0
  122. package/scripts/setup-ocr.js +120 -0
  123. package/templates/skills/image-translate.md +17 -22
@@ -3,24 +3,56 @@
3
3
  * Telegram Gateway for MAMA Standalone
4
4
  *
5
5
  * Production-hardened Telegram bot integration using grammY:
6
- * - 2-stage message dedup (message_id + content signature)
6
+ * - Telegram message_id dedup without dropping repeated short replies
7
+ * - Photo/document/caption ingestion through MAMA content blocks
7
8
  * - Group chat filtering (mention/command/reply-to-bot only)
8
9
  * - Sticker receive/send with emotion mapping
9
- * - ToolStatusTracker streaming (placeholder → editMessageText)
10
+ * - Single-message streaming presenter (placeholder → final answer)
10
11
  * - Typing indicator, error handling
11
12
  */
12
13
  Object.defineProperty(exports, "__esModule", { value: true });
13
14
  exports.TelegramGateway = void 0;
15
+ const promises_1 = require("node:fs/promises");
16
+ const node_crypto_1 = require("node:crypto");
17
+ const node_os_1 = require("node:os");
18
+ const node_path_1 = require("node:path");
19
+ const node_async_hooks_1 = require("node:async_hooks");
14
20
  const grammy_1 = require("grammy");
15
21
  const base_gateway_js_1 = require("./base-gateway.js");
16
22
  const memory_logger_js_1 = require("../memory/memory-logger.js");
17
23
  const untrusted_content_js_1 = require("../utils/untrusted-content.js");
18
- const tool_status_tracker_js_1 = require("./tool-status-tracker.js");
24
+ const attachment_utils_js_1 = require("./attachment-utils.js");
25
+ const telegram_media_js_1 = require("./telegram-media.js");
26
+ const telegram_response_presenter_js_1 = require("./telegram-response-presenter.js");
27
+ const telegram_message_ledger_js_1 = require("./telegram-message-ledger.js");
19
28
  const TELEGRAM_MAX_LENGTH = 4096;
20
29
  const MESSAGE_DEDUP_TTL_MS = 60_000;
21
- const MESSAGE_CONTENT_DEDUP_TTL_MS = 5_000;
22
30
  const REJECTED_CHAT_WARN_INTERVAL_MS = 60_000;
23
31
  const TYPING_INTERVAL_MS = 4_000;
32
+ function parseOutboundChunkProgress(value) {
33
+ const parsed = JSON.parse(value);
34
+ if (!parsed || typeof parsed !== 'object')
35
+ throw new Error('Invalid Telegram outbound progress');
36
+ const record = parsed;
37
+ if (record.version !== 1 ||
38
+ !Number.isSafeInteger(record.nextIndex) ||
39
+ record.nextIndex < 0 ||
40
+ typeof record.uncertain !== 'boolean') {
41
+ throw new Error('Invalid Telegram outbound progress');
42
+ }
43
+ return { nextIndex: record.nextIndex, uncertain: record.uncertain };
44
+ }
45
+ function isDefiniteTelegramApiRejection(error) {
46
+ if (!error || typeof error !== 'object')
47
+ return false;
48
+ const record = error;
49
+ if (Number.isSafeInteger(record.error_code))
50
+ return true;
51
+ const response = record.response;
52
+ return (Boolean(response) &&
53
+ typeof response === 'object' &&
54
+ Number.isSafeInteger(response.error_code));
55
+ }
24
56
  const EMOTION_EMOJI = {
25
57
  happy: ['😊', '😀', '😄', '🙂'],
26
58
  laugh: ['😂', '🤣', '😆'],
@@ -51,9 +83,13 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
51
83
  botUsername = '';
52
84
  lastError = null;
53
85
  lastMessageAt;
54
- // Dedup maps
86
+ mediaRoot;
87
+ fetchImpl;
88
+ messageLedger;
89
+ chatTails = new Map();
90
+ activeChat = new node_async_hooks_1.AsyncLocalStorage();
91
+ // Telegram update dedup
55
92
  recentMessageIds = new Map();
56
- recentMessageSignatures = new Map();
57
93
  rejectedChatWarnAt = new Map();
58
94
  // Dedup cleanup timer
59
95
  dedupCleanupTimer = null;
@@ -71,6 +107,13 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
71
107
  token: options.token,
72
108
  allowedChats: options.config?.allowedChats || [],
73
109
  };
110
+ this.mediaRoot =
111
+ options.mediaRoot ??
112
+ (0, node_path_1.join)(process.env.MAMA_WORKSPACE || (0, node_path_1.join)((0, node_os_1.homedir)(), '.mama', 'workspace'), 'media', 'inbound', 'telegram');
113
+ this.fetchImpl = options.fetchImpl;
114
+ this.messageLedger = new telegram_message_ledger_js_1.TelegramMessageLedger(options.messageLedgerPath ??
115
+ process.env.MAMA_TELEGRAM_MESSAGE_LEDGER_PATH ??
116
+ `${this.mediaRoot}.processed-message-ids.json`, { log: (line) => console.error(line) });
74
117
  }
75
118
  async start() {
76
119
  if (this.connected) {
@@ -93,6 +136,9 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
93
136
  timestamp: new Date(),
94
137
  data: { error: error instanceof Error ? error.message : String(error) },
95
138
  });
139
+ // Let grammY treat the update as failed. The durable message ledger
140
+ // prevents unsafe turn re-execution and replays a ready outbox response.
141
+ throw error;
96
142
  }
97
143
  });
98
144
  this.bot.catch((err) => {
@@ -103,6 +149,7 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
103
149
  this.botId = this.bot.botInfo.id;
104
150
  this.botUsername = this.bot.botInfo.username || '';
105
151
  console.log(`Telegram bot logged in as @${this.botUsername}`);
152
+ await this.recoverPendingInboundDeliveries();
106
153
  if (this.config.allowedChats && this.config.allowedChats.length > 0) {
107
154
  console.log(`[Telegram] Inbound allowlist active: ${this.config.allowedChats.length} chat(s)`);
108
155
  }
@@ -113,6 +160,7 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
113
160
  }
114
161
  this.connected = true;
115
162
  this.lastError = null;
163
+ await (0, telegram_media_js_1.pruneTelegramMediaRoot)(this.mediaRoot);
116
164
  // Periodic dedup cleanup
117
165
  this.dedupCleanupTimer = setInterval(() => {
118
166
  const now = Date.now();
@@ -120,15 +168,17 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
120
168
  if (now - ts > MESSAGE_DEDUP_TTL_MS)
121
169
  this.recentMessageIds.delete(key);
122
170
  }
123
- for (const [key, ts] of this.recentMessageSignatures) {
124
- if (now - ts > MESSAGE_CONTENT_DEDUP_TTL_MS)
125
- this.recentMessageSignatures.delete(key);
126
- }
127
171
  for (const [key, ts] of this.rejectedChatWarnAt) {
128
172
  if (now - ts > REJECTED_CHAT_WARN_INTERVAL_MS) {
129
173
  this.rejectedChatWarnAt.delete(key);
130
174
  }
131
175
  }
176
+ void (0, telegram_media_js_1.pruneTelegramMediaRoot)(this.mediaRoot).catch((error) => {
177
+ console.error(`[Telegram] media retention cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
178
+ });
179
+ void this.recoverPendingInboundDeliveries().catch((error) => {
180
+ console.error(`[Telegram] pending response recovery failed: ${error instanceof Error ? error.message : String(error)}`);
181
+ });
132
182
  }, 60_000);
133
183
  this.emitEvent({
134
184
  type: 'connected',
@@ -179,32 +229,47 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
179
229
  });
180
230
  }
181
231
  async handleMessage(msg) {
232
+ const chatKey = String(msg.chat?.id ?? 'unknown');
233
+ await this.runInChatQueue(chatKey, () => this.processMessage(msg));
234
+ }
235
+ async runInChatQueue(chatKey, work, allowReentrant = false) {
236
+ if (allowReentrant && this.activeChat.getStore() === chatKey)
237
+ return work();
238
+ const previous = this.chatTails.get(chatKey);
239
+ let release;
240
+ const gate = new Promise((resolve) => {
241
+ release = resolve;
242
+ });
243
+ const currentTail = (previous ?? Promise.resolve()).catch(() => { }).then(() => gate);
244
+ this.chatTails.set(chatKey, currentTail);
245
+ try {
246
+ if (previous)
247
+ await previous.catch(() => { });
248
+ return await this.activeChat.run(chatKey, work);
249
+ }
250
+ finally {
251
+ release();
252
+ if (this.chatTails.get(chatKey) === currentTail)
253
+ this.chatTails.delete(chatKey);
254
+ }
255
+ }
256
+ async processMessage(msg) {
182
257
  if (!msg.from || !msg.chat)
183
258
  return;
184
259
  const now = Date.now();
185
- // Stage 1: message_id dedup (60s TTL)
260
+ // Telegram update dedup (60s TTL). Do not deduplicate by text content:
261
+ // repeated short replies and emoji are distinct conversation turns.
186
262
  const messageKey = `${msg.chat.id}:${msg.message_id}`;
263
+ if (this.messageLedger.get(messageKey)?.state === 'delivered')
264
+ return;
187
265
  if (this.recentMessageIds.has(messageKey))
188
266
  return;
189
267
  this.recentMessageIds.set(messageKey, now);
190
- // Stage 2: content signature dedup (5s TTL)
191
- const rawText = msg.text || msg.sticker?.emoji || '';
192
- const signature = `${msg.chat.id}:${msg.from.id}:${rawText.trim()}`;
193
- if (rawText.trim()) {
194
- const seenAt = this.recentMessageSignatures.get(signature);
195
- if (seenAt && now - seenAt < MESSAGE_CONTENT_DEDUP_TTL_MS)
196
- return;
197
- this.recentMessageSignatures.set(signature, now);
198
- }
199
268
  // Cleanup expired entries
200
269
  for (const [key, ts] of this.recentMessageIds) {
201
270
  if (now - ts > MESSAGE_DEDUP_TTL_MS)
202
271
  this.recentMessageIds.delete(key);
203
272
  }
204
- for (const [key, ts] of this.recentMessageSignatures) {
205
- if (now - ts > MESSAGE_CONTENT_DEDUP_TTL_MS)
206
- this.recentMessageSignatures.delete(key);
207
- }
208
273
  // Allowed chats filter
209
274
  if (this.config.allowedChats && this.config.allowedChats.length > 0) {
210
275
  if (!this.config.allowedChats.includes(String(msg.chat.id))) {
@@ -218,38 +283,193 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
218
283
  return;
219
284
  }
220
285
  }
286
+ const hasMedia = Boolean((msg.photo && msg.photo.length > 0) || msg.document);
287
+ if (hasMedia && (!this.config.allowedChats || this.config.allowedChats.length === 0)) {
288
+ const lastWarn = this.rejectedChatWarnAt.get(String(msg.chat.id)) ?? 0;
289
+ if (now - lastWarn > REJECTED_CHAT_WARN_INTERVAL_MS) {
290
+ this.rejectedChatWarnAt.set(String(msg.chat.id), now);
291
+ console.warn('[Telegram] Dropped media because telegram.allowed_chats is not configured');
292
+ }
293
+ return;
294
+ }
295
+ const selectedText = msg.text ?? msg.caption ?? '';
296
+ const selectedEntities = msg.text !== undefined ? msg.entities : msg.caption_entities;
221
297
  // Group chat filtering
222
298
  const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup';
223
299
  if (isGroup) {
224
- const text = msg.text || '';
225
300
  const isMention = this.botUsername &&
226
- msg.entities?.some((e) => e.type === 'mention' &&
227
- text.slice(e.offset, e.offset + e.length).toLowerCase() ===
301
+ selectedEntities?.some((e) => e.type === 'mention' &&
302
+ selectedText.slice(e.offset, e.offset + e.length).toLowerCase() ===
228
303
  `@${this.botUsername.toLowerCase()}`);
229
- const isBotCommand = msg.entities?.some((e) => e.type === 'bot_command');
304
+ const isBotCommand = selectedEntities?.some((e) => e.type === 'bot_command');
230
305
  const isReplyToBot = msg.reply_to_message?.from?.id === this.botId;
231
306
  if (!isMention && !isBotCommand && !isReplyToBot)
232
307
  return;
233
308
  }
234
- // Process text (sticker conversion, mention stripping)
235
- let text = msg.text || '';
309
+ let text = selectedText;
236
310
  if (msg.sticker) {
237
311
  text = `[sticker: ${msg.sticker.emoji || '😊'}]`;
238
312
  }
239
- if (!text.trim())
240
- return;
313
+ if (isGroup && this.botUsername) {
314
+ const escaped = this.botUsername.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
315
+ text = text.replace(new RegExp(`@${escaped}\\b`, 'gi'), '').trim();
316
+ }
241
317
  // Provenance labeling: a FORWARDED message is third-party content the
242
- // owner relayed, not the owner speaking. Wrap it so downstream treats it
243
- // as data (never instructions), the sensitive-request wall skips it, and
244
- // the save-candidate extractor ignores directives inside it.
318
+ // owner relayed, not the owner speaking. Selection and mention removal
319
+ // happen first so caption provenance follows the same trust boundary.
245
320
  const isForwarded = Boolean(msg.forward_origin);
321
+ const durableEntry = this.messageLedger.get(messageKey);
322
+ const numChatId = msg.chat.id;
323
+ const api = this.bot.api;
324
+ const presenter = this.createResponsePresenter(numChatId, messageKey, durableEntry?.nextChunkIndex ?? 0);
325
+ if (durableEntry?.state !== 'ready')
326
+ await presenter.start();
327
+ if (durableEntry?.state === 'ready' && durableEntry.response !== undefined) {
328
+ try {
329
+ await presenter.finalize(durableEntry.response);
330
+ this.messageLedger.markDelivered(messageKey);
331
+ }
332
+ catch (error) {
333
+ this.recentMessageIds.delete(messageKey);
334
+ throw error;
335
+ }
336
+ return;
337
+ }
338
+ if (durableEntry?.state === 'processing') {
339
+ const interruptedNotice = 'The previous processing attempt was interrupted. It was not rerun because its external ' +
340
+ 'side effects could not be proven safe to repeat. Please send a new message if you want ' +
341
+ 'to retry it.';
342
+ this.messageLedger.markReady(messageKey, interruptedNotice);
343
+ try {
344
+ await presenter.finalize(interruptedNotice);
345
+ this.messageLedger.markDelivered(messageKey);
346
+ }
347
+ catch (error) {
348
+ this.recentMessageIds.delete(messageKey);
349
+ throw error;
350
+ }
351
+ return;
352
+ }
353
+ const attachments = [];
354
+ const contentBlocks = [];
355
+ let transientMediaPath;
356
+ try {
357
+ if (msg.photo && msg.photo.length > 0) {
358
+ const photo = msg.photo[msg.photo.length - 1];
359
+ const downloaded = await (0, telegram_media_js_1.downloadTelegramMedia)({
360
+ botToken: this.token,
361
+ fileId: photo.file_id,
362
+ fileUniqueId: photo.file_unique_id,
363
+ declaredSize: photo.file_size,
364
+ kind: 'photo',
365
+ mediaRoot: this.mediaRoot,
366
+ getFile: (fileId) => api.getFile(fileId),
367
+ fetchImpl: this.fetchImpl,
368
+ });
369
+ transientMediaPath = downloaded.localPath;
370
+ const detectedType = (0, attachment_utils_js_1.detectImageType)(await (0, promises_1.readFile)(downloaded.localPath));
371
+ if (!detectedType) {
372
+ await presenter.fail('This image format is not supported.');
373
+ return;
374
+ }
375
+ const attachment = {
376
+ type: 'image',
377
+ sourceRef: downloaded.sourceRef,
378
+ localPath: downloaded.localPath,
379
+ filename: downloaded.filename,
380
+ contentType: detectedType,
381
+ size: downloaded.size,
382
+ };
383
+ attachments.push(attachment);
384
+ const builtBlocks = await (0, attachment_utils_js_1.buildContentBlocks)([attachment]);
385
+ const imageBlocks = builtBlocks.filter((block) => block.type === 'image');
386
+ if (imageBlocks.length === 0) {
387
+ await presenter.fail('This image format is not supported.');
388
+ return;
389
+ }
390
+ for (const block of imageBlocks) {
391
+ contentBlocks.push({ type: 'image', source: block.source });
392
+ }
393
+ // Keep the private local path for bounded OCR/overlay follow-ups. The
394
+ // media retention sweeper removes it after the configured TTL/quota.
395
+ transientMediaPath = undefined;
396
+ if (!text.trim()) {
397
+ text = '[Image]';
398
+ }
399
+ }
400
+ else if (msg.document) {
401
+ const downloaded = await (0, telegram_media_js_1.downloadTelegramMedia)({
402
+ botToken: this.token,
403
+ fileId: msg.document.file_id,
404
+ fileUniqueId: msg.document.file_unique_id,
405
+ filename: msg.document.file_name,
406
+ mimeType: msg.document.mime_type,
407
+ declaredSize: msg.document.file_size,
408
+ kind: 'document',
409
+ mediaRoot: this.mediaRoot,
410
+ getFile: (fileId) => api.getFile(fileId),
411
+ fetchImpl: this.fetchImpl,
412
+ });
413
+ transientMediaPath = downloaded.localPath;
414
+ const detectedType = (0, attachment_utils_js_1.detectImageType)(await (0, promises_1.readFile)(downloaded.localPath));
415
+ if (downloaded.mimeType.startsWith('image/') && !detectedType) {
416
+ await presenter.fail('This image format is not supported.');
417
+ return;
418
+ }
419
+ const attachment = {
420
+ type: detectedType ? 'image' : 'file',
421
+ sourceRef: downloaded.sourceRef,
422
+ localPath: downloaded.localPath,
423
+ filename: downloaded.filename,
424
+ contentType: detectedType ?? downloaded.mimeType,
425
+ size: downloaded.size,
426
+ };
427
+ attachments.push(attachment);
428
+ if (detectedType) {
429
+ const builtBlocks = await (0, attachment_utils_js_1.buildContentBlocks)([attachment]);
430
+ const imageBlocks = builtBlocks.filter((block) => block.type === 'image');
431
+ if (imageBlocks.length === 0) {
432
+ await presenter.fail('This image format is not supported.');
433
+ return;
434
+ }
435
+ for (const block of imageBlocks) {
436
+ contentBlocks.push({ type: 'image', source: block.source });
437
+ }
438
+ if (!text.trim()) {
439
+ text = `[Image: ${downloaded.filename}]`;
440
+ }
441
+ transientMediaPath = undefined;
442
+ }
443
+ else {
444
+ contentBlocks.push({
445
+ type: 'text',
446
+ text: `[File: ${downloaded.filename}, type: ${downloaded.mimeType}]`,
447
+ });
448
+ if (!text.trim()) {
449
+ text = `[File: ${downloaded.filename}]`;
450
+ }
451
+ // Documents remain in the private inbound workspace so the routed
452
+ // turn and a bounded follow-up can read the actual attachment.
453
+ transientMediaPath = undefined;
454
+ }
455
+ }
456
+ }
457
+ catch {
458
+ await presenter.fail(msg.document ? 'The file could not be downloaded.' : 'The image could not be downloaded.');
459
+ return;
460
+ }
461
+ finally {
462
+ if (transientMediaPath) {
463
+ await (0, promises_1.unlink)(transientMediaPath).catch(() => { });
464
+ }
465
+ }
466
+ if (!text.trim()) {
467
+ await presenter.fail('This Telegram message type is not supported.');
468
+ return;
469
+ }
246
470
  if (isForwarded) {
247
471
  text = (0, untrusted_content_js_1.wrapUntrustedContent)('telegram-forward', text);
248
472
  }
249
- if (isGroup && this.botUsername) {
250
- const escaped = this.botUsername.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
251
- text = text.replace(new RegExp(`@${escaped}\\b`, 'gi'), '').trim();
252
- }
253
473
  const sender = msg.from.username || String(msg.from.id);
254
474
  console.log(`[Telegram] Message from ${sender} (${text.length} chars)`);
255
475
  const memoryLogger = (0, memory_logger_js_1.getMemoryLogger)();
@@ -265,10 +485,12 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
265
485
  channelId: String(msg.chat.id),
266
486
  userId: String(msg.from.id),
267
487
  text,
488
+ contentBlocks: contentBlocks.length > 0 ? contentBlocks : undefined,
268
489
  metadata: {
269
490
  username: msg.from.username,
270
491
  messageId: String(msg.message_id),
271
492
  chatType: msg.chat.type,
493
+ attachments: attachments.length > 0 ? attachments : undefined,
272
494
  // Trusted provenance flag: downstream strips untrusted blocks ONLY
273
495
  // when the GATEWAY wrapped them - sender-typed markers are not a
274
496
  // security boundary (forgeable in-band data).
@@ -277,118 +499,233 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
277
499
  };
278
500
  // Typing indicator
279
501
  const chatId = String(msg.chat.id);
280
- const numChatId = msg.chat.id;
281
502
  const sendTyping = () => this.bot?.api.sendChatAction(numChatId, 'typing').catch(() => { });
282
503
  sendTyping();
283
504
  const typingInterval = setInterval(sendTyping, TYPING_INTERVAL_MS);
284
- // ToolStatusTracker for streaming progress
285
- const api = this.bot.api;
286
- const telegramAdapter = {
287
- postPlaceholder: async (content) => {
288
- try {
289
- const sent = await api.sendMessage(numChatId, content);
290
- return String(sent.message_id);
291
- }
292
- catch {
293
- return null;
294
- }
295
- },
296
- editPlaceholder: async (handle, content) => {
297
- try {
298
- await api.editMessageText(numChatId, Number(handle), content);
299
- }
300
- catch {
301
- /* ignore same-text errors */
302
- }
303
- },
304
- deletePlaceholder: async (handle) => {
505
+ try {
506
+ // Process through message router
507
+ let result;
508
+ this.messageLedger.claim(messageKey);
509
+ try {
510
+ result = await this.messageRouter.process(normalizedMessage, {
511
+ onStream: presenter.callbacks(),
512
+ onQueued: () => presenter.markQueued(),
513
+ });
514
+ }
515
+ catch (error) {
516
+ const failureNotice = 'An error occurred while processing the message.';
517
+ this.messageLedger.markReady(messageKey, failureNotice);
305
518
  try {
306
- await api.editMessageText(numChatId, Number(handle), '✅');
519
+ await presenter.fail(failureNotice);
520
+ this.messageLedger.markDelivered(messageKey);
307
521
  }
308
522
  catch {
309
- /* ignore */
523
+ this.recentMessageIds.delete(messageKey);
310
524
  }
311
- },
312
- };
313
- const tracker = new tool_status_tracker_js_1.ToolStatusTracker(telegramAdapter, {
314
- throttleMs: 2000,
315
- initialDelayMs: 1000,
316
- });
317
- const streamCallbacks = tracker.toStreamCallbacks();
318
- // Process through message router
319
- let result;
320
- try {
321
- result = await this.messageRouter.process(normalizedMessage, {
322
- onStream: streamCallbacks,
525
+ throw error;
526
+ }
527
+ this.messageLedger.markReady(messageKey, result.response);
528
+ try {
529
+ await presenter.finalize(result.response);
530
+ this.messageLedger.markDelivered(messageKey);
531
+ }
532
+ catch (error) {
533
+ this.recentMessageIds.delete(messageKey);
534
+ throw error;
535
+ }
536
+ this.lastMessageAt = Date.now();
537
+ memoryLogger.logMessage('Telegram', 'MAMA', result.response, true);
538
+ this.emitEvent({
539
+ type: 'message_sent',
540
+ source: 'telegram',
541
+ timestamp: new Date(),
542
+ data: {
543
+ chatId,
544
+ responseLength: result.response.length,
545
+ duration: result.duration,
546
+ },
323
547
  });
324
548
  }
325
549
  finally {
326
550
  clearInterval(typingInterval);
327
- await tracker.cleanup();
328
551
  }
329
- this.lastMessageAt = Date.now();
330
- memoryLogger.logMessage('Telegram', 'MAMA', result.response, true);
331
- await this.sendMessage(chatId, result.response);
332
- this.emitEvent({
333
- type: 'message_sent',
334
- source: 'telegram',
335
- timestamp: new Date(),
336
- data: {
337
- chatId,
338
- responseLength: result.response.length,
339
- duration: result.duration,
340
- },
341
- });
342
552
  }
343
- async sendMessage(chatId, text) {
553
+ async sendMessage(chatId, text, idempotencyKey) {
554
+ if (!this.bot)
555
+ throw new Error('Telegram gateway not connected');
556
+ if (!text.trim())
557
+ return;
558
+ await this.runInChatQueue(chatId, () => this.sendMessageNow(chatId, text, idempotencyKey));
559
+ }
560
+ async sendMessageFromActiveTurn(chatId, text, idempotencyKey) {
344
561
  if (!this.bot)
345
562
  throw new Error('Telegram gateway not connected');
346
563
  if (!text.trim())
347
564
  return;
348
- const chunks = this.splitMessage(text, TELEGRAM_MAX_LENGTH);
565
+ await this.runInChatQueue(chatId, () => this.sendMessageNow(chatId, text, idempotencyKey), true);
566
+ }
567
+ async sendMessageNow(chatId, text, idempotencyKey) {
568
+ const bot = this.bot;
569
+ if (!bot)
570
+ throw new Error('Telegram gateway not connected');
571
+ const chunks = (0, telegram_response_presenter_js_1.splitTelegramMessage)(text, TELEGRAM_MAX_LENGTH);
349
572
  const numChatId = Number(chatId);
350
- for (const chunk of chunks) {
573
+ if (!idempotencyKey) {
574
+ for (const chunk of chunks)
575
+ await bot.api.sendMessage(numChatId, chunk);
576
+ return;
577
+ }
578
+ const ledgerKey = this.outboundLedgerKey(idempotencyKey, 'text');
579
+ const existing = this.messageLedger.get(ledgerKey);
580
+ if (existing?.state === 'delivered')
581
+ return;
582
+ if (!existing)
583
+ this.messageLedger.claim(ledgerKey);
584
+ let nextIndex = 0;
585
+ if (existing?.state === 'ready' && existing.response) {
586
+ const progress = parseOutboundChunkProgress(existing.response);
587
+ nextIndex = progress.nextIndex;
588
+ if (progress.uncertain) {
589
+ console.warn(`[Telegram] Retrying delivery ${ledgerKey} from chunk ${nextIndex} after an ` +
590
+ 'ambiguous prior acceptance; at-least-once delivery may duplicate that chunk');
591
+ }
592
+ }
593
+ for (let index = nextIndex; index < chunks.length; index += 1) {
594
+ this.messageLedger.markReady(ledgerKey, JSON.stringify({ version: 1, nextIndex: index, uncertain: true }));
351
595
  try {
352
- await this.bot.api.sendMessage(numChatId, chunk, { parse_mode: 'Markdown' });
596
+ await bot.api.sendMessage(numChatId, chunks[index]);
353
597
  }
354
- catch (err) {
355
- // Retry as plain text only for Markdown parse errors (400)
356
- const status = err?.error_code;
357
- if (status === 400) {
358
- await this.bot.api.sendMessage(numChatId, chunk);
359
- }
360
- else {
361
- throw err;
598
+ catch (error) {
599
+ if (isDefiniteTelegramApiRejection(error)) {
600
+ this.messageLedger.markReady(ledgerKey, JSON.stringify({ version: 1, nextIndex: index, uncertain: false }));
362
601
  }
602
+ throw error;
363
603
  }
604
+ this.messageLedger.markReady(ledgerKey, JSON.stringify({ version: 1, nextIndex: index + 1, uncertain: false }));
364
605
  }
606
+ this.messageLedger.markDelivered(ledgerKey);
607
+ }
608
+ async sendFile(chatId, filePath, caption, idempotencyKey) {
609
+ if (!this.bot)
610
+ throw new Error('Telegram gateway not connected');
611
+ const { InputFile } = await import('grammy');
612
+ await this.runInChatQueue(chatId, () => this.sendOutboundOnce(idempotencyKey, 'file', () => this.bot.api.sendDocument(Number(chatId), new InputFile(filePath), { caption }).then(() => { })));
613
+ }
614
+ async sendFileFromActiveTurn(chatId, filePath, caption, idempotencyKey) {
615
+ if (!this.bot)
616
+ throw new Error('Telegram gateway not connected');
617
+ const { InputFile } = await import('grammy');
618
+ await this.runInChatQueue(chatId, () => this.sendOutboundOnce(idempotencyKey, 'file', () => this.bot.api.sendDocument(Number(chatId), new InputFile(filePath), { caption }).then(() => { })), true);
365
619
  }
366
- async sendFile(chatId, filePath, caption) {
620
+ async sendImage(chatId, imagePath, caption, idempotencyKey) {
367
621
  if (!this.bot)
368
622
  throw new Error('Telegram gateway not connected');
369
623
  const { InputFile } = await import('grammy');
370
- await this.bot.api.sendDocument(Number(chatId), new InputFile(filePath), { caption });
624
+ await this.runInChatQueue(chatId, () => this.sendOutboundOnce(idempotencyKey, 'image', () => this.bot.api.sendPhoto(Number(chatId), new InputFile(imagePath), { caption }).then(() => { })));
371
625
  }
372
- async sendImage(chatId, imagePath, caption) {
626
+ async sendImageFromActiveTurn(chatId, imagePath, caption, idempotencyKey) {
373
627
  if (!this.bot)
374
628
  throw new Error('Telegram gateway not connected');
375
629
  const { InputFile } = await import('grammy');
376
- await this.bot.api.sendPhoto(Number(chatId), new InputFile(imagePath), { caption });
630
+ await this.runInChatQueue(chatId, () => this.sendOutboundOnce(idempotencyKey, 'image', () => this.bot.api.sendPhoto(Number(chatId), new InputFile(imagePath), { caption }).then(() => { })), true);
631
+ }
632
+ async sendOutboundOnce(idempotencyKey, kind, send) {
633
+ if (!idempotencyKey) {
634
+ await send();
635
+ return;
636
+ }
637
+ const ledgerKey = this.outboundLedgerKey(idempotencyKey, kind);
638
+ const existing = this.messageLedger.get(ledgerKey);
639
+ if (existing?.state === 'delivered')
640
+ return;
641
+ if (!existing)
642
+ this.messageLedger.claim(ledgerKey);
643
+ await send();
644
+ this.messageLedger.markDelivered(ledgerKey);
645
+ }
646
+ async recoverPendingInboundDeliveries() {
647
+ for (const snapshot of this.messageLedger.listUndelivered()) {
648
+ if (snapshot.key.startsWith('outbound:'))
649
+ continue;
650
+ const separator = snapshot.key.lastIndexOf(':');
651
+ if (separator <= 0)
652
+ continue;
653
+ const chatId = snapshot.key.slice(0, separator);
654
+ await this.runInChatQueue(chatId, async () => {
655
+ const entry = this.messageLedger.get(snapshot.key);
656
+ if (!entry || entry.state === 'delivered')
657
+ return;
658
+ if (entry.state === 'processing' && this.messageLedger.isOwnedByCurrentProcess(entry)) {
659
+ return;
660
+ }
661
+ const response = entry.state === 'ready' && entry.response !== undefined
662
+ ? entry.response
663
+ : 'The previous processing attempt was interrupted. It was not rerun because its ' +
664
+ 'external side effects could not be proven safe to repeat. Please send a new message ' +
665
+ 'if you want to retry it.';
666
+ if (entry.state !== 'ready')
667
+ this.messageLedger.markReady(entry.key, response);
668
+ if (entry.deliveryUncertain) {
669
+ console.warn(`[Telegram] Resuming inbound delivery ${entry.key} from uncertain chunk ` +
670
+ `${entry.nextChunkIndex ?? 0}; that chunk may appear twice`);
671
+ }
672
+ const presenter = this.createResponsePresenter(Number(chatId), entry.key, entry.nextChunkIndex ?? 0);
673
+ await presenter.finalize(response);
674
+ this.messageLedger.markDelivered(entry.key);
675
+ });
676
+ }
677
+ }
678
+ createResponsePresenter(chatId, messageKey, resumeFromChunk = 0) {
679
+ const api = this.bot.api;
680
+ return new telegram_response_presenter_js_1.TelegramResponsePresenter({
681
+ send: async (content) => {
682
+ const sent = await api.sendMessage(chatId, content);
683
+ return String(sent.message_id);
684
+ },
685
+ edit: async (handle, content) => {
686
+ await api.editMessageText(chatId, Number(handle), content);
687
+ },
688
+ delete: async (handle) => {
689
+ await api.deleteMessage(chatId, Number(handle));
690
+ },
691
+ }, {
692
+ resumeFromChunk,
693
+ onChunkProgress: (nextIndex, uncertain) => {
694
+ if (this.messageLedger.get(messageKey)?.state === 'ready') {
695
+ this.messageLedger.markDeliveryProgress(messageKey, nextIndex, uncertain);
696
+ }
697
+ },
698
+ });
699
+ }
700
+ outboundLedgerKey(idempotencyKey, kind) {
701
+ const digest = (0, node_crypto_1.createHash)('sha256').update(`${kind}\0${idempotencyKey}`).digest('hex');
702
+ return `outbound:${digest}`;
377
703
  }
378
704
  async sendSticker(chatId, emotion) {
379
705
  if (!this.bot)
380
706
  throw new Error('Telegram gateway not connected');
707
+ return this.runInChatQueue(String(chatId), () => this.sendStickerNow(chatId, emotion));
708
+ }
709
+ async sendStickerFromActiveTurn(chatId, emotion) {
710
+ if (!this.bot)
711
+ throw new Error('Telegram gateway not connected');
712
+ return this.runInChatQueue(String(chatId), () => this.sendStickerNow(chatId, emotion), true);
713
+ }
714
+ async sendStickerNow(chatId, emotion) {
715
+ const bot = this.bot;
716
+ if (!bot)
717
+ throw new Error('Telegram gateway not connected');
381
718
  await this.loadStickerSet();
382
719
  const candidates = EMOTION_EMOJI[emotion] ?? EMOTION_EMOJI.happy;
383
720
  const numChatId = typeof chatId === 'string' ? Number(chatId) : chatId;
384
721
  for (const emoji of candidates) {
385
722
  const fileId = this.stickerCache.get(emoji);
386
723
  if (fileId) {
387
- await this.bot.api.sendSticker(numChatId, fileId);
724
+ await bot.api.sendSticker(numChatId, fileId);
388
725
  return true;
389
726
  }
390
727
  }
391
- await this.bot.api.sendMessage(numChatId, candidates[0]);
728
+ await bot.api.sendMessage(numChatId, candidates[0]);
392
729
  return false;
393
730
  }
394
731
  getLastError() {
@@ -397,23 +734,6 @@ class TelegramGateway extends base_gateway_js_1.BaseGateway {
397
734
  getLastMessageAt() {
398
735
  return this.lastMessageAt;
399
736
  }
400
- splitMessage(text, maxLength) {
401
- if (text.length <= maxLength)
402
- return [text];
403
- const chunks = [];
404
- let remaining = text;
405
- while (remaining.length > 0) {
406
- if (remaining.length <= maxLength) {
407
- chunks.push(remaining);
408
- break;
409
- }
410
- const newline = remaining.lastIndexOf('\n', maxLength);
411
- const splitAt = newline > maxLength * 0.3 ? newline + 1 : maxLength;
412
- chunks.push(remaining.slice(0, splitAt));
413
- remaining = remaining.slice(splitAt);
414
- }
415
- return chunks;
416
- }
417
737
  async loadStickerSet() {
418
738
  if (this.stickerSetLoaded || !this.bot)
419
739
  return;