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/README.md +5 -5
- package/dist/acp-bridge.d.ts +9 -2
- package/dist/acp-bridge.js +65 -7
- package/dist/acp-bridge.js.map +1 -1
- package/dist/agent-status.d.ts +26 -0
- package/dist/agent-status.js +33 -0
- package/dist/agent-status.js.map +1 -0
- package/dist/commands.js +41 -9
- package/dist/commands.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/escalation.d.ts +1 -1
- package/dist/escalation.js +3 -1
- package/dist/escalation.js.map +1 -1
- package/dist/formatters.js +17 -5
- package/dist/formatters.js.map +1 -1
- package/dist/manifest.js +27 -0
- package/dist/manifest.js.map +1 -1
- package/dist/media-pipeline.js +12 -3
- package/dist/media-pipeline.js.map +1 -1
- package/dist/runtime-token.d.ts +10 -3
- package/dist/runtime-token.js +18 -8
- package/dist/runtime-token.js.map +1 -1
- package/dist/secret-ref-validation.d.ts +29 -1
- package/dist/secret-ref-validation.js +33 -1
- package/dist/secret-ref-validation.js.map +1 -1
- package/dist/ui/index.js +90 -36
- package/dist/ui/index.js.map +1 -1
- package/dist/watch-registry.d.ts +1 -1
- package/dist/watch-registry.js +7 -6
- package/dist/watch-registry.js.map +1 -1
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +715 -424
- package/dist/worker.js.map +1 -1
- package/package.json +4 -4
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
escapeMarkdownV2(
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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("
|
|
1316
|
+
await editMessage(ctx, token, chatId, messageId, `${escapeMarkdownV2("❌")} *Rejected* by ${escapeMarkdownV2(actor)}`, { parseMode: "MarkdownV2" });
|
|
1026
1317
|
}
|
|
1027
1318
|
}
|
|
1028
1319
|
catch (err) {
|