paperclip-plugin-telegram 0.7.1 → 0.8.1

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 (51) hide show
  1. package/README.md +5 -5
  2. package/dist/acp-bridge.d.ts +9 -2
  3. package/dist/acp-bridge.js +93 -35
  4. package/dist/acp-bridge.js.map +1 -1
  5. package/dist/agent-status.d.ts +26 -0
  6. package/dist/agent-status.js +33 -0
  7. package/dist/agent-status.js.map +1 -0
  8. package/dist/coerce.d.ts +17 -0
  9. package/dist/coerce.js +35 -0
  10. package/dist/coerce.js.map +1 -0
  11. package/dist/command-definition-validation.d.ts +30 -0
  12. package/dist/command-definition-validation.js +187 -0
  13. package/dist/command-definition-validation.js.map +1 -0
  14. package/dist/command-registry.d.ts +61 -0
  15. package/dist/command-registry.js +132 -28
  16. package/dist/command-registry.js.map +1 -1
  17. package/dist/commands.d.ts +10 -1
  18. package/dist/commands.js +121 -22
  19. package/dist/commands.js.map +1 -1
  20. package/dist/constants.d.ts +2 -1
  21. package/dist/constants.js +6 -1
  22. package/dist/constants.js.map +1 -1
  23. package/dist/escalation.d.ts +1 -1
  24. package/dist/escalation.js +10 -10
  25. package/dist/escalation.js.map +1 -1
  26. package/dist/events.d.ts +23 -0
  27. package/dist/events.js +28 -0
  28. package/dist/events.js.map +1 -0
  29. package/dist/formatters.js +87 -42
  30. package/dist/formatters.js.map +1 -1
  31. package/dist/manifest.js +27 -0
  32. package/dist/manifest.js.map +1 -1
  33. package/dist/media-pipeline.js +15 -5
  34. package/dist/media-pipeline.js.map +1 -1
  35. package/dist/runtime-token.d.ts +10 -3
  36. package/dist/runtime-token.js +18 -8
  37. package/dist/runtime-token.js.map +1 -1
  38. package/dist/secret-ref-validation.d.ts +29 -1
  39. package/dist/secret-ref-validation.js +33 -1
  40. package/dist/secret-ref-validation.js.map +1 -1
  41. package/dist/telegram-api.js +9 -1
  42. package/dist/telegram-api.js.map +1 -1
  43. package/dist/ui/index.js +90 -36
  44. package/dist/ui/index.js.map +1 -1
  45. package/dist/watch-registry.d.ts +1 -1
  46. package/dist/watch-registry.js +18 -16
  47. package/dist/watch-registry.js.map +1 -1
  48. package/dist/worker.d.ts +2 -1
  49. package/dist/worker.js +747 -435
  50. package/dist/worker.js.map +1 -1
  51. package/package.json +9 -5
package/dist/worker.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { definePlugin, runWorker, } from "@paperclipai/plugin-sdk";
2
3
  import { sendMessage, editMessage, answerCallbackQuery, setMyCommands, escapeMarkdownV2, isForum, GENERAL_TOPIC_THREAD_ID, } from "./telegram-api.js";
3
4
  import { formatIssueCreated, formatIssueDone, formatIssueAssigned, formatApprovalCreated, formatAgentError, formatAgentRunStarted, formatAgentRunFinished, } from "./formatters.js";
@@ -5,21 +6,75 @@ import { handleCommand, resolveNotificationThreadId, BOT_COMMANDS } from "./comm
5
6
  import { routeMessageToAgent, handleHandoffToolCall, handleDiscussToolCall, handleHandoffApproval, handleHandoffRejection, setupAcpOutputListener, } from "./acp-bridge.js";
6
7
  import { handleMediaMessage } from "./media-pipeline.js";
7
8
  import { getPersistedTelegramUpdateOffset, persistTelegramUpdateOffset, processTelegramUpdateBatch, } from "./polling-offset.js";
8
- import { handleCommandsCommand, tryCustomCommand } from "./command-registry.js";
9
+ import { handleCommandsCommand, tryCustomCommand, isWorkflowApprovalCallback, resolveWorkflowApprovalCallback, } from "./command-registry.js";
9
10
  import { handleRegisterWatch, checkWatches } from "./watch-registry.js";
10
- import { AGENT_ERROR_DEDUPLICATION_WINDOW_MS, METRIC_NAMES } from "./constants.js";
11
+ import { AGENT_ERROR_DEDUPLICATION_WINDOW_MS, DEFAULT_CONFIG, METRIC_NAMES } from "./constants.js";
11
12
  import { EscalationManager } from "./escalation.js";
12
13
  import { isTelegramUpdateAllowed, validateTelegramAllowlists } from "./allowlist.js";
13
- import { validateSecretRefFields } from "./secret-ref-validation.js";
14
+ import { normalizeSecretRef, validateSecretRefFields } from "./secret-ref-validation.js";
14
15
  import { shouldNotifyApproval } from "./approval-routing.js";
16
+ import { isWorking } from "./agent-status.js";
15
17
  import { buildPaperclipAuthHeaders, fetchPaperclipApi } from "./paperclip-api.js";
16
- import { resolveStartupTelegramBotToken } from "./runtime-token.js";
18
+ import { str } from "./coerce.js";
19
+ import { resolveTelegramBotToken } from "./runtime-token.js";
20
+ // Module initialization runs before the SDK enters any host invocation.
21
+ const runOutsideInvocation = AsyncLocalStorage.snapshot();
22
+ const deliveredCompanyIds = new Set();
17
23
  const TELEGRAM_API = "https://api.telegram.org";
18
24
  const BOARD_ACCESS_SCOPE = {
19
25
  scopeKind: "instance",
20
26
  stateKey: "telegram.board-access.v1",
21
27
  };
22
- let runtimeHealth = { status: "ok" };
28
+ // ---------------------------------------------------------------------------
29
+ // Runtime state
30
+ // ---------------------------------------------------------------------------
31
+ //
32
+ // setup() runs OUTSIDE any company scope. Since paperclipai/paperclip#9557 the
33
+ // SDK's governed-access gate requires a company scope for `config.get()` and
34
+ // `secrets.resolve()`, so a worker cannot read its own configuration while it
35
+ // starts: an unscoped call in setup() throws "company context is required"
36
+ // and kills activation (paperclip-plugin-telegram#77).
37
+ //
38
+ // setup() therefore only registers handlers, unconditionally. Everything that
39
+ // needs config or a secret lives in `runtime`, and `onConfigChanged` is the
40
+ // ONLY thing that builds or refreshes it — the host delivers stored
41
+ // configuration with a company scope at worker start and on every save. A
42
+ // handler that fires before any delivery has happened has nothing to serve
43
+ // and no-ops via `ensureRuntime()`.
44
+ //
45
+ // The installed SDK (2026.722.0) calls `onConfigChanged(newConfig)` with no
46
+ // company scope attached, even though the host binds the RPC invocation to
47
+ // the real company and denies a read for any other one. `identifyDeliveredCompany`
48
+ // probes for the scope from inside the invocation instead of guessing.
49
+ //
50
+ // This plugin's bot token and defaultChatId are a single shared instance
51
+ // config — exactly one company can own the *storage* that config lives in on
52
+ // a governed host, mirrored here as `ownerCompanyId`. That is unrelated to
53
+ // which companies the plugin *serves*: `/connect` already lets many companies
54
+ // route their own chat to their own company via per-company chat-mapping
55
+ // state, and `notify()` below dispatches events from any companyId. Ownership
56
+ // only decides whose configuration delivery built `runtime`; refusing a
57
+ // second company's *events* would break that existing multi-company routing.
58
+ let _pluginCtx = null;
59
+ let runtime = null;
60
+ let runtimeHealth = {
61
+ status: "degraded",
62
+ message: "Waiting for company-scoped configuration from the host",
63
+ };
64
+ let ownerCompanyId = null;
65
+ let ownerConfigJson = null;
66
+ const refusedCompanies = new Set();
67
+ let bootstrapQueue = Promise.resolve();
68
+ let pollingActive = false;
69
+ // Process-lifetime singletons. None of these depend on delivered config, so
70
+ // hoisting them out of setup() (where they used to be created once, the same
71
+ // way, since setup() only ever ran once) changes nothing about their
72
+ // behavior.
73
+ const escalationManager = new EscalationManager();
74
+ const issuePrefixCache = new Map();
75
+ const doneDedupe = makeUpdateDedupe();
76
+ const assignmentDedupe = makeUpdateDedupe();
77
+ const agentErrorDedupe = makeUpdateDedupe(AGENT_ERROR_DEDUPLICATION_WINDOW_MS, 1000);
23
78
  function isRecord(value) {
24
79
  return typeof value === "object" && value !== null && !Array.isArray(value);
25
80
  }
