paperclip-plugin-telegram 0.7.1 → 0.8.0

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