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