@odience-network/paperclip-plugin-telegram-enhanced 0.2.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/dist/acp-bridge.d.ts +35 -0
  4. package/dist/acp-bridge.js +891 -0
  5. package/dist/acp-bridge.js.map +1 -0
  6. package/dist/adapter.d.ts +35 -0
  7. package/dist/adapter.js +75 -0
  8. package/dist/adapter.js.map +1 -0
  9. package/dist/agent-labels.d.ts +12 -0
  10. package/dist/agent-labels.js +96 -0
  11. package/dist/agent-labels.js.map +1 -0
  12. package/dist/allowlist.d.ts +27 -0
  13. package/dist/allowlist.js +34 -0
  14. package/dist/allowlist.js.map +1 -0
  15. package/dist/approval-routing.d.ts +2 -0
  16. package/dist/approval-routing.js +7 -0
  17. package/dist/approval-routing.js.map +1 -0
  18. package/dist/command-registry.d.ts +3 -0
  19. package/dist/command-registry.js +268 -0
  20. package/dist/command-registry.js.map +1 -0
  21. package/dist/commands.d.ts +11 -0
  22. package/dist/commands.js +516 -0
  23. package/dist/commands.js.map +1 -0
  24. package/dist/constants.d.ts +76 -0
  25. package/dist/constants.js +71 -0
  26. package/dist/constants.js.map +1 -0
  27. package/dist/escalation.d.ts +42 -0
  28. package/dist/escalation.js +252 -0
  29. package/dist/escalation.js.map +1 -0
  30. package/dist/file-routing.d.ts +51 -0
  31. package/dist/file-routing.js +212 -0
  32. package/dist/file-routing.js.map +1 -0
  33. package/dist/formatters.d.ts +31 -0
  34. package/dist/formatters.js +336 -0
  35. package/dist/formatters.js.map +1 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.js +4 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/interaction-delivery.d.ts +90 -0
  40. package/dist/interaction-delivery.js +142 -0
  41. package/dist/interaction-delivery.js.map +1 -0
  42. package/dist/manifest.d.ts +3 -0
  43. package/dist/manifest.js +111 -0
  44. package/dist/manifest.js.map +1 -0
  45. package/dist/media-pipeline.d.ts +47 -0
  46. package/dist/media-pipeline.js +162 -0
  47. package/dist/media-pipeline.js.map +1 -0
  48. package/dist/notification-filters.d.ts +23 -0
  49. package/dist/notification-filters.js +93 -0
  50. package/dist/notification-filters.js.map +1 -0
  51. package/dist/paperclip-api.d.ts +25 -0
  52. package/dist/paperclip-api.js +69 -0
  53. package/dist/paperclip-api.js.map +1 -0
  54. package/dist/polling-offset.d.ts +22 -0
  55. package/dist/polling-offset.js +68 -0
  56. package/dist/polling-offset.js.map +1 -0
  57. package/dist/secret-ref-validation.d.ts +7 -0
  58. package/dist/secret-ref-validation.js +49 -0
  59. package/dist/secret-ref-validation.js.map +1 -0
  60. package/dist/telegram-api.d.ts +40 -0
  61. package/dist/telegram-api.js +251 -0
  62. package/dist/telegram-api.js.map +1 -0
  63. package/dist/topic-projects.d.ts +2 -0
  64. package/dist/topic-projects.js +45 -0
  65. package/dist/topic-projects.js.map +1 -0
  66. package/dist/ui/index.d.ts +2 -0
  67. package/dist/ui/index.js +1446 -0
  68. package/dist/ui/index.js.map +1 -0
  69. package/dist/watch-registry.d.ts +9 -0
  70. package/dist/watch-registry.js +272 -0
  71. package/dist/watch-registry.js.map +1 -0
  72. package/dist/worker.d.ts +162 -0
  73. package/dist/worker.js +1520 -0
  74. package/dist/worker.js.map +1 -0
  75. package/package.json +59 -0
