paperclip-plugin-telegram 0.7.0 → 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", {
@@ -168,15 +224,348 @@ async function resolveCompanyId(ctx, chatId) {
168
224
  scopeKind: "instance",
169
225
  stateKey: `chat_${chatId}`,
170
226
  });
171
- return mapping?.companyId ?? mapping?.companyName ?? chatId;
227
+ const companyId = mapping?.companyId ?? mapping?.companyName;
228
+ if (!companyId) {
229
+ throw new Error("This chat is not linked to a Paperclip company. Use /connect first.");
230
+ }
231
+ return companyId;
232
+ }
233
+ // Non-throwing variant for handleUpdate call sites. A throw escaping
234
+ // handleUpdate prevents the polling offset from advancing, so the same
235
+ // update is re-fetched and re-thrown forever — the poller wedges for every
236
+ // chat. Unlinked chats must degrade per-path instead (friendly reply for
237
+ // commands, skip for media/thread routing).
238
+ async function resolveCompanyIdOrNull(ctx, chatId) {
239
+ try {
240
+ return await resolveCompanyId(ctx, chatId);
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ }
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;
172
425
  }
173
- const plugin = definePlugin({
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({
174
561
  async setup(ctx) {
175
- const rawConfig = await ctx.config.get();
176
- ctx.logger.info("Telegram plugin config loaded");
177
- const config = rawConfig;
178
- const baseUrl = config.paperclipBaseUrl || "http://localhost:3100";
179
- 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.
180
569
  ctx.data.register("board-access.read", async () => getBoardAccessRegistration(await loadBoardAccessState(ctx)));
181
570
  ctx.actions.register("board-access.update", async (params) => {
182
571
  const record = isRecord(params) ? params : {};
@@ -191,301 +580,140 @@ const plugin = definePlugin({
191
580
  updatedAt: now,
192
581
  });
193
582
  });
194
- if (!config.telegramBotTokenRef) {
195
- ctx.logger.warn("No telegramBotTokenRef configured, plugin disabled");
196
- return;
197
- }
198
- const token = await resolveStartupTelegramBotToken(ctx, config.telegramBotTokenRef, (health) => {
199
- runtimeHealth = health;
200
- });
201
- if (!token) {
202
- ctx.logger.warn("Telegram plugin runtime disabled because bot token could not be resolved");
203
- return;
204
- }
205
- const telegramToken = token;
206
- // --- Register bot commands with Telegram ---
207
- if (config.enableCommands) {
208
- const allCommands = [
209
- ...BOT_COMMANDS,
210
- { command: "commands", description: "Manage custom workflow commands" },
211
- ];
212
- // Non-blocking init: don't hold up worker initialize on external API.
213
- // The host's worker-init RPC timeout is 15s; if api.telegram.org is
214
- // slow/unreachable, awaiting this call causes the worker to be SIGKILLed
215
- // before setup() completes. Fire-and-forget matches pollUpdates() below.
216
- setMyCommands(ctx, token, allCommands)
217
- .then((registered) => {
218
- if (registered) {
219
- ctx.logger.info("Bot commands registered with Telegram");
220
- }
221
- })
222
- .catch((err) => {
223
- ctx.logger.error("Failed to register bot commands", {
224
- error: String(err),
225
- });
226
- });
227
- }
228
- // --- Long polling for inbound messages ---
229
- let pollingActive = true;
230
- let lastUpdateId = await getPersistedTelegramUpdateOffset(ctx);
231
- async function pollUpdates() {
232
- while (pollingActive) {
233
- try {
234
- const res = await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/getUpdates?offset=${lastUpdateId + 1}&timeout=10&allowed_updates=["message","callback_query"]`, { method: "GET" });
235
- const data = (await res.json());
236
- if (data.ok && data.result) {
237
- lastUpdateId = await processTelegramUpdateBatch({
238
- updates: data.result,
239
- lastUpdateId,
240
- handleUpdate: (update) => handleUpdate(ctx, telegramToken, config, update, baseUrl, publicUrl),
241
- persistOffset: (updateId) => persistTelegramUpdateOffset(ctx, updateId),
242
- logger: ctx.logger,
243
- });
244
- }
245
- }
246
- catch (err) {
247
- ctx.logger.error("Telegram polling error", { error: String(err) });
248
- await new Promise((r) => setTimeout(r, 5000));
249
- }
250
- }
251
- }
252
- if (config.enableCommands || config.enableInbound) {
253
- pollUpdates().catch((err) => ctx.logger.error("Polling loop crashed", { error: String(err) }));
254
- }
255
583
  ctx.events.on("plugin.stopping", async () => {
256
584
  pollingActive = false;
257
585
  });
258
586
  // --- Phase 2: ACP output listener (cross-plugin events) ---
259
- setupAcpOutputListener(ctx, token);
587
+ setupAcpOutputListener(ctx, () => runtime?.token ?? null);
260
588
  // --- Event subscriptions ---
261
- const issuePrefixCache = new Map();
262
- async function resolveIssueLinksOpts(companyId) {
263
- let prefix = issuePrefixCache.get(companyId);
264
- if (!prefix) {
265
- const company = await ctx.companies.get(companyId);
266
- prefix = company?.issuePrefix ?? "";
267
- if (prefix)
268
- issuePrefixCache.set(companyId, prefix);
269
- }
270
- return { baseUrl: publicUrl, issuePrefix: prefix || undefined };
271
- }
272
- const notify = async (event, formatter, overrideChatId, overrideTopicId) => {
273
- const chatId = await resolveChat(ctx, event.companyId, overrideChatId || config.defaultChatId);
274
- if (!chatId)
589
+ ctx.events.on("issue.created", async (event) => {
590
+ const rt = ensureRuntime();
591
+ if (!rt || !rt.config.notifyOnIssueCreated)
592
+ return;
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}`))
275
603
  return;
276
- const linksOpts = await resolveIssueLinksOpts(event.companyId);
277
- const msg = formatter(event, linksOpts);
278
- let messageThreadId = parseTopicId(overrideTopicId);
279
- if (!messageThreadId) {
280
- messageThreadId = await resolveNotificationThreadId(ctx, chatId, event, config.topicRouting);
281
- }
282
- if (messageThreadId) {
283
- msg.options.messageThreadId = messageThreadId;
284
- }
285
- // Issue threading — if we've already sent a message for this entity in this
286
- // chat+topic, reply to that anchor so all updates about a single entity stack
287
- // as one Telegram thread on mobile (created → comments → done).
288
- const anchorKey = event.entityId
289
- ? `anchor_${chatId}_${event.entityType}_${event.entityId}`
290
- : null;
291
- if (anchorKey) {
292
- const anchor = (await ctx.state.get({
293
- scopeKind: "instance",
294
- stateKey: anchorKey,
295
- }));
296
- // Only thread when targeting the same topic — Telegram rejects cross-topic replies.
297
- if (anchor?.messageId && anchor.messageThreadId === messageThreadId) {
298
- msg.options.replyToMessageId = anchor.messageId;
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;
299
610
  }
611
+ catch { /* best effort */ }
300
612
  }
301
- const messageId = await sendMessage(ctx, token, chatId, msg.text, msg.options);
302
- if (messageId) {
303
- await ctx.state.set({
304
- scopeKind: "instance",
305
- stateKey: `msg_${chatId}_${messageId}`,
306
- }, {
307
- entityId: event.entityId,
308
- entityType: event.entityType,
309
- companyId: event.companyId,
310
- eventType: event.eventType,
311
- });
312
- await ctx.activity.log({
313
- companyId: event.companyId,
314
- message: `Forwarded ${event.eventType} to Telegram`,
315
- entityType: "plugin",
316
- entityId: event.entityId,
317
- });
318
- // First-message-per-entity: store the anchor so future notifications about the
319
- // same entity reply to this one. Never overwritten — the first message stays root.
320
- if (anchorKey) {
321
- const existing = (await ctx.state.get({
322
- scopeKind: "instance",
323
- stateKey: anchorKey,
324
- }));
325
- if (!existing) {
326
- 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;
327
620
  }
328
621
  }
622
+ catch { /* best effort */ }
329
623
  }
330
- };
331
- if (config.notifyOnIssueCreated) {
332
- ctx.events.on("issue.created", (event) => notify(event, formatIssueCreated));
333
- }
334
- if (config.notifyOnIssueDone) {
335
- const doneDedupe = makeUpdateDedupe();
336
- ctx.events.on("issue.updated", async (event) => {
337
- const payload = event.payload;
338
- if (payload.status !== "done")
339
- return;
340
- if (!doneDedupe(`done|${event.entityId}`))
341
- return;
342
- // Enrich with title if missing (issue.updated events often omit it)
343
- if (!payload.title && event.entityId) {
344
- try {
345
- const issue = await ctx.issues.get(event.entityId, event.companyId);
346
- if (issue)
347
- payload.title = issue.title;
348
- }
349
- catch { /* best effort */ }
350
- }
351
- // Enrich with latest comment (completion summary)
352
- if (!payload.comment && event.entityId) {
353
- try {
354
- const comments = await ctx.issues.listComments(event.entityId, event.companyId);
355
- if (comments.length > 0) {
356
- const latest = comments.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
357
- payload.comment = latest.body;
358
- }
359
- }
360
- catch { /* best effort */ }
361
- }
362
- await notify(event, formatIssueDone);
363
- });
364
- }
365
- if (config.notifyOnIssueAssigned) {
366
- const assignmentDedupe = makeUpdateDedupe();
367
- ctx.events.on("issue.updated", async (event) => {
368
- const payload = event.payload;
369
- const prev = payload._previous ?? {};
370
- const userChanged = "assigneeUserId" in payload && payload.assigneeUserId !== prev.assigneeUserId;
371
- const agentChanged = "assigneeAgentId" in payload && payload.assigneeAgentId !== prev.assigneeAgentId;
372
- if (!userChanged && !agentChanged)
373
- return;
374
- if (config.onlyNotifyIfAssignedTo && payload.assigneeUserId !== config.onlyNotifyIfAssignedTo) {
375
- return;
376
- }
377
- const dedupeKey = [
378
- "assigned",
379
- event.entityId,
380
- String(prev.assigneeUserId ?? ""),
381
- String(payload.assigneeUserId ?? ""),
382
- String(prev.assigneeAgentId ?? ""),
383
- String(payload.assigneeAgentId ?? ""),
384
- ].join("|");
385
- if (!assignmentDedupe(dedupeKey))
386
- return;
387
- if ((!payload.title || !payload.assigneeName) && event.entityId) {
388
- try {
389
- const issue = await ctx.issues.get(event.entityId, event.companyId);
390
- if (issue) {
391
- payload.title ??= issue.title;
392
- const name = issue.assigneeName;
393
- if (name)
394
- payload.assigneeName ??= name;
395
- }
396
- }
397
- catch { /* best effort */ }
398
- }
399
- await notify(event, formatIssueAssigned);
400
- });
401
- }
402
- if (config.notifyOnApprovalCreated) {
403
- ctx.events.on("approval.created", async (event) => {
404
- if (!shouldNotifyApproval(event, config.onlyNotifyBoardApprovals))
405
- return;
406
- const payload = event.payload;
407
- // Enrich with linked issue details (event only has issueIds)
408
- const issueIds = Array.isArray(payload.issueIds) ? payload.issueIds : [];
409
- if (issueIds.length > 0 && !payload.linkedIssues) {
410
- try {
411
- const issues = await Promise.all(issueIds.slice(0, 5).map((id) => ctx.issues.get(id, event.companyId)));
412
- payload.linkedIssues = issues
413
- .filter(Boolean)
414
- .map((i) => ({
415
- identifier: i.identifier,
416
- title: i.title,
417
- status: i.status,
418
- priority: i.priority,
419
- }));
420
- // Use first issue's title as the approval title if missing
421
- if (!payload.title && issues[0]) {
422
- payload.title = issues[0].identifier
423
- ? `${issues[0].identifier}: ${issues[0].title}`
424
- : issues[0].title;
425
- }
426
- }
427
- catch { /* best effort */ }
428
- }
429
- // Enrich agent name
430
- if (payload.agentId && !payload.agentName) {
431
- try {
432
- const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
433
- if (agent)
434
- payload.agentName = agent.name;
435
- }
436
- catch { /* best effort */ }
437
- }
438
- // Build a meaningful title if still missing
439
- if (!payload.title || payload.title === "Approval Requested") {
440
- const approvalType = String(payload.type ?? "unknown").replace(/_/g, " ");
441
- const agentLabel = payload.agentName ? String(payload.agentName) : null;
442
- payload.title = agentLabel
443
- ? `${approvalType} — ${agentLabel}`
444
- : approvalType;
445
- }
446
- await notify(event, formatApprovalCreated, config.approvalsChatId, config.approvalsTopicId);
447
- });
448
- }
449
- if (config.notifyOnAgentError) {
450
- const agentErrorDedupe = makeUpdateDedupe(AGENT_ERROR_DEDUPLICATION_WINDOW_MS, 1000);
451
- ctx.events.on("agent.run.failed", async (event) => {
452
- const payload = event.payload;
453
- const agentId = String(payload.agentId ?? event.entityId);
454
- if (payload.agentId && !payload.agentName) {
455
- try {
456
- const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
457
- if (agent)
458
- 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;
459
657
  }
460
- catch { /* best effort */ }
461
658
  }
462
- if (!payload.companyName) {
463
- try {
464
- const company = await ctx.companies.get(event.companyId);
465
- if (company?.name)
466
- 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;
467
688
  }
468
- catch { /* best effort */ }
469
689
  }
470
- if (payload.issueId && (!payload.issueIdentifier || !payload.issueTitle)) {
471
- try {
472
- const issue = await ctx.issues.get(String(payload.issueId), event.companyId);
473
- if (issue) {
474
- payload.issueIdentifier ??= issue.identifier;
475
- payload.issueTitle ??= issue.title;
476
- }
477
- }
478
- 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;
479
698
  }
480
- const errorMessage = normalizeAgentErrorMessage(payload.error ?? payload.message);
481
- const dedupeKey = ["agent.run.failed", event.companyId, agentId, errorMessage].join(":");
482
- if (!agentErrorDedupe(dedupeKey))
483
- return;
484
- await notify(event, formatAgentError, config.errorsChatId, config.errorsTopicId);
485
- });
486
- }
487
- 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;
488
715
  const payload = event.payload;
716
+ const agentId = String(payload.agentId ?? event.entityId);
489
717
  if (payload.agentId && !payload.agentName) {
490
718
  try {
491
719
  const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
@@ -494,19 +722,44 @@ const plugin = definePlugin({
494
722
  }
495
723
  catch { /* best effort */ }
496
724
  }
497
- };
498
- if (config.notifyOnAgentRunStarted) {
499
- ctx.events.on("agent.run.started", async (event) => {
500
- await enrichAgentName(event);
501
- await notify(event, formatAgentRunStarted);
502
- });
503
- }
504
- if (config.notifyOnAgentRunFinished) {
505
- ctx.events.on("agent.run.finished", async (event) => {
506
- await enrichAgentName(event);
507
- await notify(event, formatAgentRunFinished);
508
- });
509
- }
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
+ });
510
763
  // --- Per-company chat overrides ---
511
764
  ctx.data.register("chat-mapping", async (params) => {
512
765
  const companyId = String(params.companyId);
@@ -515,7 +768,8 @@ const plugin = definePlugin({
515
768
  scopeId: companyId,
516
769
  stateKey: "telegram-chat",
517
770
  });
518
- return { chatId: saved ?? config.defaultChatId };
771
+ const rt = ensureRuntime();
772
+ return { chatId: saved ?? rt?.config.defaultChatId ?? "" };
519
773
  });
520
774
  ctx.actions.register("set-chat", async (params) => {
521
775
  const companyId = String(params.companyId);
@@ -525,115 +779,123 @@ const plugin = definePlugin({
525
779
  return { ok: true };
526
780
  });
527
781
  // --- Daily digest job ---
528
- // Support legacy dailyDigestEnabled boolean
529
- const effectiveDigestMode = config.dailyDigestEnabled === true && config.digestMode === "off"
530
- ? "daily"
531
- : config.digestMode ?? "off";
532
- if (effectiveDigestMode !== "off") {
533
- ctx.jobs.register("telegram-daily-digest", async () => {
534
- // Check if current UTC hour matches a configured digest time
535
- const nowHour = new Date().getUTCHours();
536
- const nowMin = new Date().getUTCMinutes();
537
- if (nowMin >= 5)
538
- return; // only fire within first 5 min of the hour
539
- const parseHour = (t) => {
540
- const [h] = (t || "").split(":");
541
- return parseInt(h ?? "", 10);
542
- };
543
- const firstHour = parseHour(config.dailyDigestTime);
544
- const secondHour = parseHour(config.bidailySecondTime);
545
- const tridailyHours = (config.tridailyTimes || "07:00,13:00,19:00")
546
- .split(",")
547
- .map((t) => parseHour(t.trim()));
548
- let shouldSend = false;
549
- if (effectiveDigestMode === "daily") {
550
- shouldSend = nowHour === firstHour;
551
- }
552
- else if (effectiveDigestMode === "bidaily") {
553
- shouldSend = nowHour === firstHour || nowHour === secondHour;
554
- }
555
- else if (effectiveDigestMode === "tridaily") {
556
- shouldSend = tridailyHours.includes(nowHour);
557
- }
558
- if (!shouldSend)
559
- return;
560
- const companies = await ctx.companies.list();
561
- for (const company of companies) {
562
- const chatId = await resolveChat(ctx, company.id, config.digestChatId || config.defaultChatId);
563
- if (!chatId)
564
- continue;
565
- try {
566
- const agents = await ctx.agents.list({ companyId: company.id });
567
- const activeAgents = agents.filter((a) => a.status === "active");
568
- const issues = await ctx.issues.list({ companyId: company.id, limit: 50 });
569
- const now = Date.now();
570
- const oneDayMs = 24 * 60 * 60 * 1000;
571
- const completedToday = issues.filter((i) => i.status === "done" && i.completedAt && (now - new Date(i.completedAt).getTime()) < oneDayMs);
572
- const createdToday = issues.filter((i) => (now - new Date(i.createdAt).getTime()) < oneDayMs);
573
- const issuePrefix = company.issuePrefix;
574
- const inProgress = issues.filter((i) => i.status === "in_progress");
575
- const inReview = issues.filter((i) => i.status === "in_review");
576
- const blocked = issues.filter((i) => i.status === "blocked");
577
- const dateStr = new Date().toISOString().split("T")[0];
578
- const companyLabel = company.name ? ` \\- ${escapeMarkdownV2(company.name)}` : "";
579
- const digestLabel = effectiveDigestMode === "bidaily" ? "Digest" : "Daily Digest";
580
- const lines = [
581
- escapeMarkdownV2("\ud83d\udcca") + ` *${escapeMarkdownV2(digestLabel)}${companyLabel} \\- ${escapeMarkdownV2(dateStr)}*`,
582
- "",
583
- `${escapeMarkdownV2("\u2705")} Tasks completed: *${completedToday.length}*`,
584
- `${escapeMarkdownV2("\ud83d\udccb")} Tasks created: *${createdToday.length}*`,
585
- `${escapeMarkdownV2("\ud83e\udd16")} Active agents: *${activeAgents.length}*/${escapeMarkdownV2(String(agents.length))}`,
586
- ];
587
- if (activeAgents.length > 0) {
588
- const topAgent = activeAgents[0].name;
589
- lines.push(`${escapeMarkdownV2("\u2b50")} Top performer: *${escapeMarkdownV2(topAgent)}*`);
590
- }
591
- const formatIssueItem = (i) => {
592
- const id = i.identifier ?? i.id;
593
- const idText = issuePrefix
594
- ? `[${escapeMarkdownV2(id)}](${publicUrl}/${issuePrefix}/issues/${id})`
595
- : escapeMarkdownV2(id);
596
- return ` ${idText} \\- ${escapeMarkdownV2(i.title)}`;
597
- };
598
- if (inProgress.length > 0) {
599
- lines.push("", `${escapeMarkdownV2("\ud83d\udd04")} *In Progress \\(${inProgress.length}\\)*`);
600
- for (const i of inProgress.slice(0, 10))
601
- lines.push(formatIssueItem(i));
602
- }
603
- if (inReview.length > 0) {
604
- lines.push("", `${escapeMarkdownV2("\ud83d\udd0d")} *In Review \\(${inReview.length}\\)*`);
605
- for (const i of inReview.slice(0, 10))
606
- lines.push(formatIssueItem(i));
607
- }
608
- if (blocked.length > 0) {
609
- lines.push("", `${escapeMarkdownV2("\ud83d\udeab")} *Blocked \\(${blocked.length}\\)*`);
610
- for (const i of blocked.slice(0, 10))
611
- lines.push(formatIssueItem(i));
612
- }
613
- const digestThreadId = await resolveDigestThreadId(ctx, token, chatId, config.digestTopicId);
614
- await sendMessage(ctx, token, chatId, lines.join("\n"), {
615
- parseMode: "MarkdownV2",
616
- messageThreadId: digestThreadId,
617
- });
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)}*`);
618
854
  }
619
- catch (err) {
620
- ctx.logger.error("Daily digest failed for company", { companyId: company.id, error: String(err) });
621
- const text = [
622
- escapeMarkdownV2("\ud83d\udcca") + " *Daily Digest*",
623
- "",
624
- escapeMarkdownV2("Could not generate digest. Check plugin logs for details."),
625
- ].join("\n");
626
- const errorThreadId = await resolveDigestThreadId(ctx, token, chatId, config.errorsTopicId || config.digestTopicId);
627
- await sendMessage(ctx, token, chatId, text, {
628
- parseMode: "MarkdownV2",
629
- messageThreadId: errorThreadId,
630
- });
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));
631
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
+ });
632
882
  }
633
- });
634
- }
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
+ });
635
898
  // --- Phase 1: Escalation support ---
636
- const escalationManager = new EscalationManager();
637
899
  // Register escalate_to_human tool - 3-arg signature with ToolRunContext
638
900
  ctx.tools.register("escalate_to_human", {
639
901
  displayName: "Escalate to Human",
@@ -674,11 +936,14 @@ const plugin = definePlugin({
674
936
  required: ["reason", "conversationSummary"],
675
937
  },
676
938
  }, async (params, runCtx) => {
939
+ const rt = ensureRuntime();
940
+ if (!rt)
941
+ return { error: "Telegram plugin is not configured yet" };
677
942
  const p = params;
678
943
  const escalationId = crypto.randomUUID();
679
- const timeoutMs = config.escalationTimeoutMs || 900000;
680
- const defaultAction = config.escalationDefaultAction || "defer";
681
- 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);
682
947
  if (!resolvedEscalationChatId) {
683
948
  ctx.logger.warn("Escalation received but no escalationChatId configured");
684
949
  return { error: "No escalation channel configured" };
@@ -705,11 +970,11 @@ const plugin = definePlugin({
705
970
  transport: p.transport,
706
971
  sessionId: p.sessionId ? String(p.sessionId) : undefined,
707
972
  };
708
- await escalationManager.create(ctx, token, escalationEvent, resolvedEscalationChatId);
973
+ await escalationManager.create(ctx, rt.token, escalationEvent, resolvedEscalationChatId);
709
974
  // Send hold message to the originating chat if configured
710
- if (config.escalationHoldMessage && escalationEvent.originChatId) {
711
- const holdText = escapeMarkdownV2(config.escalationHoldMessage);
712
- 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, {
713
978
  parseMode: "MarkdownV2",
714
979
  messageThreadId: escalationEvent.originThreadId ? Number(escalationEvent.originThreadId) : undefined,
715
980
  replyToMessageId: escalationEvent.originMessageId ? Number(escalationEvent.originMessageId) : undefined,
@@ -734,7 +999,10 @@ const plugin = definePlugin({
734
999
  required: ["targetAgent", "reason", "contextSummary"],
735
1000
  },
736
1001
  }, async (params, runCtx) => {
737
- 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);
738
1006
  });
739
1007
  // --- Phase 2: Register discuss_with_agent tool ---
740
1008
  ctx.tools.register("discuss_with_agent", {
@@ -754,7 +1022,10 @@ const plugin = definePlugin({
754
1022
  required: ["targetAgent", "topic", "initialMessage"],
755
1023
  },
756
1024
  }, async (params, runCtx) => {
757
- 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);
758
1029
  });
759
1030
  // --- Phase 5: Register register_watch tool ---
760
1031
  ctx.tools.register("register_watch", {
@@ -791,8 +1062,11 @@ const plugin = definePlugin({
791
1062
  });
792
1063
  // --- Phase 1: Escalation timeout checker job ---
793
1064
  ctx.jobs.register("check-escalation-timeouts", async () => {
1065
+ const rt = ensureRuntime();
1066
+ if (!rt)
1067
+ return;
794
1068
  try {
795
- await escalationManager.checkTimeouts(ctx, token);
1069
+ await escalationManager.checkTimeouts(ctx, rt.token);
796
1070
  }
797
1071
  catch (err) {
798
1072
  ctx.logger.error("Escalation timeout check failed", { error: String(err) });
@@ -800,17 +1074,51 @@ const plugin = definePlugin({
800
1074
  });
801
1075
  // --- Phase 5: Watch checker job ---
802
1076
  ctx.jobs.register("check-watches", async () => {
1077
+ const rt = ensureRuntime();
1078
+ if (!rt)
1079
+ return;
803
1080
  try {
804
- await checkWatches(ctx, token, {
805
- maxSuggestionsPerHourPerCompany: config.maxSuggestionsPerHourPerCompany ?? 10,
806
- watchDeduplicationWindowMs: config.watchDeduplicationWindowMs ?? 86400000,
1081
+ await checkWatches(ctx, rt.token, {
1082
+ maxSuggestionsPerHourPerCompany: rt.config.maxSuggestionsPerHourPerCompany ?? 10,
1083
+ watchDeduplicationWindowMs: rt.config.watchDeduplicationWindowMs ?? 86400000,
807
1084
  });
808
1085
  }
809
1086
  catch (err) {
810
1087
  ctx.logger.error("Watch check failed", { error: String(err) });
811
1088
  }
812
1089
  });
813
- 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
+ });
814
1122
  },
815
1123
  async onValidateConfig(config) {
816
1124
  const secretRefErrors = validateSecretRefFields(config);
@@ -831,7 +1139,7 @@ const plugin = definePlugin({
831
1139
  return runtimeHealth;
832
1140
  },
833
1141
  });
834
- async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boardApiToken) {
1142
+ export async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boardApiToken) {
835
1143
  if (!isTelegramUpdateAllowed(config, update)) {
836
1144
  const fromId = update.message?.from?.id ?? update.callback_query?.from.id;
837
1145
  const chatId = update.message?.chat.id ?? update.callback_query?.message?.chat.id;
@@ -856,15 +1164,20 @@ async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boar
856
1164
  // Phase 3: Handle media messages
857
1165
  const hasMedia = !!(msg.voice || msg.audio || msg.video_note || msg.document || msg.photo);
858
1166
  if (hasMedia) {
859
- const companyId = await resolveCompanyId(ctx, chatId);
860
- const handled = await handleMediaMessage(ctx, token, msg, {
861
- briefAgentId: config.briefAgentId ?? "",
862
- briefAgentChatIds: config.briefAgentChatIds ?? [],
863
- transcriptionApiKeyRef: config.transcriptionApiKeyRef ?? "",
864
- publicUrl,
865
- }, companyId);
866
- if (handled)
867
- return;
1167
+ const companyId = await resolveCompanyIdOrNull(ctx, chatId);
1168
+ if (companyId) {
1169
+ const handled = await handleMediaMessage(ctx, token, msg, {
1170
+ briefAgentId: config.briefAgentId ?? "",
1171
+ briefAgentChatIds: config.briefAgentChatIds ?? [],
1172
+ transcriptionApiKeyRef: config.transcriptionApiKeyRef ?? "",
1173
+ publicUrl,
1174
+ }, companyId);
1175
+ if (handled)
1176
+ return;
1177
+ }
1178
+ else {
1179
+ ctx.logger.debug("Ignoring media message from unlinked chat", { chatId });
1180
+ }
868
1181
  }
869
1182
  if (!msg.text)
870
1183
  return;
@@ -873,11 +1186,16 @@ async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boar
873
1186
  if (threadId) {
874
1187
  const isCommand = text.startsWith("/");
875
1188
  if (!isCommand) {
876
- const companyId = await resolveCompanyId(ctx, chatId);
877
- const replyToId = msg.reply_to_message?.message_id;
878
- const routed = await routeMessageToAgent(ctx, token, chatId, threadId, text, replyToId, companyId);
879
- if (routed)
880
- return;
1189
+ const companyId = await resolveCompanyIdOrNull(ctx, chatId);
1190
+ if (companyId) {
1191
+ const replyToId = msg.reply_to_message?.message_id;
1192
+ const routed = await routeMessageToAgent(ctx, token, chatId, threadId, text, replyToId, companyId);
1193
+ if (routed)
1194
+ return;
1195
+ }
1196
+ else {
1197
+ ctx.logger.debug("Not routing thread message from unlinked chat", { chatId });
1198
+ }
881
1199
  }
882
1200
  }
883
1201
  const botCommand = msg.entities?.find((e) => e.type === "bot_command" && e.offset === 0);
@@ -885,7 +1203,9 @@ async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boar
885
1203
  const fullCommand = text.slice(botCommand.offset, botCommand.offset + botCommand.length);
886
1204
  const command = fullCommand.replace(/^\//, "").replace(/@.*$/, "");
887
1205
  const args = text.slice(botCommand.offset + botCommand.length).trim();
888
- const companyId = await resolveCompanyId(ctx, chatId);
1206
+ // undefined on unlinked chats: /connect and /help still work, and the
1207
+ // company-scoped handlers answer with their "not linked" guidance.
1208
+ const companyId = (await resolveCompanyIdOrNull(ctx, chatId)) ?? undefined;
889
1209
  // Phase 4: Check custom commands first
890
1210
  if (command === "commands") {
891
1211
  await handleCommandsCommand(ctx, token, chatId, args, threadId, companyId);
@@ -962,7 +1282,7 @@ async function handleCallbackQuery(ctx, token, query, baseUrl, boardApiToken) {
962
1282
  });
963
1283
  await answerCallbackQuery(ctx, token, query.id, "Approved");
964
1284
  if (chatId && messageId) {
965
- 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" });
966
1286
  }
967
1287
  }
968
1288
  catch (err) {
@@ -993,7 +1313,7 @@ async function handleCallbackQuery(ctx, token, query, baseUrl, boardApiToken) {
993
1313
  });
994
1314
  await answerCallbackQuery(ctx, token, query.id, "Rejected");
995
1315
  if (chatId && messageId) {
996
- 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" });
997
1317
  }
998
1318
  }
999
1319
  catch (err) {