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/README.md +5 -5
- package/dist/acp-bridge.d.ts +9 -2
- package/dist/acp-bridge.js +69 -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/command-registry.js +10 -3
- package/dist/command-registry.js.map +1 -1
- package/dist/commands.js +54 -11
- 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 +116 -0
- package/dist/worker.js +761 -441
- 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", {
|
|
@@ -168,15 +224,348 @@ async function resolveCompanyId(ctx, chatId) {
|
|
|
168
224
|
scopeKind: "instance",
|
|
169
225
|
stateKey: `chat_${chatId}`,
|
|
170
226
|
});
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (!
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
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
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
escapeMarkdownV2(
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
1316
|
+
await editMessage(ctx, token, chatId, messageId, `${escapeMarkdownV2("❌")} *Rejected* by ${escapeMarkdownV2(actor)}`, { parseMode: "MarkdownV2" });
|
|
997
1317
|
}
|
|
998
1318
|
}
|
|
999
1319
|
catch (err) {
|