package/dist/worker.js ADDED
@@ -0,0 +1,1520 @@
1
+ import { definePlugin, runWorker, } from "@paperclipai/plugin-sdk";
2
+ import { sendMessage, sendDocument, editMessage, answerCallbackQuery, setMyCommands, escapeMarkdownV2, isForum, GENERAL_TOPIC_THREAD_ID, } from "./telegram-api.js";
3
+ import { resolveTelegramFileDestination, getTelegramFileRouteSaveErrors, } from "./file-routing.js";
4
+ import { formatIssueCreated, formatIssueDone, formatIssueAssigned, formatApprovalCreated, formatAgentError, formatAgentRunStarted, formatAgentRunFinished, formatIssueBlocked, formatBoardMention, formatResolvedDecision, } from "./formatters.js";
5
+ import { handleCommand, resolveNotificationThreadId, BOT_COMMANDS } from "./commands.js";
6
+ import { routeMessageToAgent, handleHandoffToolCall, handleDiscussToolCall, handleHandoffApproval, handleHandoffRejection, setupAcpOutputListener, } from "./acp-bridge.js";
7
+ import { handleMediaMessage } from "./media-pipeline.js";
8
+ import { getPersistedTelegramUpdateOffset, persistTelegramUpdateOffset, processTelegramUpdateBatch, } from "./polling-offset.js";
9
+ import { handleCommandsCommand, tryCustomCommand } from "./command-registry.js";
10
+ import { handleRegisterWatch, checkWatches } from "./watch-registry.js";
11
+ import { AGENT_ERROR_DEDUPLICATION_WINDOW_MS, METRIC_NAMES } from "./constants.js";
12
+ import { EscalationManager } from "./escalation.js";
13
+ import { isTelegramUpdateAllowed, validateTelegramAllowlists } from "./allowlist.js";
14
+ import { validateSecretRefFields } from "./secret-ref-validation.js";
15
+ import { shouldNotifyApproval } from "./approval-routing.js";
16
+ import { buildDeliveryKey, withIdempotentDelivery } from "./interaction-delivery.js";
17
+ import { shouldNotifyIssueBlocked, shouldNotifyBoardMention, parseBoardUsernames, } from "./notification-filters.js";
18
+ import { buildPaperclipAuthHeaders, fetchPaperclipApi, isAlreadyResolvedConflict, } from "./paperclip-api.js";
19
+ const TELEGRAM_API = "https://api.telegram.org";
20
+ const BOARD_ACCESS_SCOPE = {
21
+ scopeKind: "instance",
22
+ stateKey: "telegram.board-access.v1",
23
+ };
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function asNonEmptyString(value) {
28
+ return typeof value === "string" && value.trim() ? value.trim() : null;
29
+ }
30
+ function normalizeBoardAccessState(value) {
31
+ const record = isRecord(value) ? value : {};
32
+ return {
33
+ paperclipBoardApiTokenRef: asNonEmptyString(record.paperclipBoardApiTokenRef),
34
+ identity: asNonEmptyString(record.identity),
35
+ companyId: asNonEmptyString(record.companyId),
36
+ updatedAt: asNonEmptyString(record.updatedAt),
37
+ };
38
+ }
39
+ async function loadBoardAccessState(ctx) {
40
+ return normalizeBoardAccessState(await ctx.state.get(BOARD_ACCESS_SCOPE));
41
+ }
42
+ async function persistBoardAccessState(ctx, state) {
43
+ const nextState = normalizeBoardAccessState(state);
44
+ await ctx.state.set(BOARD_ACCESS_SCOPE, nextState);
45
+ return {
46
+ ...nextState,
47
+ configured: Boolean(nextState.paperclipBoardApiTokenRef),
48
+ };
49
+ }
50
+ function getBoardAccessRegistration(state) {
51
+ return {
52
+ ...state,
53
+ configured: Boolean(state.paperclipBoardApiTokenRef),
54
+ };
55
+ }
56
+ async function resolveBoardApiToken(ctx, config, companyId) {
57
+ const boardAccessState = await loadBoardAccessState(ctx);
58
+ const candidates = [];
59
+ if (boardAccessState.paperclipBoardApiTokenRef &&
60
+ (!companyId || !boardAccessState.companyId || boardAccessState.companyId === companyId)) {
61
+ candidates.push({
62
+ source: "board-access",
63
+ ref: boardAccessState.paperclipBoardApiTokenRef,
64
+ });
65
+ }
66
+ if (config.paperclipBoardApiTokenRef) {
67
+ candidates.push({
68
+ source: "config",
69
+ ref: config.paperclipBoardApiTokenRef,
70
+ });
71
+ }
72
+ const seen = new Set();
73
+ for (const candidate of candidates) {
74
+ if (seen.has(candidate.ref))
75
+ continue;
76
+ seen.add(candidate.ref);
77
+ try {
78
+ return await ctx.secrets.resolve(candidate.ref);
79
+ }
80
+ catch (err) {
81
+ ctx.logger.warn("Failed to resolve board API token secret", {
82
+ source: candidate.source,
83
+ companyId,
84
+ error: String(err),
85
+ });
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+ async function resolveCallbackCompanyId(ctx, query) {
91
+ const chatId = query.message?.chat.id ? String(query.message.chat.id) : null;
92
+ const messageId = query.message?.message_id;
93
+ if (!chatId || !messageId)
94
+ return null;
95
+ const mapping = await ctx.state.get({
96
+ scopeKind: "instance",
97
+ stateKey: `msg_${chatId}_${messageId}`,
98
+ });
99
+ return mapping?.companyId ?? null;
100
+ }
101
+ /**
102
+ * Shared 5s sliding-window dedupe for issue.updated handlers.
103
+ *
104
+ * Paperclip's core can emit duplicate `issue.updated` plugin events for a
105
+ * single PATCH (the route's logActivity plus side-effects from heartbeat
106
+ * reconciliation), so handlers must dedupe to avoid sending the same
107
+ * Telegram message twice.
108
+ */
109
+ function makeUpdateDedupe(windowMs = 5_000, maxEntries = 500) {
110
+ const seen = new Map();
111
+ return (key) => {
112
+ const now = Date.now();
113
+ const last = seen.get(key);
114
+ if (last !== undefined && now - last < windowMs)
115
+ return false;
116
+ seen.set(key, now);
117
+ if (seen.size > maxEntries) {
118
+ const cutoff = now - windowMs;
119
+ for (const [k, ts] of seen) {
120
+ if (ts < cutoff)
121
+ seen.delete(k);
122
+ }
123
+ }
124
+ return true;
125
+ };
126
+ }
127
+ function normalizeAgentErrorMessage(input) {
128
+ return String(input ?? "Unknown error")
129
+ .trim()
130
+ .replace(/\s+/g, " ")
131
+ .slice(0, 500);
132
+ }
133
+ async function resolveChat(ctx, companyId, fallback) {
134
+ const override = await ctx.state.get({
135
+ scopeKind: "company",
136
+ scopeId: companyId,
137
+ stateKey: "telegram-chat",
138
+ });
139
+ return override ?? fallback ?? null;
140
+ }
141
+ function parseTopicId(value) {
142
+ const trimmed = value?.trim();
143
+ if (!trimmed)
144
+ return undefined;
145
+ if (!/^\d+$/.test(trimmed))
146
+ return undefined;
147
+ return Number(trimmed);
148
+ }
149
+ function validateConfiguredTopicIds(config) {
150
+ const errors = [];
151
+ for (const key of ["approvalsTopicId", "errorsTopicId", "digestTopicId"]) {
152
+ const value = config[key];
153
+ if (value === undefined || value === null || value === "")
154
+ continue;
155
+ if (typeof value !== "string" || !parseTopicId(value)) {
156
+ errors.push(`${key} must be a numeric Telegram forum topic ID string.`);
157
+ }
158
+ }
159
+ return errors;
160
+ }
161
+ async function resolveDigestThreadId(ctx, token, chatId, configuredTopicId) {
162
+ const configured = parseTopicId(configuredTopicId);
163
+ if (configured)
164
+ return configured;
165
+ return await isForum(ctx, token, chatId) ? GENERAL_TOPIC_THREAD_ID : undefined;
166
+ }
167
+ async function resolveCompanyId(ctx, chatId) {
168
+ const mapping = await ctx.state.get({
169
+ scopeKind: "instance",
170
+ stateKey: `chat_${chatId}`,
171
+ });
172
+ return mapping?.companyId ?? mapping?.companyName ?? chatId;
173
+ }
174
+ // --- Agent file-send action (ant013 TEL-8 / TEL-23) ---
175
+ // Ported from ant013/paperclip-plugin-telegram: agent-callable "send to
176
+ // Telegram" action with native markdown upload plus project-key file routing.
177
+ const MAX_OUTBOUND_MARKDOWN_BYTES = 256 * 1024;
178
+ const MAX_MARKDOWN_CAPTION_BYTES = 1024;
179
+ const DEFAULT_MARKDOWN_FILENAME = "paperclip-message.md";
180
+ const SECRET_FILENAME_TOKENS = /(?:^|[^0-9A-Za-z])(?:secret|token|credential|password|private\-key)(?:$|[^0-9A-Za-z])/i;
181
+ const UNSAFE_FILENAME_CHARS = /[\\/\u0000-\u001F\u007F]/;
182
+ const FORBIDDEN_MARKDOWN_SOURCE_FIELDS = new Set([
183
+ "filePath",
184
+ "path",
185
+ "fileUrl",
186
+ "url",
187
+ "fileURL",
188
+ "fileUri",
189
+ "file_uri",
190
+ "uri",
191
+ "telegramFileId",
192
+ "telegram_file_id",
193
+ "file_id",
194
+ "file",
195
+ "files",
196
+ "binary",
197
+ "binaryContent",
198
+ "fileContent",
199
+ "content",
200
+ ]);
201
+ function validateOutboundThreadId(value) {
202
+ if (value === undefined)
203
+ return undefined;
204
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1)
205
+ return "invalid";
206
+ return value;
207
+ }
208
+ function validateMarkdownFilename(name) {
209
+ if (!name.toLowerCase().endsWith(".md")) {
210
+ return { ok: false, code: "non_markdown_file", message: "Markdown file uploads must use a .md extension." };
211
+ }
212
+ if (/^[A-Za-z]:/.test(name)) {
213
+ return { ok: false, code: "invalid_markdown_filename", message: "Markdown filename must be a safe basename." };
214
+ }
215
+ if (name.includes("/") || name.includes("\\") || name.includes("..")) {
216
+ return { ok: false, code: "invalid_markdown_filename", message: "Markdown filename must be a safe basename." };
217
+ }
218
+ if (UNSAFE_FILENAME_CHARS.test(name) || name.startsWith(".") || SECRET_FILENAME_TOKENS.test(name)) {
219
+ return { ok: false, code: "unsafe_filename", message: "Markdown filename is considered unsafe." };
220
+ }
221
+ return { ok: true };
222
+ }
223
+ function findUnsupportedMarkdownSourceField(params) {
224
+ for (const key of FORBIDDEN_MARKDOWN_SOURCE_FIELDS) {
225
+ if (Object.prototype.hasOwnProperty.call(params, key) && params[key] !== undefined) {
226
+ return key;
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+ function makeTelegramToolError(code, message, metadata = {}) {
232
+ return { ok: false, code, message, ...metadata };
233
+ }
234
+ async function resolveIssueIdentifierForFileRoute(ctx, companyId, issueId) {
235
+ try {
236
+ const issue = await ctx.issues.get(issueId, companyId);
237
+ if (!issue)
238
+ return null;
239
+ const issueCompanyId = issue.companyId;
240
+ if (typeof issueCompanyId === "string" && issueCompanyId !== companyId)
241
+ return null;
242
+ return typeof issue.identifier === "string" ? issue.identifier : null;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ }
248
+ // A route counts as enabled unless it is explicitly disabled (enabled === false).
249
+ // Legacy/hand-edited config that omits the flag is treated as enabled.
250
+ function routeEnabled(value) {
251
+ return value !== false;
252
+ }
253
+ export function resolveTelegramOpsDestination(routes, companyId, companyName) {
254
+ if (!Array.isArray(routes))
255
+ return null;
256
+ const normalizedCompanyName = asNonEmptyString(companyName)?.toLowerCase() ?? null;
257
+ for (const route of routes) {
258
+ if (!isRecord(route) || !routeEnabled(route.enabled))
259
+ continue;
260
+ const chatId = asNonEmptyString(route.chatId);
261
+ if (!chatId)
262
+ continue;
263
+ const routeCompanyId = asNonEmptyString(route.companyId);
264
+ const routeCompanyName = asNonEmptyString(route.companyName)?.toLowerCase() ?? null;
265
+ const matchesCompanyId = Boolean(routeCompanyId && routeCompanyId === companyId);
266
+ const matchesCompanyName = Boolean(routeCompanyName && normalizedCompanyName && routeCompanyName === normalizedCompanyName);
267
+ if (!matchesCompanyId && !matchesCompanyName)
268
+ continue;
269
+ return {
270
+ chatId,
271
+ topicId: asNonEmptyString(route.topicId) ?? undefined,
272
+ routeName: asNonEmptyString(route.name) ?? undefined,
273
+ };
274
+ }
275
+ return null;
276
+ }
277
+ export async function resolveOpsDestinationForEvent(ctx, config, event) {
278
+ // First try the cheap path: direct companyId match needs no extra lookup.
279
+ const direct = resolveTelegramOpsDestination(config.opsRoutes, event.companyId);
280
+ if (direct)
281
+ return direct;
282
+ // Fall back to companyName matching (routes configured by name), resolving the
283
+ // company lazily so we never pay the lookup when no name-based route exists.
284
+ if (!Array.isArray(config.opsRoutes) || config.opsRoutes.length === 0)
285
+ return null;
286
+ try {
287
+ const company = await ctx.companies.get(event.companyId);
288
+ return resolveTelegramOpsDestination(config.opsRoutes, event.companyId, company?.name ?? null);
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ }
294
+ async function logSendToTelegramAttempt(ctx, runCtx, details) {
295
+ ctx.logger.info("Telegram agent send routing decision", {
296
+ companyId: runCtx.companyId,
297
+ agentId: runCtx.agentId,
298
+ issueId: asNonEmptyString(details.params.issueId) ?? undefined,
299
+ issueIdentifier: details.issueIdentifier ?? asNonEmptyString(details.params.issueIdentifier) ?? undefined,
300
+ projectKey: details.projectKey ?? asNonEmptyString(details.params.projectKey) ?? undefined,
301
+ routeSource: details.routeSource,
302
+ routeName: details.routeName,
303
+ chatId: details.chatId,
304
+ topicId: details.threadId,
305
+ contentMode: details.mode,
306
+ errorCode: details.errorCode,
307
+ });
308
+ }
309
+ export async function sendToTelegramTool(ctx, token, config, params, runCtx) {
310
+ const p = isRecord(params) ? params : {};
311
+ if (findUnsupportedMarkdownSourceField(p)) {
312
+ const result = makeTelegramToolError("unsupported_file_source", "Only text and markdownContent are supported.");
313
+ return { content: JSON.stringify(result), data: result };
314
+ }
315
+ const text = asNonEmptyString(p.text);
316
+ const markdownContent = asNonEmptyString(p.markdownContent);
317
+ if (!text && !markdownContent) {
318
+ const result = makeTelegramToolError("missing_content", "At least one of text or markdownContent is required.");
319
+ return { content: JSON.stringify(result), data: result };
320
+ }
321
+ const explicitChatId = asNonEmptyString(p.chatId);
322
+ const threadId = validateOutboundThreadId(p.threadId);
323
+ if (threadId === "invalid") {
324
+ const result = makeTelegramToolError("invalid_thread", "threadId must be a positive integer.");
325
+ return { content: JSON.stringify(result), data: result };
326
+ }
327
+ const replyToMessageId = validateOutboundThreadId(p.replyToMessageId);
328
+ if (replyToMessageId === "invalid") {
329
+ const result = makeTelegramToolError("invalid_thread", "replyToMessageId must be a positive integer.");
330
+ return { content: JSON.stringify(result), data: result };
331
+ }
332
+ const parseMode = p.parseMode === "MarkdownV2" || p.parseMode === "HTML" ? p.parseMode : undefined;
333
+ const requestedMarkdownFileName = asNonEmptyString(p.markdownFileName) ?? DEFAULT_MARKDOWN_FILENAME;
334
+ const sessionId = asNonEmptyString(p.sessionId);
335
+ if (markdownContent && Buffer.byteLength(markdownContent, "utf-8") > MAX_OUTBOUND_MARKDOWN_BYTES) {
336
+ const result = makeTelegramToolError("markdown_too_large", "Markdown content exceeds size limits.");
337
+ return { content: JSON.stringify(result), data: result };
338
+ }
339
+ if (markdownContent && text && Buffer.byteLength(text, "utf-8") > MAX_MARKDOWN_CAPTION_BYTES) {
340
+ const result = makeTelegramToolError("caption_too_large", "Caption exceeds Telegram caption size limits.");
341
+ return { content: JSON.stringify(result), data: result };
342
+ }
343
+ if (markdownContent) {
344
+ const markdownFileValidation = validateMarkdownFilename(requestedMarkdownFileName);
345
+ if (!markdownFileValidation.ok) {
346
+ const result = makeTelegramToolError(markdownFileValidation.code, markdownFileValidation.message);
347
+ return { content: JSON.stringify(result), data: result };
348
+ }
349
+ }
350
+ const destination = markdownContent
351
+ ? await resolveTelegramFileDestination(config.fileRoutes, {
352
+ explicitChatId,
353
+ explicitThreadId: typeof threadId === "number" ? threadId : undefined,
354
+ issueId: asNonEmptyString(p.issueId),
355
+ issueIdentifier: asNonEmptyString(p.issueIdentifier),
356
+ projectKey: asNonEmptyString(p.projectKey),
357
+ lookupIssueIdentifier: (issueId) => resolveIssueIdentifierForFileRoute(ctx, runCtx.companyId, issueId),
358
+ })
359
+ : null;
360
+ if (destination && !destination.ok) {
361
+ const result = makeTelegramToolError(destination.code, destination.message, {
362
+ projectKey: destination.projectKey,
363
+ issueIdentifier: destination.issueIdentifier,
364
+ });
365
+ await logSendToTelegramAttempt(ctx, runCtx, {
366
+ params: p,
367
+ mode: markdownContent ? "document" : "message",
368
+ routeSource: "file_route",
369
+ projectKey: destination.projectKey,
370
+ issueIdentifier: destination.issueIdentifier,
371
+ errorCode: destination.code,
372
+ });
373
+ return { content: JSON.stringify(result), data: result };
374
+ }
375
+ const routeSource = destination?.ok ? destination.source : explicitChatId ? "explicit" : "legacy_fallback";
376
+ const chatId = destination?.ok && destination.source === "file_route"
377
+ ? destination.chatId
378
+ : explicitChatId ?? await resolveChat(ctx, runCtx.companyId, config.defaultChatId);
379
+ if (!chatId) {
380
+ const result = makeTelegramToolError("disallowed_chat", "No Telegram chat configured.");
381
+ return { content: JSON.stringify(result), data: result };
382
+ }
383
+ const allowedChatIds = Array.isArray(config.allowedTelegramChatIds)
384
+ ? config.allowedTelegramChatIds.map(String).filter(Boolean)
385
+ : [];
386
+ if (explicitChatId) {
387
+ if (allowedChatIds.length === 0) {
388
+ const result = makeTelegramToolError("disallowed_chat", "Explicit Telegram chat IDs are not allowed.");
389
+ return { content: JSON.stringify(result), data: result };
390
+ }
391
+ if (!allowedChatIds.includes(explicitChatId)) {
392
+ const result = makeTelegramToolError("disallowed_chat", "Telegram chat is not allowed for agent outbound delivery.");
393
+ return { content: JSON.stringify(result), data: result };
394
+ }
395
+ }
396
+ const outboundThreadId = destination?.ok && destination.source === "file_route"
397
+ ? destination.topicId
398
+ : threadId;
399
+ const result = markdownContent
400
+ ? await sendDocument(ctx, token, chatId, markdownContent, {
401
+ filename: requestedMarkdownFileName,
402
+ caption: text ?? undefined,
403
+ parseMode,
404
+ messageThreadId: outboundThreadId,
405
+ replyToMessageId: typeof replyToMessageId === "number" ? replyToMessageId : undefined,
406
+ disableNotification: p.silent === true,
407
+ }).then((messageId) => {
408
+ if (!messageId) {
409
+ return makeTelegramToolError("telegram_send_failed", "Telegram send failed.");
410
+ }
411
+ return {
412
+ ok: true,
413
+ mode: "document",
414
+ chatId,
415
+ threadId: outboundThreadId,
416
+ messageId,
417
+ routeSource,
418
+ routeName: destination?.ok ? destination.routeName : undefined,
419
+ projectKey: destination?.ok ? destination.projectKey : undefined,
420
+ issueIdentifier: destination?.ok ? destination.issueIdentifier : undefined,
421
+ };
422
+ })
423
+ : await sendMessage(ctx, token, chatId, text, {
424
+ parseMode,
425
+ messageThreadId: outboundThreadId,
426
+ replyToMessageId: typeof replyToMessageId === "number" ? replyToMessageId : undefined,
427
+ disableNotification: p.silent === true,
428
+ }).then((messageId) => {
429
+ if (!messageId) {
430
+ return makeTelegramToolError("telegram_send_failed", "Telegram send failed.");
431
+ }
432
+ return {
433
+ ok: true,
434
+ mode: "message",
435
+ chatId,
436
+ threadId: outboundThreadId,
437
+ messageId,
438
+ routeSource,
439
+ };
440
+ });
441
+ if (!result.ok) {
442
+ await logSendToTelegramAttempt(ctx, runCtx, {
443
+ params: p,
444
+ mode: markdownContent ? "document" : "message",
445
+ chatId,
446
+ threadId: outboundThreadId,
447
+ routeSource,
448
+ routeName: destination?.ok ? destination.routeName : undefined,
449
+ projectKey: destination?.ok ? destination.projectKey : undefined,
450
+ issueIdentifier: destination?.ok ? destination.issueIdentifier : undefined,
451
+ errorCode: result.code,
452
+ });
453
+ return { content: JSON.stringify(result), data: result };
454
+ }
455
+ if (sessionId) {
456
+ await ctx.state.set({ scopeKind: "instance", stateKey: `agent_msg_${chatId}_${result.messageId}` }, { sessionId });
457
+ }
458
+ await ctx.activity.log({
459
+ companyId: runCtx.companyId,
460
+ message: "Agent sent content to Telegram",
461
+ entityType: "agent",
462
+ entityId: runCtx.agentId,
463
+ metadata: {
464
+ chatId,
465
+ threadId: outboundThreadId,
466
+ mode: result.mode,
467
+ messageId: result.messageId,
468
+ routeSource: result.routeSource,
469
+ routeName: destination?.ok ? destination.routeName : undefined,
470
+ projectKey: destination?.ok ? destination.projectKey : undefined,
471
+ issueId: asNonEmptyString(p.issueId) ?? undefined,
472
+ issueIdentifier: destination?.ok ? destination.issueIdentifier : asNonEmptyString(p.issueIdentifier) ?? undefined,
473
+ },
474
+ });
475
+ return { content: JSON.stringify(result), data: result };
476
+ }
477
+ const plugin = definePlugin({
478
+ async setup(ctx) {
479
+ const rawConfig = await ctx.config.get();
480
+ ctx.logger.info("Telegram plugin config loaded");
481
+ const config = rawConfig;
482
+ const baseUrl = config.paperclipBaseUrl || "http://localhost:3100";
483
+ const publicUrl = config.paperclipPublicUrl || baseUrl;
484
+ ctx.data.register("board-access.read", async () => getBoardAccessRegistration(await loadBoardAccessState(ctx)));
485
+ ctx.actions.register("board-access.update", async (params) => {
486
+ const record = isRecord(params) ? params : {};
487
+ const paperclipBoardApiTokenRef = asNonEmptyString(record.paperclipBoardApiTokenRef);
488
+ const identity = asNonEmptyString(record.identity);
489
+ const companyId = asNonEmptyString(record.companyId);
490
+ const now = new Date().toISOString();
491
+ return persistBoardAccessState(ctx, {
492
+ paperclipBoardApiTokenRef,
493
+ identity,
494
+ companyId,
495
+ updatedAt: now,
496
+ });
497
+ });
498
+ if (!config.telegramBotTokenRef) {
499
+ ctx.logger.warn("No telegramBotTokenRef configured, plugin disabled");
500
+ return;
501
+ }
502
+ const token = await ctx.secrets.resolve(config.telegramBotTokenRef);
503
+ // --- Agent file-send action (ant013 TEL-8 / TEL-23) ---
504
+ // Expose the send tool as a directly-invokable action so non-tool callers
505
+ // (smoke checks, other plugins) can route text/markdown to Telegram.
506
+ const runActionContext = (params) => ({
507
+ companyId: asNonEmptyString(params.companyId) ?? "system",
508
+ agentId: asNonEmptyString(params.agentId) ?? "system",
509
+ });
510
+ const invokeSendToTelegramAction = async (params) => {
511
+ const runCtx = runActionContext(params);
512
+ const result = await sendToTelegramTool(ctx, token, config, params, runCtx);
513
+ return { content: result.content, data: result.data };
514
+ };
515
+ ctx.actions.register("send_to_telegram", (params) => invokeSendToTelegramAction(params));
516
+ ctx.actions.register("send_file_to_telegram", (params) => invokeSendToTelegramAction(params));
517
+ // --- Register bot commands with Telegram ---
518
+ if (config.enableCommands) {
519
+ const allCommands = [
520
+ ...BOT_COMMANDS,
521
+ { command: "commands", description: "Manage custom workflow commands" },
522
+ ];
523
+ // Non-blocking init: don't hold up worker initialize on external API.
524
+ // The host's worker-init RPC timeout is 15s; if api.telegram.org is
525
+ // slow/unreachable, awaiting this call causes the worker to be SIGKILLed
526
+ // before setup() completes. Fire-and-forget matches pollUpdates() below.
527
+ setMyCommands(ctx, token, allCommands)
528
+ .then((registered) => {
529
+ if (registered) {
530
+ ctx.logger.info("Bot commands registered with Telegram");
531
+ }
532
+ })
533
+ .catch((err) => {
534
+ ctx.logger.error("Failed to register bot commands", {
535
+ error: String(err),
536
+ });
537
+ });
538
+ }
539
+ // --- Long polling for inbound messages ---
540
+ let pollingActive = true;
541
+ let lastUpdateId = await getPersistedTelegramUpdateOffset(ctx);
542
+ async function pollUpdates() {
543
+ while (pollingActive) {
544
+ try {
545
+ const res = await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/getUpdates?offset=${lastUpdateId + 1}&timeout=10&allowed_updates=["message","callback_query"]`, { method: "GET" });
546
+ const data = (await res.json());
547
+ if (data.ok && data.result) {
548
+ lastUpdateId = await processTelegramUpdateBatch({
549
+ updates: data.result,
550
+ lastUpdateId,
551
+ handleUpdate: (update) => handleUpdate(ctx, token, config, update, baseUrl, publicUrl),
552
+ persistOffset: (updateId) => persistTelegramUpdateOffset(ctx, updateId),
553
+ logger: ctx.logger,
554
+ });
555
+ }
556
+ }
557
+ catch (err) {
558
+ ctx.logger.error("Telegram polling error", { error: String(err) });
559
+ await new Promise((r) => setTimeout(r, 5000));
560
+ }
561
+ }
562
+ }
563
+ if (config.enableCommands || config.enableInbound) {
564
+ pollUpdates().catch((err) => ctx.logger.error("Polling loop crashed", { error: String(err) }));
565
+ }
566
+ ctx.events.on("plugin.stopping", async () => {
567
+ pollingActive = false;
568
+ });
569
+ // --- Phase 2: ACP output listener (cross-plugin events) ---
570
+ setupAcpOutputListener(ctx, token);
571
+ // --- Event subscriptions ---
572
+ const issuePrefixCache = new Map();
573
+ async function resolveIssueLinksOpts(companyId) {
574
+ let prefix = issuePrefixCache.get(companyId);
575
+ if (!prefix) {
576
+ const company = await ctx.companies.get(companyId);
577
+ prefix = company?.issuePrefix ?? "";
578
+ if (prefix)
579
+ issuePrefixCache.set(companyId, prefix);
580
+ }
581
+ return { baseUrl: publicUrl, issuePrefix: prefix || undefined };
582
+ }
583
+ const notify = async (event, formatter, overrideChatId, overrideTopicId, deliveryKey) => {
584
+ const chatId = await resolveChat(ctx, event.companyId, overrideChatId || config.defaultChatId);
585
+ if (!chatId)
586
+ return;
587
+ const linksOpts = await resolveIssueLinksOpts(event.companyId);
588
+ const msg = formatter(event, linksOpts);
589
+ let messageThreadId = parseTopicId(overrideTopicId);
590
+ if (!messageThreadId) {
591
+ messageThreadId = await resolveNotificationThreadId(ctx, chatId, event, config.topicRouting);
592
+ }
593
+ if (messageThreadId) {
594
+ msg.options.messageThreadId = messageThreadId;
595
+ }
596
+ // Issue threading — if we've already sent a message for this entity in this
597
+ // chat+topic, reply to that anchor so all updates about a single entity stack
598
+ // as one Telegram thread on mobile (created → comments → done).
599
+ const anchorKey = event.entityId
600
+ ? `anchor_${chatId}_${event.entityType}_${event.entityId}`
601
+ : null;
602
+ if (anchorKey) {
603
+ const anchor = (await ctx.state.get({
604
+ scopeKind: "instance",
605
+ stateKey: anchorKey,
606
+ }));
607
+ // Only thread when targeting the same topic — Telegram rejects cross-topic replies.
608
+ if (anchor?.messageId && anchor.messageThreadId === messageThreadId) {
609
+ msg.options.replyToMessageId = anchor.messageId;
610
+ }
611
+ }
612
+ // Durable idempotency guard: claim a delivery slot before sending so a
613
+ // duplicate event re-emission or a retried worker run cannot send the
614
+ // same notification twice. A failed send releases the claim so a later
615
+ // retry can re-attempt. Keyed by chat+topic+logical-event so the same
616
+ // entity notified in different chats/topics is not falsely suppressed.
617
+ const effectiveDeliveryKey = buildDeliveryKey(chatId, messageThreadId ?? "", deliveryKey ?? `${event.eventType}:${event.entityId ?? event.eventId}`);
618
+ const messageId = await withIdempotentDelivery(ctx, effectiveDeliveryKey, () => sendMessage(ctx, token, chatId, msg.text, msg.options));
619
+ if (messageId) {
620
+ await ctx.state.set({
621
+ scopeKind: "instance",
622
+ stateKey: `msg_${chatId}_${messageId}`,
623
+ }, {
624
+ entityId: event.entityId,
625
+ entityType: event.entityType,
626
+ companyId: event.companyId,
627
+ eventType: event.eventType,
628
+ });
629
+ await ctx.activity.log({
630
+ companyId: event.companyId,
631
+ message: `Forwarded ${event.eventType} to Telegram`,
632
+ entityType: "plugin",
633
+ entityId: event.entityId,
634
+ });
635
+ // First-message-per-entity: store the anchor so future notifications about the
636
+ // same entity reply to this one. Never overwritten — the first message stays root.
637
+ if (anchorKey) {
638
+ const existing = (await ctx.state.get({
639
+ scopeKind: "instance",
640
+ stateKey: anchorKey,
641
+ }));
642
+ if (!existing) {
643
+ await ctx.state.set({ scopeKind: "instance", stateKey: anchorKey }, { messageId, messageThreadId });
644
+ }
645
+ }
646
+ }
647
+ };
648
+ if (config.notifyOnIssueCreated) {
649
+ ctx.events.on("issue.created", (event) => notify(event, formatIssueCreated));
650
+ }
651
+ if (config.notifyOnIssueDone) {
652
+ const doneDedupe = makeUpdateDedupe();
653
+ ctx.events.on("issue.updated", async (event) => {
654
+ const payload = event.payload;
655
+ if (payload.status !== "done")
656
+ return;
657
+ if (!doneDedupe(`done|${event.entityId}`))
658
+ return;
659
+ // Enrich with title if missing (issue.updated events often omit it)
660
+ if (!payload.title && event.entityId) {
661
+ try {
662
+ const issue = await ctx.issues.get(event.entityId, event.companyId);
663
+ if (issue)
664
+ payload.title = issue.title;
665
+ }
666
+ catch { /* best effort */ }
667
+ }
668
+ // Enrich with latest comment (completion summary)
669
+ if (!payload.comment && event.entityId) {
670
+ try {
671
+ const comments = await ctx.issues.listComments(event.entityId, event.companyId);
672
+ if (comments.length > 0) {
673
+ const latest = comments.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
674
+ payload.comment = latest.body;
675
+ }
676
+ }
677
+ catch { /* best effort */ }
678
+ }
679
+ await notify(event, formatIssueDone, undefined, undefined, `done|${event.entityId}`);
680
+ });
681
+ }
682
+ if (config.notifyOnIssueAssigned) {
683
+ const assignmentDedupe = makeUpdateDedupe();
684
+ ctx.events.on("issue.updated", async (event) => {
685
+ const payload = event.payload;
686
+ const prev = payload._previous ?? {};
687
+ const userChanged = "assigneeUserId" in payload && payload.assigneeUserId !== prev.assigneeUserId;
688
+ const agentChanged = "assigneeAgentId" in payload && payload.assigneeAgentId !== prev.assigneeAgentId;
689
+ if (!userChanged && !agentChanged)
690
+ return;
691
+ if (config.onlyNotifyIfAssignedTo && payload.assigneeUserId !== config.onlyNotifyIfAssignedTo) {
692
+ return;
693
+ }
694
+ const dedupeKey = [
695
+ "assigned",
696
+ event.entityId,
697
+ String(prev.assigneeUserId ?? ""),
698
+ String(payload.assigneeUserId ?? ""),
699
+ String(prev.assigneeAgentId ?? ""),
700
+ String(payload.assigneeAgentId ?? ""),
701
+ ].join("|");
702
+ if (!assignmentDedupe(dedupeKey))
703
+ return;
704
+ if ((!payload.title || !payload.assigneeName) && event.entityId) {
705
+ try {
706
+ const issue = await ctx.issues.get(event.entityId, event.companyId);
707
+ if (issue) {
708
+ payload.title ??= issue.title;
709
+ const name = issue.assigneeName;
710
+ if (name)
711
+ payload.assigneeName ??= name;
712
+ }
713
+ }
714
+ catch { /* best effort */ }
715
+ }
716
+ await notify(event, formatIssueAssigned, undefined, undefined, dedupeKey);
717
+ });
718
+ }
719
+ if (config.notifyOnApprovalCreated) {
720
+ ctx.events.on("approval.created", async (event) => {
721
+ if (!shouldNotifyApproval(event, config.onlyNotifyBoardApprovals))
722
+ return;
723
+ const payload = event.payload;
724
+ // Enrich with linked issue details (event only has issueIds)
725
+ const issueIds = Array.isArray(payload.issueIds) ? payload.issueIds : [];
726
+ if (issueIds.length > 0 && !payload.linkedIssues) {
727
+ try {
728
+ const issues = await Promise.all(issueIds.slice(0, 5).map((id) => ctx.issues.get(id, event.companyId)));
729
+ payload.linkedIssues = issues
730
+ .filter(Boolean)
731
+ .map((i) => ({
732
+ identifier: i.identifier,
733
+ title: i.title,
734
+ status: i.status,
735
+ priority: i.priority,
736
+ }));
737
+ // Use first issue's title as the approval title if missing
738
+ if (!payload.title && issues[0]) {
739
+ payload.title = issues[0].identifier
740
+ ? `${issues[0].identifier}: ${issues[0].title}`
741
+ : issues[0].title;
742
+ }
743
+ }
744
+ catch { /* best effort */ }
745
+ }
746
+ // Enrich agent name
747
+ if (payload.agentId && !payload.agentName) {
748
+ try {
749
+ const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
750
+ if (agent)
751
+ payload.agentName = agent.name;
752
+ }
753
+ catch { /* best effort */ }
754
+ }
755
+ // Build a meaningful title if still missing
756
+ if (!payload.title || payload.title === "Approval Requested") {
757
+ const approvalType = String(payload.type ?? "unknown").replace(/_/g, " ");
758
+ const agentLabel = payload.agentName ? String(payload.agentName) : null;
759
+ payload.title = agentLabel
760
+ ? `${approvalType} — ${agentLabel}`
761
+ : approvalType;
762
+ }
763
+ await notify(event, formatApprovalCreated, config.approvalsChatId, config.approvalsTopicId);
764
+ });
765
+ }
766
+ if (config.notifyOnAgentError) {
767
+ const agentErrorDedupe = makeUpdateDedupe(AGENT_ERROR_DEDUPLICATION_WINDOW_MS, 1000);
768
+ ctx.events.on("agent.run.failed", async (event) => {
769
+ const payload = event.payload;
770
+ const agentId = String(payload.agentId ?? event.entityId);
771
+ if (payload.agentId && !payload.agentName) {
772
+ try {
773
+ const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
774
+ if (agent)
775
+ payload.agentName = agent.name;
776
+ }
777
+ catch { /* best effort */ }
778
+ }
779
+ if (!payload.companyName) {
780
+ try {
781
+ const company = await ctx.companies.get(event.companyId);
782
+ if (company?.name)
783
+ payload.companyName = company.name;
784
+ }
785
+ catch { /* best effort */ }
786
+ }
787
+ if (payload.issueId && (!payload.issueIdentifier || !payload.issueTitle)) {
788
+ try {
789
+ const issue = await ctx.issues.get(String(payload.issueId), event.companyId);
790
+ if (issue) {
791
+ payload.issueIdentifier ??= issue.identifier;
792
+ payload.issueTitle ??= issue.title;
793
+ }
794
+ }
795
+ catch { /* best effort */ }
796
+ }
797
+ const errorMessage = normalizeAgentErrorMessage(payload.error ?? payload.message);
798
+ const dedupeKey = ["agent.run.failed", event.companyId, agentId, errorMessage].join(":");
799
+ if (!agentErrorDedupe(dedupeKey))
800
+ return;
801
+ await notify(event, formatAgentError, config.errorsChatId, config.errorsTopicId, dedupeKey);
802
+ });
803
+ }
804
+ const enrichAgentName = async (event) => {
805
+ const payload = event.payload;
806
+ if (payload.agentId && !payload.agentName) {
807
+ try {
808
+ const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
809
+ if (agent)
810
+ payload.agentName = agent.name;
811
+ }
812
+ catch { /* best effort */ }
813
+ }
814
+ };
815
+ // Ops events route to a dedicated ops chat when an ops route matches the
816
+ // company; otherwise they fall back to the normal default-chat routing.
817
+ const notifyOps = async (event, formatter) => {
818
+ const opsDestination = await resolveOpsDestinationForEvent(ctx, config, event);
819
+ if (opsDestination) {
820
+ ctx.logger.info("Telegram ops notification routed", {
821
+ eventType: event.eventType,
822
+ companyId: event.companyId,
823
+ routeName: opsDestination.routeName,
824
+ chatId: opsDestination.chatId,
825
+ topicId: opsDestination.topicId,
826
+ });
827
+ }
828
+ await notify(event, formatter, opsDestination?.chatId, opsDestination?.topicId);
829
+ };
830
+ if (config.notifyOnAgentRunStarted) {
831
+ ctx.events.on("agent.run.started", async (event) => {
832
+ await enrichAgentName(event);
833
+ await notifyOps(event, formatAgentRunStarted);
834
+ });
835
+ }
836
+ if (config.notifyOnAgentRunFinished) {
837
+ ctx.events.on("agent.run.finished", async (event) => {
838
+ await enrichAgentName(event);
839
+ await notifyOps(event, formatAgentRunFinished);
840
+ });
841
+ }
842
+ // --- Anti-flood filters (TWB-94, tue-Jonas/paperclip-plugin-telegram @03b6e99) ---
843
+ // Forward issue.updated as a "blocked" notification only when the issue is
844
+ // genuinely blocked AND a human/board user owns it (assigneeUserId non-null).
845
+ if (config.notifyOnIssueBlocked) {
846
+ const blockedDedupe = makeUpdateDedupe();
847
+ ctx.events.on("issue.updated", async (event) => {
848
+ const payload = event.payload;
849
+ // Cheap pre-gate so we never fetch the issue for non-blocked updates.
850
+ if (payload.status !== "blocked")
851
+ return;
852
+ if (!blockedDedupe(`blocked|${event.entityId}`))
853
+ return;
854
+ // issue.updated payloads frequently omit assignee/title, so enrich from
855
+ // the issue record before deciding whether a human/board user owns it.
856
+ if (event.entityId) {
857
+ try {
858
+ const issue = await ctx.issues.get(event.entityId, event.companyId);
859
+ if (issue) {
860
+ const anyIssue = issue;
861
+ if (payload.assigneeUserId == null && anyIssue.assigneeUserId != null) {
862
+ payload.assigneeUserId = anyIssue.assigneeUserId;
863
+ }
864
+ if (!payload.assigneeName && anyIssue.assigneeName) {
865
+ payload.assigneeName = anyIssue.assigneeName;
866
+ }
867
+ if (!payload.title && issue.title)
868
+ payload.title = issue.title;
869
+ }
870
+ }
871
+ catch { /* best effort */ }
872
+ }
873
+ // Skip agent-only blocks (no human/board assignee). The rule itself lives
874
+ // in shouldNotifyIssueBlocked and now sees the enriched assigneeUserId.
875
+ if (!shouldNotifyIssueBlocked(event, true))
876
+ return;
877
+ // Enrich with latest comment (likely the blocker reason)
878
+ if (!payload.comment && event.entityId) {
879
+ try {
880
+ const comments = await ctx.issues.listComments(event.entityId, event.companyId);
881
+ if (comments.length > 0) {
882
+ const latest = comments.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
883
+ payload.comment = latest.body;
884
+ }
885
+ }
886
+ catch { /* best effort */ }
887
+ }
888
+ await notify(event, formatIssueBlocked);
889
+ });
890
+ }
891
+ // Forward issue.comment.created only when a configured board username is
892
+ // @-mentioned (word-boundary aware, case-insensitive).
893
+ const boardUsernames = parseBoardUsernames(config.boardUsernames);
894
+ if (config.notifyOnBoardMention && boardUsernames.length > 0) {
895
+ ctx.events.on("issue.comment.created", async (event) => {
896
+ if (!shouldNotifyBoardMention(event, true, boardUsernames))
897
+ return;
898
+ const payload = event.payload;
899
+ // Enrich with issue identifier/title for a useful link + heading
900
+ const issueId = payload.issueId ??
901
+ payload.issueIdentifier ??
902
+ undefined;
903
+ if (issueId && (!payload.identifier || !payload.title)) {
904
+ try {
905
+ const issue = await ctx.issues.get(String(issueId), event.companyId);
906
+ if (issue) {
907
+ payload.identifier ??= issue.identifier;
908
+ payload.title ??= issue.title;
909
+ }
910
+ }
911
+ catch { /* best effort */ }
912
+ }
913
+ await notify(event, formatBoardMention);
914
+ });
915
+ }
916
+ // --- Per-company chat overrides ---
917
+ ctx.data.register("chat-mapping", async (params) => {
918
+ const companyId = String(params.companyId);
919
+ const saved = await ctx.state.get({
920
+ scopeKind: "company",
921
+ scopeId: companyId,
922
+ stateKey: "telegram-chat",
923
+ });
924
+ return { chatId: saved ?? config.defaultChatId };
925
+ });
926
+ ctx.actions.register("set-chat", async (params) => {
927
+ const companyId = String(params.companyId);
928
+ const chatId = String(params.chatId);
929
+ await ctx.state.set({ scopeKind: "company", scopeId: companyId, stateKey: "telegram-chat" }, chatId);
930
+ ctx.logger.info("Updated Telegram chat mapping", { companyId, chatId });
931
+ return { ok: true };
932
+ });
933
+ // --- Daily digest job ---
934
+ // Support legacy dailyDigestEnabled boolean
935
+ const effectiveDigestMode = config.dailyDigestEnabled === true && config.digestMode === "off"
936
+ ? "daily"
937
+ : config.digestMode ?? "off";
938
+ if (effectiveDigestMode !== "off") {
939
+ ctx.jobs.register("telegram-daily-digest", async () => {
940
+ // Check if current UTC hour matches a configured digest time
941
+ const nowHour = new Date().getUTCHours();
942
+ const nowMin = new Date().getUTCMinutes();
943
+ if (nowMin >= 5)
944
+ return; // only fire within first 5 min of the hour
945
+ const parseHour = (t) => {
946
+ const [h] = (t || "").split(":");
947
+ return parseInt(h ?? "", 10);
948
+ };
949
+ const firstHour = parseHour(config.dailyDigestTime);
950
+ const secondHour = parseHour(config.bidailySecondTime);
951
+ const tridailyHours = (config.tridailyTimes || "07:00,13:00,19:00")
952
+ .split(",")
953
+ .map((t) => parseHour(t.trim()));
954
+ let shouldSend = false;
955
+ if (effectiveDigestMode === "daily") {
956
+ shouldSend = nowHour === firstHour;
957
+ }
958
+ else if (effectiveDigestMode === "bidaily") {
959
+ shouldSend = nowHour === firstHour || nowHour === secondHour;
960
+ }
961
+ else if (effectiveDigestMode === "tridaily") {
962
+ shouldSend = tridailyHours.includes(nowHour);
963
+ }
964
+ if (!shouldSend)
965
+ return;
966
+ const companies = await ctx.companies.list();
967
+ for (const company of companies) {
968
+ const chatId = await resolveChat(ctx, company.id, config.digestChatId || config.defaultChatId);
969
+ if (!chatId)
970
+ continue;
971
+ try {
972
+ const agents = await ctx.agents.list({ companyId: company.id });
973
+ const activeAgents = agents.filter((a) => a.status === "active");
974
+ const issues = await ctx.issues.list({ companyId: company.id, limit: 50 });
975
+ const now = Date.now();
976
+ const oneDayMs = 24 * 60 * 60 * 1000;
977
+ const completedToday = issues.filter((i) => i.status === "done" && i.completedAt && (now - new Date(i.completedAt).getTime()) < oneDayMs);
978
+ const createdToday = issues.filter((i) => (now - new Date(i.createdAt).getTime()) < oneDayMs);
979
+ const issuePrefix = company.issuePrefix;
980
+ const inProgress = issues.filter((i) => i.status === "in_progress");
981
+ const inReview = issues.filter((i) => i.status === "in_review");
982
+ const blocked = issues.filter((i) => i.status === "blocked");
983
+ const dateStr = new Date().toISOString().split("T")[0];
984
+ const companyLabel = company.name ? ` \\- ${escapeMarkdownV2(company.name)}` : "";
985
+ const digestLabel = effectiveDigestMode === "bidaily" ? "Digest" : "Daily Digest";
986
+ const lines = [
987
+ escapeMarkdownV2("\ud83d\udcca") + ` *${escapeMarkdownV2(digestLabel)}${companyLabel} \\- ${escapeMarkdownV2(dateStr)}*`,
988
+ "",
989
+ `${escapeMarkdownV2("\u2705")} Tasks completed: *${completedToday.length}*`,
990
+ `${escapeMarkdownV2("\ud83d\udccb")} Tasks created: *${createdToday.length}*`,
991
+ `${escapeMarkdownV2("\ud83e\udd16")} Active agents: *${activeAgents.length}*/${escapeMarkdownV2(String(agents.length))}`,
992
+ ];
993
+ if (activeAgents.length > 0) {
994
+ const topAgent = activeAgents[0].name;
995
+ lines.push(`${escapeMarkdownV2("\u2b50")} Top performer: *${escapeMarkdownV2(topAgent)}*`);
996
+ }
997
+ const formatIssueItem = (i) => {
998
+ const id = i.identifier ?? i.id;
999
+ const idText = issuePrefix
1000
+ ? `[${escapeMarkdownV2(id)}](${publicUrl}/${issuePrefix}/issues/${id})`
1001
+ : escapeMarkdownV2(id);
1002
+ return ` ${idText} \\- ${escapeMarkdownV2(i.title)}`;
1003
+ };
1004
+ if (inProgress.length > 0) {
1005
+ lines.push("", `${escapeMarkdownV2("\ud83d\udd04")} *In Progress \\(${inProgress.length}\\)*`);
1006
+ for (const i of inProgress.slice(0, 10))
1007
+ lines.push(formatIssueItem(i));
1008
+ }
1009
+ if (inReview.length > 0) {
1010
+ lines.push("", `${escapeMarkdownV2("\ud83d\udd0d")} *In Review \\(${inReview.length}\\)*`);
1011
+ for (const i of inReview.slice(0, 10))
1012
+ lines.push(formatIssueItem(i));
1013
+ }
1014
+ if (blocked.length > 0) {
1015
+ lines.push("", `${escapeMarkdownV2("\ud83d\udeab")} *Blocked \\(${blocked.length}\\)*`);
1016
+ for (const i of blocked.slice(0, 10))
1017
+ lines.push(formatIssueItem(i));
1018
+ }
1019
+ const digestThreadId = await resolveDigestThreadId(ctx, token, chatId, config.digestTopicId);
1020
+ await sendMessage(ctx, token, chatId, lines.join("\n"), {
1021
+ parseMode: "MarkdownV2",
1022
+ messageThreadId: digestThreadId,
1023
+ });
1024
+ }
1025
+ catch (err) {
1026
+ ctx.logger.error("Daily digest failed for company", { companyId: company.id, error: String(err) });
1027
+ const text = [
1028
+ escapeMarkdownV2("\ud83d\udcca") + " *Daily Digest*",
1029
+ "",
1030
+ escapeMarkdownV2("Could not generate digest. Check plugin logs for details."),
1031
+ ].join("\n");
1032
+ const errorThreadId = await resolveDigestThreadId(ctx, token, chatId, config.errorsTopicId || config.digestTopicId);
1033
+ await sendMessage(ctx, token, chatId, text, {
1034
+ parseMode: "MarkdownV2",
1035
+ messageThreadId: errorThreadId,
1036
+ });
1037
+ }
1038
+ }
1039
+ });
1040
+ }
1041
+ // --- Phase 1: Escalation support ---
1042
+ const escalationManager = new EscalationManager();
1043
+ // Register escalate_to_human tool - 3-arg signature with ToolRunContext
1044
+ ctx.tools.register("escalate_to_human", {
1045
+ displayName: "Escalate to Human",
1046
+ description: "Escalate a conversation to a human when you cannot handle it confidently",
1047
+ parametersSchema: {
1048
+ type: "object",
1049
+ properties: {
1050
+ reason: {
1051
+ type: "string",
1052
+ enum: ["low_confidence", "explicit_request", "policy_violation", "unknown_intent"],
1053
+ description: "Why this conversation needs human attention",
1054
+ },
1055
+ conversationSummary: {
1056
+ type: "string",
1057
+ description: "Brief summary of the conversation context and what the user needs",
1058
+ },
1059
+ suggestedActions: {
1060
+ type: "array",
1061
+ items: { type: "string" },
1062
+ description: "Suggested actions the human responder could take",
1063
+ },
1064
+ suggestedReply: {
1065
+ type: "string",
1066
+ description: "A draft reply the human can send or modify",
1067
+ },
1068
+ confidenceScore: {
1069
+ type: "number",
1070
+ minimum: 0,
1071
+ maximum: 1,
1072
+ description: "How confident the agent is (0-1). Lower values indicate greater need for human help",
1073
+ },
1074
+ originChatId: { type: "string" },
1075
+ originThreadId: { type: "string" },
1076
+ originMessageId: { type: "string" },
1077
+ sessionId: { type: "string", description: "Session ID for routing reply back" },
1078
+ transport: { type: "string", enum: ["native", "acp"], description: "Transport type for reply routing" },
1079
+ },
1080
+ required: ["reason", "conversationSummary"],
1081
+ },
1082
+ }, async (params, runCtx) => {
1083
+ const p = params;
1084
+ const escalationId = crypto.randomUUID();
1085
+ const timeoutMs = config.escalationTimeoutMs || 900000;
1086
+ const defaultAction = config.escalationDefaultAction || "defer";
1087
+ const resolvedEscalationChatId = await resolveChat(ctx, runCtx.companyId, config.escalationChatId);
1088
+ if (!resolvedEscalationChatId) {
1089
+ ctx.logger.warn("Escalation received but no escalationChatId configured");
1090
+ return { error: "No escalation channel configured" };
1091
+ }
1092
+ const escalationEvent = {
1093
+ escalationId,
1094
+ agentId: runCtx.agentId,
1095
+ companyId: runCtx.companyId,
1096
+ reason: p.reason,
1097
+ context: {
1098
+ conversationHistory: [],
1099
+ agentReasoning: String(p.conversationSummary ?? ""),
1100
+ suggestedActions: p.suggestedActions ?? [],
1101
+ suggestedReply: p.suggestedReply ? String(p.suggestedReply) : undefined,
1102
+ confidenceScore: typeof p.confidenceScore === "number" ? p.confidenceScore : undefined,
1103
+ },
1104
+ timeout: {
1105
+ durationMs: timeoutMs,
1106
+ defaultAction,
1107
+ },
1108
+ originChatId: p.originChatId ? String(p.originChatId) : undefined,
1109
+ originThreadId: p.originThreadId ? String(p.originThreadId) : undefined,
1110
+ originMessageId: p.originMessageId ? String(p.originMessageId) : undefined,
1111
+ transport: p.transport,
1112
+ sessionId: p.sessionId ? String(p.sessionId) : undefined,
1113
+ };
1114
+ await escalationManager.create(ctx, token, escalationEvent, resolvedEscalationChatId);
1115
+ // Send hold message to the originating chat if configured
1116
+ if (config.escalationHoldMessage && escalationEvent.originChatId) {
1117
+ const holdText = escapeMarkdownV2(config.escalationHoldMessage);
1118
+ await sendMessage(ctx, token, escalationEvent.originChatId, holdText, {
1119
+ parseMode: "MarkdownV2",
1120
+ messageThreadId: escalationEvent.originThreadId ? Number(escalationEvent.originThreadId) : undefined,
1121
+ replyToMessageId: escalationEvent.originMessageId ? Number(escalationEvent.originMessageId) : undefined,
1122
+ });
1123
+ }
1124
+ return { content: JSON.stringify({ status: "escalated", escalationId }) };
1125
+ });
1126
+ // --- Phase 2: Register handoff_to_agent tool ---
1127
+ ctx.tools.register("handoff_to_agent", {
1128
+ displayName: "Handoff to Agent",
1129
+ description: "Hand off work to another agent in this thread",
1130
+ parametersSchema: {
1131
+ type: "object",
1132
+ properties: {
1133
+ targetAgent: { type: "string", description: "Name of agent to hand off to" },
1134
+ reason: { type: "string", description: "Why you're handing off" },
1135
+ contextSummary: { type: "string", description: "Summary for the target agent" },
1136
+ requiresApproval: { type: "boolean", default: true, description: "Wait for human approval before target starts" },
1137
+ chatId: { type: "string", description: "Telegram chat ID" },
1138
+ threadId: { type: "number", description: "Telegram thread ID" },
1139
+ },
1140
+ required: ["targetAgent", "reason", "contextSummary"],
1141
+ },
1142
+ }, async (params, runCtx) => {
1143
+ return handleHandoffToolCall(ctx, token, params, runCtx.companyId, runCtx.agentId);
1144
+ });
1145
+ // --- Phase 2: Register discuss_with_agent tool ---
1146
+ ctx.tools.register("discuss_with_agent", {
1147
+ displayName: "Discuss with Agent",
1148
+ description: "Start a back-and-forth conversation with another agent",
1149
+ parametersSchema: {
1150
+ type: "object",
1151
+ properties: {
1152
+ targetAgent: { type: "string", description: "Name of agent to discuss with" },
1153
+ topic: { type: "string", description: "Discussion topic" },
1154
+ initialMessage: { type: "string", description: "First message to send" },
1155
+ maxTurns: { type: "number", default: 10, description: "Maximum conversation turns" },
1156
+ humanCheckpointAt: { type: "number", description: "Pause for human approval at this turn" },
1157
+ chatId: { type: "string", description: "Telegram chat ID" },
1158
+ threadId: { type: "number", description: "Telegram thread ID" },
1159
+ },
1160
+ required: ["targetAgent", "topic", "initialMessage"],
1161
+ },
1162
+ }, async (params, runCtx) => {
1163
+ return handleDiscussToolCall(ctx, token, params, runCtx.companyId, runCtx.agentId);
1164
+ });
1165
+ // --- Agent file-send tool (ant013 TEL-8 / TEL-23) ---
1166
+ const sendToTelegram = (params, runCtx) => sendToTelegramTool(ctx, token, config, params, runCtx);
1167
+ const sendToTelegramParametersSchema = {
1168
+ type: "object",
1169
+ properties: {
1170
+ chatId: {
1171
+ type: "string",
1172
+ description: "Telegram chat ID. Defaults to the configured company chat when omitted.",
1173
+ },
1174
+ threadId: {
1175
+ type: "number",
1176
+ description: "Optional Telegram forum topic ID.",
1177
+ },
1178
+ text: {
1179
+ type: "string",
1180
+ description: "Text message or Markdown caption if markdownContent is used.",
1181
+ },
1182
+ markdownContent: {
1183
+ type: "string",
1184
+ description: "Markdown document content for upload.",
1185
+ },
1186
+ markdownFileName: {
1187
+ type: "string",
1188
+ description: "Optional .md filename when markdownContent is provided.",
1189
+ },
1190
+ projectKey: {
1191
+ type: "string",
1192
+ description: "Optional Paperclip project key for Markdown document file routing, such as TEL.",
1193
+ },
1194
+ issueIdentifier: {
1195
+ type: "string",
1196
+ description: "Optional Paperclip issue key for Markdown document file routing, such as TEL-8.",
1197
+ },
1198
+ issueId: {
1199
+ type: "string",
1200
+ description: "Optional Paperclip issue ID used to resolve a project-key file route.",
1201
+ },
1202
+ parseMode: {
1203
+ type: "string",
1204
+ enum: ["MarkdownV2", "HTML"],
1205
+ description: "Optional parse mode for text/caption.",
1206
+ },
1207
+ replyToMessageId: {
1208
+ type: "number",
1209
+ description: "Optional Telegram message ID to reply to.",
1210
+ },
1211
+ silent: {
1212
+ type: "boolean",
1213
+ description: "Send without notification.",
1214
+ },
1215
+ sessionId: {
1216
+ type: "string",
1217
+ description: "Optional Paperclip session ID for routing Telegram replies back to the agent session.",
1218
+ },
1219
+ },
1220
+ anyOf: [{ required: ["text"] }, { required: ["markdownContent"] }],
1221
+ };
1222
+ ctx.tools.register("send_to_telegram", {
1223
+ displayName: "Send Telegram Message",
1224
+ description: "Send text and Markdown content to Telegram.",
1225
+ parametersSchema: sendToTelegramParametersSchema,
1226
+ }, (params, runCtx) => sendToTelegram(params, runCtx));
1227
+ // Keep the previous tool name as a compatibility alias.
1228
+ ctx.tools.register("send_file_to_telegram", {
1229
+ displayName: "Send File to Telegram",
1230
+ description: "Deprecated: send text and Markdown content to Telegram.",
1231
+ parametersSchema: sendToTelegramParametersSchema,
1232
+ }, (params, runCtx) => sendToTelegram(params, runCtx));
1233
+ // --- Phase 5: Register register_watch tool ---
1234
+ ctx.tools.register("register_watch", {
1235
+ displayName: "Register Watch",
1236
+ description: "Register a proactive watch that monitors entities and sends suggestions",
1237
+ parametersSchema: {
1238
+ type: "object",
1239
+ properties: {
1240
+ name: { type: "string", description: "Name of the watch" },
1241
+ description: { type: "string", description: "What this watch monitors" },
1242
+ entityType: { type: "string", enum: ["issue", "agent", "company", "custom"], description: "Type of entity to watch" },
1243
+ conditions: {
1244
+ type: "array",
1245
+ items: {
1246
+ type: "object",
1247
+ properties: {
1248
+ field: { type: "string" },
1249
+ operator: { type: "string", enum: ["gt", "lt", "eq", "ne", "contains", "exists"] },
1250
+ value: {},
1251
+ },
1252
+ required: ["field", "operator", "value"],
1253
+ },
1254
+ description: "Conditions that trigger the watch",
1255
+ },
1256
+ template: { type: "string", description: "Message template with {{field}} placeholders" },
1257
+ builtinTemplate: { type: "string", enum: ["invoice-overdue", "lead-stale"], description: "Use a built-in template instead" },
1258
+ chatId: { type: "string", description: "Telegram chat ID for suggestions" },
1259
+ threadId: { type: "number", description: "Telegram thread ID for suggestions" },
1260
+ },
1261
+ required: ["chatId"],
1262
+ },
1263
+ }, async (params, runCtx) => {
1264
+ return handleRegisterWatch(ctx, params, runCtx.companyId);
1265
+ });
1266
+ // --- Phase 1: Escalation timeout checker job ---
1267
+ ctx.jobs.register("check-escalation-timeouts", async () => {
1268
+ try {
1269
+ await escalationManager.checkTimeouts(ctx, token);
1270
+ }
1271
+ catch (err) {
1272
+ ctx.logger.error("Escalation timeout check failed", { error: String(err) });
1273
+ }
1274
+ });
1275
+ // --- Phase 5: Watch checker job ---
1276
+ ctx.jobs.register("check-watches", async () => {
1277
+ try {
1278
+ await checkWatches(ctx, token, {
1279
+ maxSuggestionsPerHourPerCompany: config.maxSuggestionsPerHourPerCompany ?? 10,
1280
+ watchDeduplicationWindowMs: config.watchDeduplicationWindowMs ?? 86400000,
1281
+ });
1282
+ }
1283
+ catch (err) {
1284
+ ctx.logger.error("Watch check failed", { error: String(err) });
1285
+ }
1286
+ });
1287
+ ctx.logger.info("Telegram bot plugin started (Chat OS v2 - all 5 phases)");
1288
+ },
1289
+ async onValidateConfig(config) {
1290
+ const secretRefErrors = validateSecretRefFields(config);
1291
+ if (secretRefErrors.length > 0) {
1292
+ return { ok: false, errors: secretRefErrors };
1293
+ }
1294
+ const allowlistErrors = validateTelegramAllowlists(config);
1295
+ if (allowlistErrors.length > 0) {
1296
+ return { ok: false, errors: allowlistErrors };
1297
+ }
1298
+ const topicErrors = validateConfiguredTopicIds(config);
1299
+ if (topicErrors.length > 0) {
1300
+ return { ok: false, errors: topicErrors };
1301
+ }
1302
+ const fileRouteErrors = getTelegramFileRouteSaveErrors(config.fileRoutes);
1303
+ if (fileRouteErrors.length > 0) {
1304
+ return { ok: false, errors: fileRouteErrors };
1305
+ }
1306
+ return { ok: true };
1307
+ },
1308
+ async onHealth() {
1309
+ return { status: "ok" };
1310
+ },
1311
+ });
1312
+ async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boardApiToken) {
1313
+ if (!isTelegramUpdateAllowed(config, update)) {
1314
+ const fromId = update.message?.from?.id ?? update.callback_query?.from.id;
1315
+ const chatId = update.message?.chat.id ?? update.callback_query?.message?.chat.id;
1316
+ ctx.logger.warn("Blocked unauthorized Telegram update", {
1317
+ updateId: update.update_id,
1318
+ fromId,
1319
+ chatId,
1320
+ });
1321
+ return;
1322
+ }
1323
+ if (update.callback_query) {
1324
+ const companyId = await resolveCallbackCompanyId(ctx, update.callback_query);
1325
+ const boardApiToken = await resolveBoardApiToken(ctx, config, companyId);
1326
+ await handleCallbackQuery(ctx, token, update.callback_query, baseUrl, boardApiToken);
1327
+ return;
1328
+ }
1329
+ const msg = update.message;
1330
+ if (!msg)
1331
+ return;
1332
+ const chatId = String(msg.chat.id);
1333
+ const threadId = msg.message_thread_id;
1334
+ // Phase 3: Handle media messages
1335
+ const hasMedia = !!(msg.voice || msg.audio || msg.video_note || msg.document || msg.photo);
1336
+ if (hasMedia) {
1337
+ const companyId = await resolveCompanyId(ctx, chatId);
1338
+ const handled = await handleMediaMessage(ctx, token, msg, {
1339
+ briefAgentId: config.briefAgentId ?? "",
1340
+ briefAgentChatIds: config.briefAgentChatIds ?? [],
1341
+ transcriptionApiKeyRef: config.transcriptionApiKeyRef ?? "",
1342
+ publicUrl,
1343
+ }, companyId);
1344
+ if (handled)
1345
+ return;
1346
+ }
1347
+ if (!msg.text)
1348
+ return;
1349
+ const text = msg.text;
1350
+ // Route thread messages to agent sessions
1351
+ if (threadId) {
1352
+ const isCommand = text.startsWith("/");
1353
+ if (!isCommand) {
1354
+ const companyId = await resolveCompanyId(ctx, chatId);
1355
+ const replyToId = msg.reply_to_message?.message_id;
1356
+ const routed = await routeMessageToAgent(ctx, token, chatId, threadId, text, replyToId, companyId);
1357
+ if (routed)
1358
+ return;
1359
+ }
1360
+ }
1361
+ const botCommand = msg.entities?.find((e) => e.type === "bot_command" && e.offset === 0);
1362
+ if (botCommand && config.enableCommands) {
1363
+ const fullCommand = text.slice(botCommand.offset, botCommand.offset + botCommand.length);
1364
+ const command = fullCommand.replace(/^\//, "").replace(/@.*$/, "");
1365
+ const args = text.slice(botCommand.offset + botCommand.length).trim();
1366
+ const companyId = await resolveCompanyId(ctx, chatId);
1367
+ // Phase 4: Check custom commands first
1368
+ if (command === "commands") {
1369
+ await handleCommandsCommand(ctx, token, chatId, args, threadId, companyId);
1370
+ return;
1371
+ }
1372
+ const handledCustom = await tryCustomCommand(ctx, token, chatId, command, args, threadId, companyId);
1373
+ if (handledCustom)
1374
+ return;
1375
+ // Built-in commands
1376
+ const boardApiToken = command === "approve" ? await resolveBoardApiToken(ctx, config, companyId) : undefined;
1377
+ await handleCommand(ctx, token, chatId, command, args, threadId, baseUrl, publicUrl, companyId, boardApiToken, config.maxAgentsPerThread);
1378
+ return;
1379
+ }
1380
+ if (config.enableInbound && msg.reply_to_message?.from?.is_bot) {
1381
+ const replyToId = msg.reply_to_message.message_id;
1382
+ const mapping = await ctx.state.get({
1383
+ scopeKind: "instance",
1384
+ stateKey: `msg_${chatId}_${replyToId}`,
1385
+ });
1386
+ if (mapping && mapping.entityType === "escalation") {
1387
+ const escalationManager = new EscalationManager();
1388
+ const responderId = `telegram:${msg.from?.username ?? msg.from?.id ?? chatId}`;
1389
+ await escalationManager.respond(ctx, token, mapping.entityId, {
1390
+ escalationId: mapping.entityId,
1391
+ responderId,
1392
+ responseText: text,
1393
+ action: "reply_to_customer",
1394
+ });
1395
+ await ctx.metrics.write(METRIC_NAMES.inboundRouted, 1);
1396
+ ctx.logger.info("Routed Telegram reply to escalation", {
1397
+ escalationId: mapping.entityId,
1398
+ from: msg.from?.username,
1399
+ });
1400
+ }
1401
+ else if (mapping && mapping.entityType === "issue") {
1402
+ try {
1403
+ // Use the SDK (not ctx.http.fetch) because the plugin sandbox blocks
1404
+ // outbound fetches to private IPs like 127.0.0.1 for SSRF protection.
1405
+ // The SDK's createComment goes through the plugin RPC bridge instead.
1406
+ await ctx.issues.createComment(mapping.entityId, text, mapping.companyId);
1407
+ await ctx.metrics.write(METRIC_NAMES.inboundRouted, 1);
1408
+ ctx.logger.info("Routed Telegram reply to issue comment", {
1409
+ issueId: mapping.entityId,
1410
+ from: msg.from?.username,
1411
+ });
1412
+ }
1413
+ catch (err) {
1414
+ ctx.logger.error("Failed to route inbound message", {
1415
+ issueId: mapping.entityId,
1416
+ error: String(err),
1417
+ });
1418
+ }
1419
+ }
1420
+ }
1421
+ }
1422
+ /**
1423
+ * Graceful fallback for a decision-button callback whose underlying approval is
1424
+ * no longer actionable (TWX-328: stale inline-button presses). When the failure
1425
+ * is an "already resolved/decided" conflict — the approval was decided through
1426
+ * another channel, expired, or already resolved in the opposite direction — we
1427
+ * acknowledge it quietly and update the card instead of surfacing a raw API
1428
+ * error string to the board member. Any other error still surfaces as a failure.
1429
+ */
1430
+ async function handleDecisionCallbackError(ctx, token, query, chatId, messageId, decision, err) {
1431
+ if (isAlreadyResolvedConflict(err)) {
1432
+ ctx.logger.info("Ignored stale Telegram decision callback", {
1433
+ kind: decision.kind,
1434
+ id: decision.id,
1435
+ actor: decision.actor,
1436
+ });
1437
+ await answerCallbackQuery(ctx, token, query.id, "Already resolved");
1438
+ if (chatId && messageId) {
1439
+ await editMessage(ctx, token, chatId, messageId, escapeMarkdownV2("This decision was already resolved."), { parseMode: "MarkdownV2" });
1440
+ }
1441
+ return;
1442
+ }
1443
+ await answerCallbackQuery(ctx, token, query.id, `Failed: ${String(err)}`);
1444
+ }
1445
+ export async function handleCallbackQuery(ctx, token, query, baseUrl, boardApiToken) {
1446
+ const data = query.data;
1447
+ if (!data)
1448
+ return;
1449
+ const actor = query.from.username ?? query.from.first_name ?? String(query.from.id);
1450
+ const chatId = query.message?.chat.id ? String(query.message.chat.id) : null;
1451
+ const messageId = query.message?.message_id;
1452
+ if (data.startsWith("approve_")) {
1453
+ const approvalId = data.replace("approve_", "");
1454
+ ctx.logger.info("Approval button clicked", { approvalId, actor });
1455
+ try {
1456
+ await fetchPaperclipApi(ctx, `${baseUrl}/api/approvals/${approvalId}/approve`, {
1457
+ method: "POST",
1458
+ headers: {
1459
+ "Content-Type": "application/json",
1460
+ ...buildPaperclipAuthHeaders(boardApiToken),
1461
+ },
1462
+ body: JSON.stringify({ decidedByUserId: `telegram:${actor}` }),
1463
+ });
1464
+ await answerCallbackQuery(ctx, token, query.id, "Approved");
1465
+ if (chatId && messageId) {
1466
+ await editMessage(ctx, token, chatId, messageId, formatResolvedDecision(query.message?.text, "approved", actor), { parseMode: "MarkdownV2" });
1467
+ }
1468
+ }
1469
+ catch (err) {
1470
+ await handleDecisionCallbackError(ctx, token, query, chatId, messageId, { kind: "approval_approve", id: approvalId, actor }, err);
1471
+ }
1472
+ return;
1473
+ }
1474
+ if (data.startsWith("esc_")) {
1475
+ const parts = data.split("_");
1476
+ const action = parts[1] ?? "";
1477
+ const escalationId = parts.slice(2).join("_");
1478
+ const escalationManager = new EscalationManager();
1479
+ await escalationManager.handleCallback(ctx, token, action, escalationId, actor, query.id, chatId, messageId);
1480
+ await answerCallbackQuery(ctx, token, query.id, `Escalation: ${action}`);
1481
+ return;
1482
+ }
1483
+ if (data.startsWith("reject_")) {
1484
+ const approvalId = data.replace("reject_", "");
1485
+ ctx.logger.info("Rejection button clicked", { approvalId, actor });
1486
+ try {
1487
+ await fetchPaperclipApi(ctx, `${baseUrl}/api/approvals/${approvalId}/reject`, {
1488
+ method: "POST",
1489
+ headers: {
1490
+ "Content-Type": "application/json",
1491
+ ...buildPaperclipAuthHeaders(boardApiToken),
1492
+ },
1493
+ body: JSON.stringify({ decidedByUserId: `telegram:${actor}` }),
1494
+ });
1495
+ await answerCallbackQuery(ctx, token, query.id, "Rejected");
1496
+ if (chatId && messageId) {
1497
+ await editMessage(ctx, token, chatId, messageId, formatResolvedDecision(query.message?.text, "rejected", actor), { parseMode: "MarkdownV2" });
1498
+ }
1499
+ }
1500
+ catch (err) {
1501
+ await handleDecisionCallbackError(ctx, token, query, chatId, messageId, { kind: "approval_reject", id: approvalId, actor }, err);
1502
+ }
1503
+ return;
1504
+ }
1505
+ if (data.startsWith("handoff_approve_")) {
1506
+ const handoffId = data.replace("handoff_approve_", "");
1507
+ await handleHandoffApproval(ctx, token, handoffId, actor, query.id, chatId, messageId);
1508
+ await answerCallbackQuery(ctx, token, query.id, "Handoff approved");
1509
+ return;
1510
+ }
1511
+ if (data.startsWith("handoff_reject_")) {
1512
+ const handoffId = data.replace("handoff_reject_", "");
1513
+ await handleHandoffRejection(ctx, token, handoffId, actor, query.id, chatId, messageId);
1514
+ await answerCallbackQuery(ctx, token, query.id, "Handoff rejected");
1515
+ return;
1516
+ }
1517
+ await answerCallbackQuery(ctx, token, query.id, "Unknown action");
1518
+ }
1519
+ runWorker(plugin, import.meta.url);
1520
+ //# sourceMappingURL=worker.js.map