@@ -74,7 +129,13 @@ async function resolveBoardApiToken(ctx, config, companyId) {
74
129
  continue;
75
130
  seen.add(candidate.ref);
76
131
  try {
77
- return await ctx.secrets.resolve(candidate.ref);
132
+ const normalizedRef = normalizeSecretRef(candidate.ref);
133
+ if (!normalizedRef)
134
+ continue;
135
+ return await ctx.secrets.resolve(normalizedRef, {
136
+ companyId: companyId ?? undefined,
137
+ configPath: candidate.source === "config" ? "paperclipBoardApiTokenRef" : undefined,
138
+ });
78
139
  }
79
140
  catch (err) {
80
141
  ctx.logger.warn("Failed to resolve board API token secret", {
@@ -124,7 +185,7 @@ function makeUpdateDedupe(windowMs = 5_000, maxEntries = 500) {
124
185
  };
125
186
  }
126
187
  function normalizeAgentErrorMessage(input) {
127
- return String(input ?? "Unknown error")
188
+ return str(input, "Unknown error")
128
189
  .trim()
129
190
  .replace(/\s+/g, " ")
130
191
  .slice(0, 500);
@@ -187,13 +248,331 @@ async function resolveCompanyIdOrNull(ctx, chatId) {
187
248
  return null;
188
249
  }
189
250
  }
190
- const plugin = definePlugin({
251
+ /** Deterministic enough to compare two deliveries for the equal-config rule below. */
252
+ function stableConfigJson(config) {
253
+ const normalize = (value) => {
254
+ if (Array.isArray(value))
255
+ return value.map(normalize);
256
+ if (value && typeof value === "object") {
257
+ return Object.fromEntries(Object.entries(value)
258
+ .sort(([a], [b]) => a.localeCompare(b))
259
+ .map(([key, entry]) => [key, normalize(entry)]));
260
+ }
261
+ return value;
262
+ };
263
+ return JSON.stringify(normalize(config ?? null));
264
+ }
265
+ /**
266
+ * Decide whether a configuration delivery for `companyId` may (re)build the
267
+ * runtime.
268
+ *
269
+ * - No owner yet → this company becomes the owner.
270
+ * - Same owner → always allowed (refresh in place).
271
+ * - Different company, byte-identical config → ownership advances. This is
272
+ * what resolves duplicated-config deliveries from a host migration (the
273
+ * scenario upstream maintainer flagged for paperclip-plugin-telegram#61):
274
+ * instead of the two companies fighting over a shared runtime, the second
275
+ * identical delivery is treated as the same install moving, not a rival.
276
+ * - Different company, different config → refused; the current owner's
277
+ * runtime is left untouched, logged once per refused company.
278
+ */
279
+ function claimOwnership(ctx, companyId, config) {
280
+ const configJson = stableConfigJson(config);
281
+ if (!ownerCompanyId) {
282
+ ownerCompanyId = companyId;
283
+ ownerConfigJson = configJson;
284
+ return true;
285
+ }
286
+ if (ownerCompanyId === companyId) {
287
+ ownerConfigJson = configJson;
288
+ return true;
289
+ }
290
+ if (ownerConfigJson !== null && ownerConfigJson === configJson) {
291
+ ctx.logger.info(`Telegram plugin owner advancing from company ${ownerCompanyId} to ${companyId}: identical configuration`, { previousCompanyId: ownerCompanyId, companyId });
292
+ ownerCompanyId = companyId;
293
+ ownerConfigJson = configJson;
294
+ refusedCompanies.delete(companyId);
295
+ return true;
296
+ }
297
+ if (!refusedCompanies.has(companyId)) {
298
+ refusedCompanies.add(companyId);
299
+ ctx.logger.warn(`Telegram plugin ignoring configuration for company ${companyId}; this install's config is owned by ${ownerCompanyId}`, { ownerCompanyId, deliveredCompanyId: companyId });
300
+ }
301
+ return false;
302
+ }
303
+ /** Run one bootstrap attempt inside the ordered critical section every delivery shares. */
304
+ function queueBootstrap(work) {
305
+ const next = bootstrapQueue.then(work, work);
306
+ bootstrapQueue = next.then(() => undefined, () => undefined);
307
+ return next;
308
+ }
309
+ /**
310
+ * The current runtime, or null before any configuration has been delivered.
311
+ *
312
+ * Read-only: this never bootstraps and never reads config. Every handler
313
+ * calls this first and no-ops on null instead of trying to build a runtime
314
+ * itself — only `onConfigChanged` does that (see "Runtime state" above).
315
+ */
316
+ function ensureRuntime() {
317
+ return runtime;
318
+ }
319
+ /**
320
+ * On the installed SDK (2026.722.0), `onConfigChanged` is not told which
321
+ * company's configuration was delivered — only the host's RPC binding knows,
322
+ * and it denies a scoped read for any other company. Probing each company's
323
+ * scoped config from inside this invocation identifies the delivery: only
324
+ * the real one answers.
325
+ */
326
+ async function identifyDeliveredCompany(ctx, deliveredConfig) {
327
+ let companies;
328
+ try {
329
+ companies = await ctx.companies.list();
330
+ }
331
+ catch (err) {
332
+ ctx.logger.info("Could not list companies while attributing a configuration delivery", {
333
+ error: String(err),
334
+ });
335
+ return null;
336
+ }
337
+ const readable = [];
338
+ for (const company of companies) {
339
+ try {
340
+ const config = await ctx.config.get(company.id);
341
+ readable.push({ id: company.id, config });
342
+ }
343
+ catch {
344
+ // Not this invocation's scope — expected for every company but one.
345
+ }
346
+ }
347
+ if (readable.length === 0)
348
+ return null;
349
+ if (readable.length === 1)
350
+ return readable[0].id;
351
+ // A host that answers for several companies is not telling us which one was
352
+ // saved; match the delivered bot-token reference against the readable rows.
353
+ const deliveredRef = normalizeSecretRef(deliveredConfig?.telegramBotTokenRef);
354
+ const deliveredSecretId = deliveredRef && typeof deliveredRef === "object" ? deliveredRef.secretId : null;
355
+ if (deliveredSecretId) {
356
+ const matches = readable.filter((row) => {
357
+ const rowRef = normalizeSecretRef(row.config.telegramBotTokenRef);
358
+ return rowRef && typeof rowRef === "object" && rowRef.secretId === deliveredSecretId;
359
+ });
360
+ if (matches.length === 1)
361
+ return matches[0].id;
362
+ ctx.logger.warn("Telegram plugin refused ambiguous configuration delivery attribution", {
363
+ deliveredSecretId,
364
+ matchingCompanyIds: matches.map((row) => row.id),
365
+ });
366
+ return null;
367
+ }
368
+ ctx.logger.warn("Telegram plugin refused configuration delivery attribution without a usable bot token reference", {
369
+ readableCompanyIds: readable.map((row) => row.id),
370
+ });
371
+ return null;
372
+ }
373
+ /**
374
+ * Apply one company's stored configuration to the runtime.
375
+ *
376
+ * Callers MUST go through `queueBootstrap` — nothing serializes host→worker
377
+ * RPC calls, so two config saves arriving close together could otherwise
378
+ * both pass `claimOwnership` and race to build `runtime`.
379
+ *
380
+ * Never throws: every failure path degrades health and returns null so a
381
+ * bad or not-yet-complete delivery cannot crash worker.
382
+ */
383
+ async function bootstrapRuntime(ctx, companyId, rawConfig) {
384
+ if (!claimOwnership(ctx, companyId, rawConfig))
385
+ return runtime;
386
+ const config = {
387
+ ...DEFAULT_CONFIG,
388
+ ...(isRecord(rawConfig) ? rawConfig : {}),
389
+ };
390
+ const baseUrl = config.paperclipBaseUrl || "http://localhost:3100";
391
+ const publicUrl = config.paperclipPublicUrl || baseUrl;
392
+ if (!config.telegramBotTokenRef) {
393
+ ctx.logger.warn("No telegramBotTokenRef configured, plugin disabled", { companyId });
394
+ runtime = null;
395
+ runtimeHealth = { status: "degraded", message: "No telegramBotTokenRef configured" };
396
+ return null;
397
+ }
398
+ const token = await resolveTelegramBotToken(ctx, config.telegramBotTokenRef, (health) => { runtimeHealth = health; }, companyId);
399
+ if (!token) {
400
+ ctx.logger.warn("Telegram plugin runtime disabled because bot token could not be resolved", { companyId });
401
+ return runtime;
402
+ }
403
+ runtime = { companyId, config, token, baseUrl, publicUrl };
404
+ // --- Register bot commands with Telegram ---
405
+ if (config.enableCommands) {
406
+ const allCommands = [
407
+ ...BOT_COMMANDS,
408
+ { command: "commands", description: "Manage custom workflow commands" },
409
+ ];
410
+ // Non-blocking: this runs from onConfigChanged, which the host also
411
+ // subjects to an RPC timeout, and api.telegram.org being slow must not
412
+ // fail a config save. Idempotent on Telegram's side, so re-registering on
413
+ // every delivery (including config-only refreshes) is harmless.
414
+ setMyCommands(ctx, token, allCommands)
415
+ .then((registered) => {
416
+ if (registered) {
417
+ ctx.logger.info("Bot commands registered with Telegram", { companyId });
418
+ }
419
+ })
420
+ .catch((err) => {
421
+ ctx.logger.error("Failed to register bot commands", { error: String(err) });
422
+ });
423
+ }
424
+ if (!pollingActive) {
425
+ pollingActive = true;
426
+ // Capture at module load, not here: this callback belongs to a short-lived
427
+ // host invocation. The polling descendants must make proactive RPC calls.
428
+ runOutsideInvocation(() => pollUpdates(ctx)).catch((err) => ctx.logger.error("Polling loop crashed", { error: String(err) }));
429
+ }
430
+ ctx.logger.info("Telegram plugin runtime ready", { companyId });
431
+ return runtime;
432
+ }
433
+ /**
434
+ * Long-polls Telegram for inbound updates. Started once, the first time a
435
+ * configuration delivery resolves a usable bot token, and runs for the life
436
+ * of the worker process (stopped only by `plugin.stopping`).
437
+ *
438
+ * Reads `runtime` fresh on every iteration instead of closing over a token or
439
+ * config captured at start time: a bot-token rotation or a flag flip
440
+ * (enableCommands/enableInbound) takes effect on the next tick without
441
+ * needing to tear down and restart the loop, which long-polling has no
442
+ * persistent connection to tear down anyway.
443
+ */
444
+ async function pollUpdates(ctx) {
445
+ ctx.logger.info("Telegram polling loop starting");
446
+ let lastUpdateId = await getPersistedTelegramUpdateOffset(ctx);
447
+ while (pollingActive) {
448
+ const rt = runtime;
449
+ if (!rt || !(rt.config.enableCommands || rt.config.enableInbound)) {
450
+ await new Promise((r) => setTimeout(r, 2000));
451
+ continue;
452
+ }
453
+ try {
454
+ ctx.logger.debug("Telegram poll tick", { lastUpdateId });
455
+ const res = await ctx.http.fetch(`${TELEGRAM_API}/bot${rt.token}/getUpdates?offset=${lastUpdateId + 1}&timeout=10&allowed_updates=["message","callback_query"]`, { method: "GET" });
456
+ const data = (await res.json());
457
+ if (data.ok && data.result) {
458
+ lastUpdateId = await processTelegramUpdateBatch({
459
+ updates: data.result,
460
+ lastUpdateId,
461
+ handleUpdate: (update) => handleUpdate(ctx, rt.token, rt.config, update, rt.baseUrl, rt.publicUrl, undefined, [...deliveredCompanyIds]),
462
+ persistOffset: (updateId) => persistTelegramUpdateOffset(ctx, updateId),
463
+ logger: ctx.logger,
464
+ });
465
+ }
466
+ else {
467
+ ctx.logger.warn("Telegram getUpdates: unexpected response", {
468
+ ok: data.ok,
469
+ hasResult: !!data.result,
470
+ description: data.description,
471
+ error_code: data.error_code,
472
+ });
473
+ // ok:false (revoked token/401, 409 conflict, 429) returns immediately
474
+ // rather than honoring timeout=10, and fetch does not throw on non-2xx,
475
+ // so without this the loop spins hot — flooding logs and hammering
476
+ // Telegram. Back off 5s, mirroring the catch block below.
477
+ await new Promise((r) => setTimeout(r, 5000));
478
+ }
479
+ }
480
+ catch (err) {
481
+ ctx.logger.error("Telegram polling error", { error: String(err) });
482
+ await new Promise((r) => setTimeout(r, 5000));
483
+ }
484
+ }
485
+ ctx.logger.warn("Telegram polling loop exited", { pollingActive });
486
+ }
487
+ async function resolveIssueLinksOpts(ctx, publicUrl, companyId) {
488
+ let prefix = issuePrefixCache.get(companyId);
489
+ if (!prefix) {
490
+ const company = await ctx.companies.get(companyId);
491
+ prefix = company?.issuePrefix ?? "";
492
+ if (prefix)
493
+ issuePrefixCache.set(companyId, prefix);
494
+ }
495
+ return { baseUrl: publicUrl, issuePrefix: prefix || undefined };
496
+ }
497
+ async function notify(ctx, rt, event, formatter, overrideChatId, overrideTopicId) {
498
+ const chatId = await resolveChat(ctx, event.companyId, overrideChatId || rt.config.defaultChatId);
499
+ if (!chatId)
500
+ return;
501
+ const linksOpts = await resolveIssueLinksOpts(ctx, rt.publicUrl, event.companyId);
502
+ const msg = formatter(event, linksOpts);
503
+ let messageThreadId = parseTopicId(overrideTopicId);
504
+ if (!messageThreadId) {
505
+ messageThreadId = await resolveNotificationThreadId(ctx, chatId, event, rt.config.topicRouting);
506
+ }
507
+ if (messageThreadId) {
508
+ msg.options.messageThreadId = messageThreadId;
509
+ }
510
+ // Issue threading — if we've already sent a message for this entity in this
511
+ // chat+topic, reply to that anchor so all updates about a single entity stack
512
+ // as one Telegram thread on mobile (created → comments → done).
513
+ const anchorKey = event.entityId
514
+ ? `anchor_${chatId}_${event.entityType}_${event.entityId}`
515
+ : null;
516
+ if (anchorKey) {
517
+ const anchor = (await ctx.state.get({
518
+ scopeKind: "instance",
519
+ stateKey: anchorKey,
520
+ }));
521
+ // Only thread when targeting the same topic — Telegram rejects cross-topic replies.
522
+ if (anchor?.messageId && anchor.messageThreadId === messageThreadId) {
523
+ msg.options.replyToMessageId = anchor.messageId;
524
+ }
525
+ }
526
+ const messageId = await sendMessage(ctx, rt.token, chatId, msg.text, msg.options);
527
+ if (messageId) {
528
+ await ctx.state.set({
529
+ scopeKind: "instance",
530
+ stateKey: `msg_${chatId}_${messageId}`,
531
+ }, {
532
+ entityId: event.entityId,
533
+ entityType: event.entityType,
534
+ companyId: event.companyId,
535
+ eventType: event.eventType,
536
+ });
537
+ await ctx.activity.log({
538
+ companyId: event.companyId,
539
+ message: `Forwarded ${event.eventType} to Telegram`,
540
+ entityType: "plugin",
541
+ entityId: event.entityId,
542
+ });
543
+ // First-message-per-entity: store the anchor so future notifications about the
544
+ // same entity reply to this one. Never overwritten — the first message stays root.
545
+ if (anchorKey) {
546
+ const existing = (await ctx.state.get({
547
+ scopeKind: "instance",
548
+ stateKey: anchorKey,
549
+ }));
550
+ if (!existing) {
551
+ await ctx.state.set({ scopeKind: "instance", stateKey: anchorKey }, { messageId, messageThreadId });
552
+ }
553
+ }
554
+ }
555
+ }
556
+ const enrichAgentName = async (ctx, event) => {
557
+ const payload = event.payload;
558
+ if (payload.agentId && !payload.agentName) {
559
+ try {
560
+ const agent = await ctx.agents.get(str(payload.agentId), event.companyId);
561
+ if (agent)
562
+ payload.agentName = agent.name;
563
+ }
564
+ catch { /* best effort */ }
565
+ }
566
+ };
567
+ export const plugin = definePlugin({
191
568
  async setup(ctx) {
192
- const rawConfig = await ctx.config.get();
193
- ctx.logger.info("Telegram plugin config loaded");
194
- const config = rawConfig;
195
- const baseUrl = config.paperclipBaseUrl || "http://localhost:3100";
196
- const publicUrl = config.paperclipPublicUrl || baseUrl;
569
+ _pluginCtx = ctx;
570
+ // Handlers are registered unconditionally. The feature flags that used to
571
+ // gate these registrations live in company-scoped config, which is
572
+ // unreadable here (see "Runtime state" above), and the SDK requires every
573
+ // registration to complete synchronously within setup(). Each handler
574
+ // therefore starts by resolving the runtime via `ensureRuntime()` and
575
+ // checking its own flag against the live config, no-oping until both exist.
197
576
  ctx.data.register("board-access.read", async () => getBoardAccessRegistration(await loadBoardAccessState(ctx)));
198
577
  ctx.actions.register("board-access.update", async (params) => {
199
578
  const record = isRecord(params) ? params : {};
@@ -208,322 +587,190 @@ const plugin = definePlugin({
208
587
  updatedAt: now,
209
588
  });
210
589
  });
211
- if (!config.telegramBotTokenRef) {
212
- ctx.logger.warn("No telegramBotTokenRef configured, plugin disabled");
213
- return;
214
- }
215
- const token = await resolveStartupTelegramBotToken(ctx, config.telegramBotTokenRef, (health) => {
216
- runtimeHealth = health;
217
- });
218
- if (!token) {
219
- ctx.logger.warn("Telegram plugin runtime disabled because bot token could not be resolved");
220
- return;
221
- }
222
- const telegramToken = token;
223
- // --- Register bot commands with Telegram ---
224
- if (config.enableCommands) {
225
- const allCommands = [
226
- ...BOT_COMMANDS,
227
- { command: "commands", description: "Manage custom workflow commands" },
228
- ];
229
- // Non-blocking init: don't hold up worker initialize on external API.
230
- // The host's worker-init RPC timeout is 15s; if api.telegram.org is
231
- // slow/unreachable, awaiting this call causes the worker to be SIGKILLed
232
- // before setup() completes. Fire-and-forget matches pollUpdates() below.
233
- setMyCommands(ctx, token, allCommands)
234
- .then((registered) => {
235
- if (registered) {
236
- ctx.logger.info("Bot commands registered with Telegram");
237
- }
238
- })
239
- .catch((err) => {
240
- ctx.logger.error("Failed to register bot commands", {
241
- error: String(err),
242
- });
243
- });
244
- }
245
- // --- Long polling for inbound messages ---
246
- let pollingActive = true;
247
- let lastUpdateId = await getPersistedTelegramUpdateOffset(ctx);
248
- async function pollUpdates() {
249
- while (pollingActive) {
250
- try {
251
- const res = await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/getUpdates?offset=${lastUpdateId + 1}&timeout=10&allowed_updates=["message","callback_query"]`, { method: "GET" });
252
- const data = (await res.json());
253
- if (data.ok && data.result) {
254
- lastUpdateId = await processTelegramUpdateBatch({
255
- updates: data.result,
256
- lastUpdateId,
257
- handleUpdate: (update) => handleUpdate(ctx, telegramToken, config, update, baseUrl, publicUrl),
258
- persistOffset: (updateId) => persistTelegramUpdateOffset(ctx, updateId),
259
- logger: ctx.logger,
260
- });
261
- }
262
- }
263
- catch (err) {
264
- ctx.logger.error("Telegram polling error", { error: String(err) });
265
- await new Promise((r) => setTimeout(r, 5000));
266
- }
267
- }
268
- }
269
- if (config.enableCommands || config.enableInbound) {
270
- pollUpdates().catch((err) => ctx.logger.error("Polling loop crashed", { error: String(err) }));
271
- }
272
590
  ctx.events.on("plugin.stopping", async () => {
273
591
  pollingActive = false;
274
592
  });
275
593
  // --- Phase 2: ACP output listener (cross-plugin events) ---
276
- setupAcpOutputListener(ctx, token);
594
+ setupAcpOutputListener(ctx, () => runtime?.token ?? null);
277
595
  // --- Event subscriptions ---
278
- const issuePrefixCache = new Map();
279
- async function resolveIssueLinksOpts(companyId) {
280
- let prefix = issuePrefixCache.get(companyId);
281
- if (!prefix) {
282
- const company = await ctx.companies.get(companyId);
283
- prefix = company?.issuePrefix ?? "";
284
- if (prefix)
285
- issuePrefixCache.set(companyId, prefix);
286
- }
287
- return { baseUrl: publicUrl, issuePrefix: prefix || undefined };
288
- }
289
- const notify = async (event, formatter, overrideChatId, overrideTopicId) => {
290
- const chatId = await resolveChat(ctx, event.companyId, overrideChatId || config.defaultChatId);
291
- if (!chatId)
596
+ ctx.events.on("issue.created", async (event) => {
597
+ const rt = ensureRuntime();
598
+ if (!rt || !rt.config.notifyOnIssueCreated)
292
599
  return;
293
- const linksOpts = await resolveIssueLinksOpts(event.companyId);
294
- const msg = formatter(event, linksOpts);
295
- let messageThreadId = parseTopicId(overrideTopicId);
296
- if (!messageThreadId) {
297
- messageThreadId = await resolveNotificationThreadId(ctx, chatId, event, config.topicRouting);
298
- }
299
- if (messageThreadId) {
300
- msg.options.messageThreadId = messageThreadId;
301
- }
302
- // Issue threading — if we've already sent a message for this entity in this
303
- // chat+topic, reply to that anchor so all updates about a single entity stack
304
- // as one Telegram thread on mobile (created → comments → done).
305
- const anchorKey = event.entityId
306
- ? `anchor_${chatId}_${event.entityType}_${event.entityId}`
307
- : null;
308
- if (anchorKey) {
309
- const anchor = (await ctx.state.get({
310
- scopeKind: "instance",
311
- stateKey: anchorKey,
312
- }));
313
- // Only thread when targeting the same topic — Telegram rejects cross-topic replies.
314
- if (anchor?.messageId && anchor.messageThreadId === messageThreadId) {
315
- msg.options.replyToMessageId = anchor.messageId;
316
- }
317
- }
318
- const messageId = await sendMessage(ctx, token, chatId, msg.text, msg.options);
319
- if (messageId) {
320
- await ctx.state.set({
321
- scopeKind: "instance",
322
- stateKey: `msg_${chatId}_${messageId}`,
323
- }, {
324
- entityId: event.entityId,
325
- entityType: event.entityType,
326
- companyId: event.companyId,
327
- eventType: event.eventType,
328
- });
329
- await ctx.activity.log({
330
- companyId: event.companyId,
331
- message: `Forwarded ${event.eventType} to Telegram`,
332
- entityType: "plugin",
333
- entityId: event.entityId,
334
- });
335
- // First-message-per-entity: store the anchor so future notifications about the
336
- // same entity reply to this one. Never overwritten — the first message stays root.
337
- if (anchorKey) {
338
- const existing = (await ctx.state.get({
339
- scopeKind: "instance",
340
- stateKey: anchorKey,
341
- }));
342
- if (!existing) {
343
- await ctx.state.set({ scopeKind: "instance", stateKey: anchorKey }, { messageId, messageThreadId });
600
+ await notify(ctx, rt, event, formatIssueCreated);
601
+ });
602
+ ctx.events.on("issue.updated", async (event) => {
603
+ const rt = ensureRuntime();
604
+ if (!rt || !rt.config.notifyOnIssueDone)
605
+ return;
606
+ const payload = event.payload;
607
+ if (payload.status !== "done")
608
+ return;
609
+ if (!doneDedupe(`done|${event.entityId}`))
610
+ return;
611
+ // Enrich with title/priority if missing (issue.updated events often omit them;
612
+ // priority drives the disableNotification policy in formatIssueDone)
613
+ if ((!payload.title || !payload.priority) && event.entityId) {
614
+ try {
615
+ const issue = await ctx.issues.get(event.entityId, event.companyId);
616
+ if (issue) {
617
+ payload.title ??= issue.title;
618
+ payload.priority ??= issue.priority;
344
619
  }
345
620
  }
621
+ catch { /* best effort */ }
346
622
  }
347
- };
348
- if (config.notifyOnIssueCreated) {
349
- ctx.events.on("issue.created", (event) => notify(event, formatIssueCreated));
350
- }
351
- if (config.notifyOnIssueDone) {
352
- const doneDedupe = makeUpdateDedupe();
353
- ctx.events.on("issue.updated", async (event) => {
354
- const payload = event.payload;
355
- if (payload.status !== "done")
356
- return;
357
- if (!doneDedupe(`done|${event.entityId}`))
358
- return;
359
- // Enrich with title if missing (issue.updated events often omit it)
360
- if (!payload.title && event.entityId) {
361
- try {
362
- const issue = await ctx.issues.get(event.entityId, event.companyId);
363
- if (issue)
364
- payload.title = issue.title;
365
- }
366
- catch { /* best effort */ }
367
- }
368
- // Enrich with latest comment (completion summary)
369
- if (!payload.comment && event.entityId) {
370
- try {
371
- const comments = await ctx.issues.listComments(event.entityId, event.companyId);
372
- if (comments.length > 0) {
373
- const latest = comments.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
374
- payload.comment = latest.body;
375
- }
376
- }
377
- catch { /* best effort */ }
378
- }
379
- await notify(event, formatIssueDone);
380
- });
381
- }
382
- if (config.notifyOnIssueAssigned) {
383
- const assignmentDedupe = makeUpdateDedupe();
384
- ctx.events.on("issue.updated", async (event) => {
385
- const payload = event.payload;
386
- const prev = payload._previous ?? {};
387
- const userChanged = "assigneeUserId" in payload && payload.assigneeUserId !== prev.assigneeUserId;
388
- const agentChanged = "assigneeAgentId" in payload && payload.assigneeAgentId !== prev.assigneeAgentId;
389
- if (!userChanged && !agentChanged)
390
- return;
391
- if (config.onlyNotifyIfAssignedTo && payload.assigneeUserId !== config.onlyNotifyIfAssignedTo) {
392
- return;
393
- }
394
- const dedupeKey = [
395
- "assigned",
396
- event.entityId,
397
- String(prev.assigneeUserId ?? ""),
398
- String(payload.assigneeUserId ?? ""),
399
- String(prev.assigneeAgentId ?? ""),
400
- String(payload.assigneeAgentId ?? ""),
401
- ].join("|");
402
- if (!assignmentDedupe(dedupeKey))
403
- return;
404
- if ((!payload.title || !payload.assigneeName) && event.entityId) {
405
- try {
406
- const issue = await ctx.issues.get(event.entityId, event.companyId);
407
- if (issue) {
408
- payload.title ??= issue.title;
409
- const name = issue.assigneeName;
410
- if (name)
411
- payload.assigneeName ??= name;
412
- }
413
- }
414
- catch { /* best effort */ }
415
- }
416
- await notify(event, formatIssueAssigned);
417
- });
418
- }
419
- if (config.notifyOnApprovalCreated) {
420
- ctx.events.on("approval.created", async (event) => {
421
- if (!shouldNotifyApproval(event, config.onlyNotifyBoardApprovals))
422
- return;
423
- const payload = event.payload;
424
- // Enrich with linked issue details (event only has issueIds)
425
- const issueIds = Array.isArray(payload.issueIds) ? payload.issueIds : [];
426
- if (issueIds.length > 0 && !payload.linkedIssues) {
427
- try {
428
- const issues = await Promise.all(issueIds.slice(0, 5).map((id) => ctx.issues.get(id, event.companyId)));
429
- payload.linkedIssues = issues
430
- .filter(Boolean)
431
- .map((i) => ({
432
- identifier: i.identifier,
433
- title: i.title,
434
- status: i.status,
435
- priority: i.priority,
436
- }));
437
- // Use first issue's title as the approval title if missing
438
- if (!payload.title && issues[0]) {
439
- payload.title = issues[0].identifier
440
- ? `${issues[0].identifier}: ${issues[0].title}`
441
- : issues[0].title;
442
- }
443
- }
444
- catch { /* best effort */ }
445
- }
446
- // Enrich agent name
447
- if (payload.agentId && !payload.agentName) {
448
- try {
449
- const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
450
- if (agent)
451
- payload.agentName = agent.name;
623
+ // Enrich with latest comment (completion summary)
624
+ if (!payload.comment && event.entityId) {
625
+ try {
626
+ const comments = await ctx.issues.listComments(event.entityId, event.companyId);
627
+ if (comments.length > 0) {
628
+ const latest = comments.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
629
+ payload.comment = latest.body;
452
630
  }
453
- catch { /* best effort */ }
454
631
  }
455
- // Build a meaningful title if still missing
456
- if (!payload.title || payload.title === "Approval Requested") {
457
- const approvalType = String(payload.type ?? "unknown").replace(/_/g, " ");
458
- const agentLabel = payload.agentName ? String(payload.agentName) : null;
459
- payload.title = agentLabel
460
- ? `${approvalType} — ${agentLabel}`
461
- : approvalType;
462
- }
463
- await notify(event, formatApprovalCreated, config.approvalsChatId, config.approvalsTopicId);
464
- });
465
- }
466
- if (config.notifyOnAgentError) {
467
- const agentErrorDedupe = makeUpdateDedupe(AGENT_ERROR_DEDUPLICATION_WINDOW_MS, 1000);
468
- ctx.events.on("agent.run.failed", async (event) => {
469
- const payload = event.payload;
470
- const agentId = String(payload.agentId ?? event.entityId);
471
- if (payload.agentId && !payload.agentName) {
472
- try {
473
- const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
474
- if (agent)
475
- payload.agentName = agent.name;
632
+ catch { /* best effort */ }
633
+ }
634
+ await notify(ctx, rt, event, formatIssueDone);
635
+ });
636
+ ctx.events.on("issue.updated", async (event) => {
637
+ const rt = ensureRuntime();
638
+ if (!rt || !rt.config.notifyOnIssueAssigned)
639
+ return;
640
+ const payload = event.payload;
641
+ const prev = payload._previous ?? {};
642
+ const userChanged = "assigneeUserId" in payload && payload.assigneeUserId !== prev.assigneeUserId;
643
+ const agentChanged = "assigneeAgentId" in payload && payload.assigneeAgentId !== prev.assigneeAgentId;
644
+ if (!userChanged && !agentChanged)
645
+ return;
646
+ if (rt.config.onlyNotifyIfAssignedTo && payload.assigneeUserId !== rt.config.onlyNotifyIfAssignedTo) {
647
+ return;
648
+ }
649
+ const dedupeKey = [
650
+ "assigned",
651
+ event.entityId,
652
+ str(prev.assigneeUserId),
653
+ str(payload.assigneeUserId),
654
+ str(prev.assigneeAgentId),
655
+ str(payload.assigneeAgentId),
656
+ ].join("|");
657
+ if (!assignmentDedupe(dedupeKey))
658
+ return;
659
+ if ((!payload.title || !payload.assigneeName || !payload.priority) && event.entityId) {
660
+ try {
661
+ const issue = await ctx.issues.get(event.entityId, event.companyId);
662
+ if (issue) {
663
+ payload.title ??= issue.title;
664
+ payload.priority ??= issue.priority;
665
+ const name = issue.assigneeName;
666
+ if (name)
667
+ payload.assigneeName ??= name;
476
668
  }
477
- catch { /* best effort */ }
478
669
  }
479
- if (!payload.companyName) {
480
- try {
481
- const company = await ctx.companies.get(event.companyId);
482
- if (company?.name)
483
- payload.companyName = company.name;
670
+ catch { /* best effort */ }
671
+ }
672
+ await notify(ctx, rt, event, formatIssueAssigned);
673
+ });
674
+ ctx.events.on("approval.created", async (event) => {
675
+ const rt = ensureRuntime();
676
+ if (!rt || !rt.config.notifyOnApprovalCreated)
677
+ return;
678
+ if (!shouldNotifyApproval(event, rt.config.onlyNotifyBoardApprovals))
679
+ return;
680
+ const payload = event.payload;
681
+ // Enrich with linked issue details (event only has issueIds)
682
+ const issueIds = Array.isArray(payload.issueIds) ? payload.issueIds : [];
683
+ if (issueIds.length > 0 && !payload.linkedIssues) {
684
+ try {
685
+ const issues = await Promise.all(issueIds.slice(0, 5).map((id) => ctx.issues.get(id, event.companyId)));
686
+ payload.linkedIssues = issues
687
+ .filter(Boolean)
688
+ .map((i) => ({
689
+ identifier: i.identifier,
690
+ title: i.title,
691
+ status: i.status,
692
+ priority: i.priority,
693
+ }));
694
+ // Use first issue's title as the approval title if missing
695
+ if (!payload.title && issues[0]) {
696
+ payload.title = issues[0].identifier
697
+ ? `${issues[0].identifier}: ${issues[0].title}`
698
+ : issues[0].title;
484
699
  }
485
- catch { /* best effort */ }
486
700
  }
487
- if (payload.issueId && (!payload.issueIdentifier || !payload.issueTitle)) {
488
- try {
489
- const issue = await ctx.issues.get(String(payload.issueId), event.companyId);
490
- if (issue) {
491
- payload.issueIdentifier ??= issue.identifier;
492
- payload.issueTitle ??= issue.title;
493
- }
494
- }
495
- catch { /* best effort */ }
701
+ catch { /* best effort */ }
702
+ }
703
+ // Enrich agent name
704
+ if (payload.agentId && !payload.agentName) {
705
+ try {
706
+ const agent = await ctx.agents.get(str(payload.agentId), event.companyId);
707
+ if (agent)
708
+ payload.agentName = agent.name;
496
709
  }
497
- const errorMessage = normalizeAgentErrorMessage(payload.error ?? payload.message);
498
- const dedupeKey = ["agent.run.failed", event.companyId, agentId, errorMessage].join(":");
499
- if (!agentErrorDedupe(dedupeKey))
500
- return;
501
- await notify(event, formatAgentError, config.errorsChatId, config.errorsTopicId);
502
- });
503
- }
504
- const enrichAgentName = async (event) => {
710
+ catch { /* best effort */ }
711
+ }
712
+ // Build a meaningful title if still missing
713
+ if (!payload.title || payload.title === "Approval Requested") {
714
+ const approvalType = str(payload.type, "unknown").replace(/_/g, " ");
715
+ const agentLabel = payload.agentName ? str(payload.agentName) : null;
716
+ payload.title = agentLabel
717
+ ? `${approvalType} — ${agentLabel}`
718
+ : approvalType;
719
+ }
720
+ await notify(ctx, rt, event, formatApprovalCreated, rt.config.approvalsChatId, rt.config.approvalsTopicId);
721
+ });
722
+ ctx.events.on("agent.run.failed", async (event) => {
723
+ const rt = ensureRuntime();
724
+ if (!rt || !rt.config.notifyOnAgentError)
725
+ return;
505
726
  const payload = event.payload;
727
+ const agentId = str(payload.agentId, event.entityId);
506
728
  if (payload.agentId && !payload.agentName) {
507
729
  try {
508
- const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
730
+ const agent = await ctx.agents.get(str(payload.agentId), event.companyId);
509
731
  if (agent)
510
732
  payload.agentName = agent.name;
511
733
  }
512
734
  catch { /* best effort */ }
513
735
  }
514
- };
515
- if (config.notifyOnAgentRunStarted) {
516
- ctx.events.on("agent.run.started", async (event) => {
517
- await enrichAgentName(event);
518
- await notify(event, formatAgentRunStarted);
519
- });
520
- }
521
- if (config.notifyOnAgentRunFinished) {
522
- ctx.events.on("agent.run.finished", async (event) => {
523
- await enrichAgentName(event);
524
- await notify(event, formatAgentRunFinished);
525
- });
526
- }
736
+ if (!payload.companyName) {
737
+ try {
738
+ const company = await ctx.companies.get(event.companyId);
739
+ if (company?.name)
740
+ payload.companyName = company.name;
741
+ }
742
+ catch { /* best effort */ }
743
+ }
744
+ if (payload.issueId && (!payload.issueIdentifier || !payload.issueTitle)) {
745
+ try {
746
+ const issue = await ctx.issues.get(str(payload.issueId), event.companyId);
747
+ if (issue) {
748
+ payload.issueIdentifier ??= issue.identifier;
749
+ payload.issueTitle ??= issue.title;
750
+ }
751
+ }
752
+ catch { /* best effort */ }
753
+ }
754
+ const errorMessage = normalizeAgentErrorMessage(payload.error ?? payload.message);
755
+ const dedupeKey = ["agent.run.failed", event.companyId, agentId, errorMessage].join(":");
756
+ if (!agentErrorDedupe(dedupeKey))
757
+ return;
758
+ await notify(ctx, rt, event, formatAgentError, rt.config.errorsChatId, rt.config.errorsTopicId);
759
+ });
760
+ ctx.events.on("agent.run.started", async (event) => {
761
+ const rt = ensureRuntime();
762
+ if (!rt || !rt.config.notifyOnAgentRunStarted)
763
+ return;
764
+ await enrichAgentName(ctx, event);
765
+ await notify(ctx, rt, event, formatAgentRunStarted);
766
+ });
767
+ ctx.events.on("agent.run.finished", async (event) => {
768
+ const rt = ensureRuntime();
769
+ if (!rt || !rt.config.notifyOnAgentRunFinished)
770
+ return;
771
+ await enrichAgentName(ctx, event);
772
+ await notify(ctx, rt, event, formatAgentRunFinished);
773
+ });
527
774
  // --- Per-company chat overrides ---
528
775
  ctx.data.register("chat-mapping", async (params) => {
529
776
  const companyId = String(params.companyId);
@@ -532,7 +779,8 @@ const plugin = definePlugin({
532
779
  scopeId: companyId,
533
780
  stateKey: "telegram-chat",
534
781
  });
535
- return { chatId: saved ?? config.defaultChatId };
782
+ const rt = ensureRuntime();
783
+ return { chatId: saved ?? rt?.config.defaultChatId ?? "" };
536
784
  });
537
785
  ctx.actions.register("set-chat", async (params) => {
538
786
  const companyId = String(params.companyId);
@@ -542,115 +790,123 @@ const plugin = definePlugin({
542
790
  return { ok: true };
543
791
  });
544
792
  // --- Daily digest job ---
545
- // Support legacy dailyDigestEnabled boolean
546
- const effectiveDigestMode = config.dailyDigestEnabled === true && config.digestMode === "off"
547
- ? "daily"
548
- : config.digestMode ?? "off";
549
- if (effectiveDigestMode !== "off") {
550
- ctx.jobs.register("telegram-daily-digest", async () => {
551
- // Check if current UTC hour matches a configured digest time
552
- const nowHour = new Date().getUTCHours();
553
- const nowMin = new Date().getUTCMinutes();
554
- if (nowMin >= 5)
555
- return; // only fire within first 5 min of the hour
556
- const parseHour = (t) => {
557
- const [h] = (t || "").split(":");
558
- return parseInt(h ?? "", 10);
559
- };
560
- const firstHour = parseHour(config.dailyDigestTime);
561
- const secondHour = parseHour(config.bidailySecondTime);
562
- const tridailyHours = (config.tridailyTimes || "07:00,13:00,19:00")
563
- .split(",")
564
- .map((t) => parseHour(t.trim()));
565
- let shouldSend = false;
566
- if (effectiveDigestMode === "daily") {
567
- shouldSend = nowHour === firstHour;
568
- }
569
- else if (effectiveDigestMode === "bidaily") {
570
- shouldSend = nowHour === firstHour || nowHour === secondHour;
571
- }
572
- else if (effectiveDigestMode === "tridaily") {
573
- shouldSend = tridailyHours.includes(nowHour);
574
- }
575
- if (!shouldSend)
576
- return;
577
- const companies = await ctx.companies.list();
578
- for (const company of companies) {
579
- const chatId = await resolveChat(ctx, company.id, config.digestChatId || config.defaultChatId);
580
- if (!chatId)
581
- continue;
582
- try {
583
- const agents = await ctx.agents.list({ companyId: company.id });
584
- const activeAgents = agents.filter((a) => a.status === "active");
585
- const issues = await ctx.issues.list({ companyId: company.id, limit: 50 });
586
- const now = Date.now();
587
- const oneDayMs = 24 * 60 * 60 * 1000;
588
- const completedToday = issues.filter((i) => i.status === "done" && i.completedAt && (now - new Date(i.completedAt).getTime()) < oneDayMs);
589
- const createdToday = issues.filter((i) => (now - new Date(i.createdAt).getTime()) < oneDayMs);
590
- const issuePrefix = company.issuePrefix;
591
- const inProgress = issues.filter((i) => i.status === "in_progress");
592
- const inReview = issues.filter((i) => i.status === "in_review");
593
- const blocked = issues.filter((i) => i.status === "blocked");
594
- const dateStr = new Date().toISOString().split("T")[0];
595
- const companyLabel = company.name ? ` \\- ${escapeMarkdownV2(company.name)}` : "";
596
- const digestLabel = effectiveDigestMode === "bidaily" ? "Digest" : "Daily Digest";
597
- const lines = [
598
- escapeMarkdownV2("\ud83d\udcca") + ` *${escapeMarkdownV2(digestLabel)}${companyLabel} \\- ${escapeMarkdownV2(dateStr)}*`,
599
- "",
600
- `${escapeMarkdownV2("\u2705")} Tasks completed: *${completedToday.length}*`,
601
- `${escapeMarkdownV2("\ud83d\udccb")} Tasks created: *${createdToday.length}*`,
602
- `${escapeMarkdownV2("\ud83e\udd16")} Active agents: *${activeAgents.length}*/${escapeMarkdownV2(String(agents.length))}`,
603
- ];
604
- if (activeAgents.length > 0) {
605
- const topAgent = activeAgents[0].name;
606
- lines.push(`${escapeMarkdownV2("\u2b50")} Top performer: *${escapeMarkdownV2(topAgent)}*`);
607
- }
608
- const formatIssueItem = (i) => {
609
- const id = i.identifier ?? i.id;
610
- const idText = issuePrefix
611
- ? `[${escapeMarkdownV2(id)}](${publicUrl}/${issuePrefix}/issues/${id})`
612
- : escapeMarkdownV2(id);
613
- return ` ${idText} \\- ${escapeMarkdownV2(i.title)}`;
614
- };
615
- if (inProgress.length > 0) {
616
- lines.push("", `${escapeMarkdownV2("\ud83d\udd04")} *In Progress \\(${inProgress.length}\\)*`);
617
- for (const i of inProgress.slice(0, 10))
618
- lines.push(formatIssueItem(i));
619
- }
620
- if (inReview.length > 0) {
621
- lines.push("", `${escapeMarkdownV2("\ud83d\udd0d")} *In Review \\(${inReview.length}\\)*`);
622
- for (const i of inReview.slice(0, 10))
623
- lines.push(formatIssueItem(i));
624
- }
625
- if (blocked.length > 0) {
626
- lines.push("", `${escapeMarkdownV2("\ud83d\udeab")} *Blocked \\(${blocked.length}\\)*`);
627
- for (const i of blocked.slice(0, 10))
628
- lines.push(formatIssueItem(i));
629
- }
630
- const digestThreadId = await resolveDigestThreadId(ctx, token, chatId, config.digestTopicId);
631
- await sendMessage(ctx, token, chatId, lines.join("\n"), {
632
- parseMode: "MarkdownV2",
633
- messageThreadId: digestThreadId,
634
- });
793
+ ctx.jobs.register("telegram-daily-digest", async () => {
794
+ const rt = ensureRuntime();
795
+ if (!rt)
796
+ return;
797
+ // Support legacy dailyDigestEnabled boolean
798
+ const effectiveDigestMode = rt.config.dailyDigestEnabled === true && rt.config.digestMode === "off"
799
+ ? "daily"
800
+ : rt.config.digestMode ?? "off";
801
+ if (effectiveDigestMode === "off")
802
+ return;
803
+ // Check if current UTC hour matches a configured digest time
804
+ const nowHour = new Date().getUTCHours();
805
+ const nowMin = new Date().getUTCMinutes();
806
+ if (nowMin >= 5)
807
+ return; // only fire within first 5 min of the hour
808
+ const parseHour = (t) => {
809
+ const [h] = (t || "").split(":");
810
+ return parseInt(h ?? "", 10);
811
+ };
812
+ const firstHour = parseHour(rt.config.dailyDigestTime);
813
+ const secondHour = parseHour(rt.config.bidailySecondTime);
814
+ const tridailyHours = (rt.config.tridailyTimes || "07:00,13:00,19:00")
815
+ .split(",")
816
+ .map((t) => parseHour(t.trim()));
817
+ let shouldSend = false;
818
+ if (effectiveDigestMode === "daily") {
819
+ shouldSend = nowHour === firstHour;
820
+ }
821
+ else if (effectiveDigestMode === "bidaily") {
822
+ shouldSend = nowHour === firstHour || nowHour === secondHour;
823
+ }
824
+ else if (effectiveDigestMode === "tridaily") {
825
+ shouldSend = tridailyHours.includes(nowHour);
826
+ }
827
+ if (!shouldSend)
828
+ return;
829
+ const companies = await ctx.companies.list();
830
+ for (const company of companies) {
831
+ const chatId = await resolveChat(ctx, company.id, rt.config.digestChatId || rt.config.defaultChatId);
832
+ if (!chatId)
833
+ continue;
834
+ try {
835
+ const agents = await ctx.agents.list({ companyId: company.id });
836
+ // Same defect as /status had: `status === "active"` matched nothing on
837
+ // current hosts, so the digest always reported "0/N" active agents.
838
+ const workingAgents = agents.filter(isWorking);
839
+ const issues = await ctx.issues.list({ companyId: company.id, limit: 50 });
840
+ const now = Date.now();
841
+ const oneDayMs = 24 * 60 * 60 * 1000;
842
+ const completedToday = issues.filter((i) => i.status === "done" && i.completedAt && (now - new Date(i.completedAt).getTime()) < oneDayMs);
843
+ const createdToday = issues.filter((i) => (now - new Date(i.createdAt).getTime()) < oneDayMs);
844
+ const issuePrefix = company.issuePrefix;
845
+ const inProgress = issues.filter((i) => i.status === "in_progress");
846
+ const inReview = issues.filter((i) => i.status === "in_review");
847
+ const blocked = issues.filter((i) => i.status === "blocked");
848
+ const dateStr = new Date().toISOString().split("T")[0];
849
+ const companyLabel = company.name ? ` \\- ${escapeMarkdownV2(company.name)}` : "";
850
+ const digestLabel = effectiveDigestMode === "bidaily" ? "Digest" : "Daily Digest";
851
+ const lines = [
852
+ escapeMarkdownV2("📊") + ` *${escapeMarkdownV2(digestLabel)}${companyLabel} \\- ${escapeMarkdownV2(dateStr)}*`,
853
+ "",
854
+ `${escapeMarkdownV2("✅")} Tasks completed: *${completedToday.length}*`,
855
+ `${escapeMarkdownV2("📋")} Tasks created: *${createdToday.length}*`,
856
+ `${escapeMarkdownV2("🤖")} Active agents: *${workingAgents.length}*/${escapeMarkdownV2(String(agents.length))}`,
857
+ ];
858
+ // The list is not ranked, so this names an agent that is working, not
859
+ // the best one. The old "Top performer" label went unseen wherever the
860
+ // filter above found nothing; do not resurrect it as a claim the data
861
+ // cannot support.
862
+ if (workingAgents.length > 0) {
863
+ const workingAgent = workingAgents[0].name;
864
+ lines.push(`${escapeMarkdownV2("⭐")} Working: *${escapeMarkdownV2(workingAgent)}*`);
865
+ }
866
+ const formatIssueItem = (i) => {
867
+ const id = i.identifier ?? i.id;
868
+ const idText = issuePrefix
869
+ ? `[${escapeMarkdownV2(id)}](${rt.publicUrl}/${issuePrefix}/issues/${id})`
870
+ : escapeMarkdownV2(id);
871
+ return ` ${idText} \\- ${escapeMarkdownV2(i.title)}`;
872
+ };
873
+ if (inProgress.length > 0) {
874
+ lines.push("", `${escapeMarkdownV2("🔄")} *In Progress \\(${inProgress.length}\\)*`);
875
+ for (const i of inProgress.slice(0, 10))
876
+ lines.push(formatIssueItem(i));
877
+ }
878
+ if (inReview.length > 0) {
879
+ lines.push("", `${escapeMarkdownV2("🔍")} *In Review \\(${inReview.length}\\)*`);
880
+ for (const i of inReview.slice(0, 10))
881
+ lines.push(formatIssueItem(i));
635
882
  }
636
- catch (err) {
637
- ctx.logger.error("Daily digest failed for company", { companyId: company.id, error: String(err) });
638
- const text = [
639
- escapeMarkdownV2("\ud83d\udcca") + " *Daily Digest*",
640
- "",
641
- escapeMarkdownV2("Could not generate digest. Check plugin logs for details."),
642
- ].join("\n");
643
- const errorThreadId = await resolveDigestThreadId(ctx, token, chatId, config.errorsTopicId || config.digestTopicId);
644
- await sendMessage(ctx, token, chatId, text, {
645
- parseMode: "MarkdownV2",
646
- messageThreadId: errorThreadId,
647
- });
883
+ if (blocked.length > 0) {
884
+ lines.push("", `${escapeMarkdownV2("🚫")} *Blocked \\(${blocked.length}\\)*`);
885
+ for (const i of blocked.slice(0, 10))
886
+ lines.push(formatIssueItem(i));
648
887
  }
888
+ const digestThreadId = await resolveDigestThreadId(ctx, rt.token, chatId, rt.config.digestTopicId);
889
+ await sendMessage(ctx, rt.token, chatId, lines.join("\n"), {
890
+ parseMode: "MarkdownV2",
891
+ messageThreadId: digestThreadId,
892
+ });
649
893
  }
650
- });
651
- }
894
+ catch (err) {
895
+ ctx.logger.error("Daily digest failed for company", { companyId: company.id, error: String(err) });
896
+ const text = [
897
+ escapeMarkdownV2("📊") + " *Daily Digest*",
898
+ "",
899
+ escapeMarkdownV2("Could not generate digest. Check plugin logs for details."),
900
+ ].join("\n");
901
+ const errorThreadId = await resolveDigestThreadId(ctx, rt.token, chatId, rt.config.errorsTopicId || rt.config.digestTopicId);
902
+ await sendMessage(ctx, rt.token, chatId, text, {
903
+ parseMode: "MarkdownV2",
904
+ messageThreadId: errorThreadId,
905
+ });
906
+ }
907
+ }
908
+ });
652
909
  // --- Phase 1: Escalation support ---
653
- const escalationManager = new EscalationManager();
654
910
  // Register escalate_to_human tool - 3-arg signature with ToolRunContext
655
911
  ctx.tools.register("escalate_to_human", {
656
912
  displayName: "Escalate to Human",
@@ -691,11 +947,14 @@ const plugin = definePlugin({
691
947
  required: ["reason", "conversationSummary"],
692
948
  },
693
949
  }, async (params, runCtx) => {
950
+ const rt = ensureRuntime();
951
+ if (!rt)
952
+ return { error: "Telegram plugin is not configured yet" };
694
953
  const p = params;
695
954
  const escalationId = crypto.randomUUID();
696
- const timeoutMs = config.escalationTimeoutMs || 900000;
697
- const defaultAction = config.escalationDefaultAction || "defer";
698
- const resolvedEscalationChatId = await resolveChat(ctx, runCtx.companyId, config.escalationChatId);
955
+ const timeoutMs = rt.config.escalationTimeoutMs || 900000;
956
+ const defaultAction = rt.config.escalationDefaultAction || "defer";
957
+ const resolvedEscalationChatId = await resolveChat(ctx, runCtx.companyId, rt.config.escalationChatId);
699
958
  if (!resolvedEscalationChatId) {
700
959
  ctx.logger.warn("Escalation received but no escalationChatId configured");
701
960
  return { error: "No escalation channel configured" };
@@ -707,26 +966,26 @@ const plugin = definePlugin({
707
966
  reason: p.reason,
708
967
  context: {
709
968
  conversationHistory: [],
710
- agentReasoning: String(p.conversationSummary ?? ""),
969
+ agentReasoning: str(p.conversationSummary),
711
970
  suggestedActions: p.suggestedActions ?? [],
712
- suggestedReply: p.suggestedReply ? String(p.suggestedReply) : undefined,
971
+ suggestedReply: p.suggestedReply ? str(p.suggestedReply) : undefined,
713
972
  confidenceScore: typeof p.confidenceScore === "number" ? p.confidenceScore : undefined,
714
973
  },
715
974
  timeout: {
716
975
  durationMs: timeoutMs,
717
976
  defaultAction,
718
977
  },
719
- originChatId: p.originChatId ? String(p.originChatId) : undefined,
720
- originThreadId: p.originThreadId ? String(p.originThreadId) : undefined,
721
- originMessageId: p.originMessageId ? String(p.originMessageId) : undefined,
978
+ originChatId: p.originChatId ? str(p.originChatId) : undefined,
979
+ originThreadId: p.originThreadId ? str(p.originThreadId) : undefined,
980
+ originMessageId: p.originMessageId ? str(p.originMessageId) : undefined,
722
981
  transport: p.transport,
723
- sessionId: p.sessionId ? String(p.sessionId) : undefined,
982
+ sessionId: p.sessionId ? str(p.sessionId) : undefined,
724
983
  };
725
- await escalationManager.create(ctx, token, escalationEvent, resolvedEscalationChatId);
984
+ await escalationManager.create(ctx, rt.token, escalationEvent, resolvedEscalationChatId);
726
985
  // Send hold message to the originating chat if configured
727
- if (config.escalationHoldMessage && escalationEvent.originChatId) {
728
- const holdText = escapeMarkdownV2(config.escalationHoldMessage);
729
- await sendMessage(ctx, token, escalationEvent.originChatId, holdText, {
986
+ if (rt.config.escalationHoldMessage && escalationEvent.originChatId) {
987
+ const holdText = escapeMarkdownV2(rt.config.escalationHoldMessage);
988
+ await sendMessage(ctx, rt.token, escalationEvent.originChatId, holdText, {
730
989
  parseMode: "MarkdownV2",
731
990
  messageThreadId: escalationEvent.originThreadId ? Number(escalationEvent.originThreadId) : undefined,
732
991
  replyToMessageId: escalationEvent.originMessageId ? Number(escalationEvent.originMessageId) : undefined,
@@ -751,7 +1010,10 @@ const plugin = definePlugin({
751
1010
  required: ["targetAgent", "reason", "contextSummary"],
752
1011
  },
753
1012
  }, async (params, runCtx) => {
754
- return handleHandoffToolCall(ctx, token, params, runCtx.companyId, runCtx.agentId);
1013
+ const rt = ensureRuntime();
1014
+ if (!rt)
1015
+ return { error: "Telegram plugin is not configured yet" };
1016
+ return handleHandoffToolCall(ctx, rt.token, params, runCtx.companyId, runCtx.agentId);
755
1017
  });
756
1018
  // --- Phase 2: Register discuss_with_agent tool ---
757
1019
  ctx.tools.register("discuss_with_agent", {
@@ -771,7 +1033,10 @@ const plugin = definePlugin({
771
1033
  required: ["targetAgent", "topic", "initialMessage"],
772
1034
  },
773
1035
  }, async (params, runCtx) => {
774
- return handleDiscussToolCall(ctx, token, params, runCtx.companyId, runCtx.agentId);
1036
+ const rt = ensureRuntime();
1037
+ if (!rt)
1038
+ return { error: "Telegram plugin is not configured yet" };
1039
+ return handleDiscussToolCall(ctx, rt.token, params, runCtx.companyId, runCtx.agentId);
775
1040
  });
776
1041
  // --- Phase 5: Register register_watch tool ---
777
1042
  ctx.tools.register("register_watch", {
@@ -808,8 +1073,11 @@ const plugin = definePlugin({
808
1073
  });
809
1074
  // --- Phase 1: Escalation timeout checker job ---
810
1075
  ctx.jobs.register("check-escalation-timeouts", async () => {
1076
+ const rt = ensureRuntime();
1077
+ if (!rt)
1078
+ return;
811
1079
  try {
812
- await escalationManager.checkTimeouts(ctx, token);
1080
+ await escalationManager.checkTimeouts(ctx, rt.token);
813
1081
  }
814
1082
  catch (err) {
815
1083
  ctx.logger.error("Escalation timeout check failed", { error: String(err) });
@@ -817,17 +1085,54 @@ const plugin = definePlugin({
817
1085
  });
818
1086
  // --- Phase 5: Watch checker job ---
819
1087
  ctx.jobs.register("check-watches", async () => {
1088
+ const rt = ensureRuntime();
1089
+ if (!rt)
1090
+ return;
820
1091
  try {
821
- await checkWatches(ctx, token, {
822
- maxSuggestionsPerHourPerCompany: config.maxSuggestionsPerHourPerCompany ?? 10,
823
- watchDeduplicationWindowMs: config.watchDeduplicationWindowMs ?? 86400000,
1092
+ await checkWatches(ctx, rt.token, {
1093
+ maxSuggestionsPerHourPerCompany: rt.config.maxSuggestionsPerHourPerCompany ?? 10,
1094
+ watchDeduplicationWindowMs: rt.config.watchDeduplicationWindowMs ?? 86400000,
824
1095
  });
825
1096
  }
826
1097
  catch (err) {
827
1098
  ctx.logger.error("Watch check failed", { error: String(err) });
828
1099
  }
829
1100
  });
830
- ctx.logger.info("Telegram bot plugin started (Chat OS v2 - all 5 phases)");
1101
+ ctx.logger.info("Telegram bot plugin handlers registered; waiting for delivered configuration");
1102
+ },
1103
+ /**
1104
+ * The host delivers stored config here — at worker startup and on every
1105
+ * save. This is the ONLY place `runtime` is built or refreshed; see
1106
+ * "Runtime state" above.
1107
+ */
1108
+ async onConfigChanged(newConfig) {
1109
+ const ctx = _pluginCtx;
1110
+ if (!ctx)
1111
+ return;
1112
+ await queueBootstrap(async () => {
1113
+ const companyId = await identifyDeliveredCompany(ctx, newConfig);
1114
+ if (!companyId) {
1115
+ ctx.logger.warn("Telegram plugin could not attribute a configuration delivery to a company; leaving current runtime unchanged");
1116
+ if (!runtime) {
1117
+ runtimeHealth = {
1118
+ status: "degraded",
1119
+ message: "Configuration was delivered but no company answered a scoped configuration read.",
1120
+ };
1121
+ }
1122
+ return;
1123
+ }
1124
+ // Discovery comes only from verified host deliveries. Keep routing for
1125
+ // other companies even when they do not own the shared bot config.
1126
+ deliveredCompanyIds.add(companyId);
1127
+ try {
1128
+ await bootstrapRuntime(ctx, companyId, newConfig);
1129
+ }
1130
+ catch (err) {
1131
+ const error = String(err);
1132
+ ctx.logger.error("Telegram plugin failed to apply a configuration change", { error, companyId });
1133
+ runtimeHealth = { status: "degraded", message: `Applying the delivered configuration failed: ${error}` };
1134
+ }
1135
+ });
831
1136
  },
832
1137
  async onValidateConfig(config) {
833
1138
  const secretRefErrors = validateSecretRefFields(config);
@@ -848,7 +1153,7 @@ const plugin = definePlugin({
848
1153
  return runtimeHealth;
849
1154
  },
850
1155
  });
851
- export async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boardApiToken) {
1156
+ export async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boardApiToken, connectCompanyIds) {
852
1157
  if (!isTelegramUpdateAllowed(config, update)) {
853
1158
  const fromId = update.message?.from?.id ?? update.callback_query?.from.id;
854
1159
  const chatId = update.message?.chat.id ?? update.callback_query?.message?.chat.id;
@@ -925,7 +1230,7 @@ export async function handleUpdate(ctx, token, config, update, baseUrl, publicUr
925
1230
  return;
926
1231
  // Built-in commands
927
1232
  const boardApiToken = command === "approve" ? await resolveBoardApiToken(ctx, config, companyId) : undefined;
928
- await handleCommand(ctx, token, chatId, command, args, threadId, baseUrl, publicUrl, companyId, boardApiToken, config.maxAgentsPerThread);
1233
+ await handleCommand(ctx, token, chatId, command, args, threadId, baseUrl, publicUrl, companyId, boardApiToken, config.maxAgentsPerThread, connectCompanyIds);
929
1234
  return;
930
1235
  }
931
1236
  if (config.enableInbound && msg.reply_to_message?.from?.is_bot) {
@@ -977,6 +1282,13 @@ async function handleCallbackQuery(ctx, token, query, baseUrl, boardApiToken) {
977
1282
  const actor = query.from.username ?? query.from.first_name ?? String(query.from.id);
978
1283
  const chatId = query.message?.chat.id ? String(query.message.chat.id) : null;
979
1284
  const messageId = query.message?.message_id;
1285
+ // Must precede the "approve_" branch below only by intent, not by necessity:
1286
+ // these are "cmd_approve_"/"cmd_reject_" and cannot collide with it. Kept
1287
+ // adjacent so the two approval flows are read together.
1288
+ if (isWorkflowApprovalCallback(data)) {
1289
+ await resolveWorkflowApprovalCallback(ctx, token, data, query.id, actor, messageId);
1290
+ return;
1291
+ }
980
1292
  if (data.startsWith("approve_")) {
981
1293
  const approvalId = data.replace("approve_", "");
982
1294
  ctx.logger.info("Approval button clicked", { approvalId, actor });
@@ -991,7 +1303,7 @@ async function handleCallbackQuery(ctx, token, query, baseUrl, boardApiToken) {
991
1303
  });
992
1304
  await answerCallbackQuery(ctx, token, query.id, "Approved");
993
1305
  if (chatId && messageId) {
994
- await editMessage(ctx, token, chatId, messageId, `${escapeMarkdownV2("\u2705")} *Approved* by ${escapeMarkdownV2(actor)}`, { parseMode: "MarkdownV2" });
1306
+ await editMessage(ctx, token, chatId, messageId, `${escapeMarkdownV2("✅")} *Approved* by ${escapeMarkdownV2(actor)}`, { parseMode: "MarkdownV2" });
995
1307
  }
996
1308
  }
997
1309
  catch (err) {
@@ -1022,7 +1334,7 @@ async function handleCallbackQuery(ctx, token, query, baseUrl, boardApiToken) {
1022
1334
  });
1023
1335
  await answerCallbackQuery(ctx, token, query.id, "Rejected");
1024
1336
  if (chatId && messageId) {
1025
- await editMessage(ctx, token, chatId, messageId, `${escapeMarkdownV2("\u274c")} *Rejected* by ${escapeMarkdownV2(actor)}`, { parseMode: "MarkdownV2" });
1337
+ await editMessage(ctx, token, chatId, messageId, `${escapeMarkdownV2("❌")} *Rejected* by ${escapeMarkdownV2(actor)}`, { parseMode: "MarkdownV2" });
1026
1338
  }
1027
1339
  }
1028
1340
  catch (err) {