@odience-network/paperclip-plugin-telegram-enhanced 0.2.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/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/acp-bridge.d.ts +35 -0
- package/dist/acp-bridge.js +891 -0
- package/dist/acp-bridge.js.map +1 -0
- package/dist/adapter.d.ts +35 -0
- package/dist/adapter.js +75 -0
- package/dist/adapter.js.map +1 -0
- package/dist/agent-labels.d.ts +12 -0
- package/dist/agent-labels.js +96 -0
- package/dist/agent-labels.js.map +1 -0
- package/dist/allowlist.d.ts +27 -0
- package/dist/allowlist.js +34 -0
- package/dist/allowlist.js.map +1 -0
- package/dist/approval-routing.d.ts +2 -0
- package/dist/approval-routing.js +7 -0
- package/dist/approval-routing.js.map +1 -0
- package/dist/command-registry.d.ts +3 -0
- package/dist/command-registry.js +268 -0
- package/dist/command-registry.js.map +1 -0
- package/dist/commands.d.ts +11 -0
- package/dist/commands.js +516 -0
- package/dist/commands.js.map +1 -0
- package/dist/constants.d.ts +76 -0
- package/dist/constants.js +71 -0
- package/dist/constants.js.map +1 -0
- package/dist/escalation.d.ts +42 -0
- package/dist/escalation.js +252 -0
- package/dist/escalation.js.map +1 -0
- package/dist/file-routing.d.ts +51 -0
- package/dist/file-routing.js +212 -0
- package/dist/file-routing.js.map +1 -0
- package/dist/formatters.d.ts +31 -0
- package/dist/formatters.js +336 -0
- package/dist/formatters.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/interaction-delivery.d.ts +90 -0
- package/dist/interaction-delivery.js +142 -0
- package/dist/interaction-delivery.js.map +1 -0
- package/dist/manifest.d.ts +3 -0
- package/dist/manifest.js +111 -0
- package/dist/manifest.js.map +1 -0
- package/dist/media-pipeline.d.ts +47 -0
- package/dist/media-pipeline.js +162 -0
- package/dist/media-pipeline.js.map +1 -0
- package/dist/notification-filters.d.ts +23 -0
- package/dist/notification-filters.js +93 -0
- package/dist/notification-filters.js.map +1 -0
- package/dist/paperclip-api.d.ts +25 -0
- package/dist/paperclip-api.js +69 -0
- package/dist/paperclip-api.js.map +1 -0
- package/dist/polling-offset.d.ts +22 -0
- package/dist/polling-offset.js +68 -0
- package/dist/polling-offset.js.map +1 -0
- package/dist/secret-ref-validation.d.ts +7 -0
- package/dist/secret-ref-validation.js +49 -0
- package/dist/secret-ref-validation.js.map +1 -0
- package/dist/telegram-api.d.ts +40 -0
- package/dist/telegram-api.js +251 -0
- package/dist/telegram-api.js.map +1 -0
- package/dist/topic-projects.d.ts +2 -0
- package/dist/topic-projects.js +45 -0
- package/dist/topic-projects.js.map +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1446 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/watch-registry.d.ts +9 -0
- package/dist/watch-registry.js +272 -0
- package/dist/watch-registry.js.map +1 -0
- package/dist/worker.d.ts +162 -0
- package/dist/worker.js +1520 -0
- package/dist/worker.js.map +1 -0
- package/package.json +59 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
import { definePlugin, runWorker, } from "@paperclipai/plugin-sdk";
|
|
2
|
+
import { sendMessage, sendDocument, editMessage, answerCallbackQuery, setMyCommands, escapeMarkdownV2, isForum, GENERAL_TOPIC_THREAD_ID, } from "./telegram-api.js";
|
|
3
|
+
import { resolveTelegramFileDestination, getTelegramFileRouteSaveErrors, } from "./file-routing.js";
|
|
4
|
+
import { formatIssueCreated, formatIssueDone, formatIssueAssigned, formatApprovalCreated, formatAgentError, formatAgentRunStarted, formatAgentRunFinished, formatIssueBlocked, formatBoardMention, formatResolvedDecision, } from "./formatters.js";
|
|
5
|
+
import { handleCommand, resolveNotificationThreadId, BOT_COMMANDS } from "./commands.js";
|
|
6
|
+
import { routeMessageToAgent, handleHandoffToolCall, handleDiscussToolCall, handleHandoffApproval, handleHandoffRejection, setupAcpOutputListener, } from "./acp-bridge.js";
|
|
7
|
+
import { handleMediaMessage } from "./media-pipeline.js";
|
|
8
|
+
import { getPersistedTelegramUpdateOffset, persistTelegramUpdateOffset, processTelegramUpdateBatch, } from "./polling-offset.js";
|
|
9
|
+
import { handleCommandsCommand, tryCustomCommand } from "./command-registry.js";
|
|
10
|
+
import { handleRegisterWatch, checkWatches } from "./watch-registry.js";
|
|
11
|
+
import { AGENT_ERROR_DEDUPLICATION_WINDOW_MS, METRIC_NAMES } from "./constants.js";
|
|
12
|
+
import { EscalationManager } from "./escalation.js";
|
|
13
|
+
import { isTelegramUpdateAllowed, validateTelegramAllowlists } from "./allowlist.js";
|
|
14
|
+
import { validateSecretRefFields } from "./secret-ref-validation.js";
|
|
15
|
+
import { shouldNotifyApproval } from "./approval-routing.js";
|
|
16
|
+
import { buildDeliveryKey, withIdempotentDelivery } from "./interaction-delivery.js";
|
|
17
|
+
import { shouldNotifyIssueBlocked, shouldNotifyBoardMention, parseBoardUsernames, } from "./notification-filters.js";
|
|
18
|
+
import { buildPaperclipAuthHeaders, fetchPaperclipApi, isAlreadyResolvedConflict, } from "./paperclip-api.js";
|
|
19
|
+
const TELEGRAM_API = "https://api.telegram.org";
|
|
20
|
+
const BOARD_ACCESS_SCOPE = {
|
|
21
|
+
scopeKind: "instance",
|
|
22
|
+
stateKey: "telegram.board-access.v1",
|
|
23
|
+
};
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
function asNonEmptyString(value) {
|
|
28
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
function normalizeBoardAccessState(value) {
|
|
31
|
+
const record = isRecord(value) ? value : {};
|
|
32
|
+
return {
|
|
33
|
+
paperclipBoardApiTokenRef: asNonEmptyString(record.paperclipBoardApiTokenRef),
|
|
34
|
+
identity: asNonEmptyString(record.identity),
|
|
35
|
+
companyId: asNonEmptyString(record.companyId),
|
|
36
|
+
updatedAt: asNonEmptyString(record.updatedAt),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function loadBoardAccessState(ctx) {
|
|
40
|
+
return normalizeBoardAccessState(await ctx.state.get(BOARD_ACCESS_SCOPE));
|
|
41
|
+
}
|
|
42
|
+
async function persistBoardAccessState(ctx, state) {
|
|
43
|
+
const nextState = normalizeBoardAccessState(state);
|
|
44
|
+
await ctx.state.set(BOARD_ACCESS_SCOPE, nextState);
|
|
45
|
+
return {
|
|
46
|
+
...nextState,
|
|
47
|
+
configured: Boolean(nextState.paperclipBoardApiTokenRef),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function getBoardAccessRegistration(state) {
|
|
51
|
+
return {
|
|
52
|
+
...state,
|
|
53
|
+
configured: Boolean(state.paperclipBoardApiTokenRef),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function resolveBoardApiToken(ctx, config, companyId) {
|
|
57
|
+
const boardAccessState = await loadBoardAccessState(ctx);
|
|
58
|
+
const candidates = [];
|
|
59
|
+
if (boardAccessState.paperclipBoardApiTokenRef &&
|
|
60
|
+
(!companyId || !boardAccessState.companyId || boardAccessState.companyId === companyId)) {
|
|
61
|
+
candidates.push({
|
|
62
|
+
source: "board-access",
|
|
63
|
+
ref: boardAccessState.paperclipBoardApiTokenRef,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (config.paperclipBoardApiTokenRef) {
|
|
67
|
+
candidates.push({
|
|
68
|
+
source: "config",
|
|
69
|
+
ref: config.paperclipBoardApiTokenRef,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
if (seen.has(candidate.ref))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(candidate.ref);
|
|
77
|
+
try {
|
|
78
|
+
return await ctx.secrets.resolve(candidate.ref);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
ctx.logger.warn("Failed to resolve board API token secret", {
|
|
82
|
+
source: candidate.source,
|
|
83
|
+
companyId,
|
|
84
|
+
error: String(err),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
async function resolveCallbackCompanyId(ctx, query) {
|
|
91
|
+
const chatId = query.message?.chat.id ? String(query.message.chat.id) : null;
|
|
92
|
+
const messageId = query.message?.message_id;
|
|
93
|
+
if (!chatId || !messageId)
|
|
94
|
+
return null;
|
|
95
|
+
const mapping = await ctx.state.get({
|
|
96
|
+
scopeKind: "instance",
|
|
97
|
+
stateKey: `msg_${chatId}_${messageId}`,
|
|
98
|
+
});
|
|
99
|
+
return mapping?.companyId ?? null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Shared 5s sliding-window dedupe for issue.updated handlers.
|
|
103
|
+
*
|
|
104
|
+
* Paperclip's core can emit duplicate `issue.updated` plugin events for a
|
|
105
|
+
* single PATCH (the route's logActivity plus side-effects from heartbeat
|
|
106
|
+
* reconciliation), so handlers must dedupe to avoid sending the same
|
|
107
|
+
* Telegram message twice.
|
|
108
|
+
*/
|
|
109
|
+
function makeUpdateDedupe(windowMs = 5_000, maxEntries = 500) {
|
|
110
|
+
const seen = new Map();
|
|
111
|
+
return (key) => {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const last = seen.get(key);
|
|
114
|
+
if (last !== undefined && now - last < windowMs)
|
|
115
|
+
return false;
|
|
116
|
+
seen.set(key, now);
|
|
117
|
+
if (seen.size > maxEntries) {
|
|
118
|
+
const cutoff = now - windowMs;
|
|
119
|
+
for (const [k, ts] of seen) {
|
|
120
|
+
if (ts < cutoff)
|
|
121
|
+
seen.delete(k);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function normalizeAgentErrorMessage(input) {
|
|
128
|
+
return String(input ?? "Unknown error")
|
|
129
|
+
.trim()
|
|
130
|
+
.replace(/\s+/g, " ")
|
|
131
|
+
.slice(0, 500);
|
|
132
|
+
}
|
|
133
|
+
async function resolveChat(ctx, companyId, fallback) {
|
|
134
|
+
const override = await ctx.state.get({
|
|
135
|
+
scopeKind: "company",
|
|
136
|
+
scopeId: companyId,
|
|
137
|
+
stateKey: "telegram-chat",
|
|
138
|
+
});
|
|
139
|
+
return override ?? fallback ?? null;
|
|
140
|
+
}
|
|
141
|
+
function parseTopicId(value) {
|
|
142
|
+
const trimmed = value?.trim();
|
|
143
|
+
if (!trimmed)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (!/^\d+$/.test(trimmed))
|
|
146
|
+
return undefined;
|
|
147
|
+
return Number(trimmed);
|
|
148
|
+
}
|
|
149
|
+
function validateConfiguredTopicIds(config) {
|
|
150
|
+
const errors = [];
|
|
151
|
+
for (const key of ["approvalsTopicId", "errorsTopicId", "digestTopicId"]) {
|
|
152
|
+
const value = config[key];
|
|
153
|
+
if (value === undefined || value === null || value === "")
|
|
154
|
+
continue;
|
|
155
|
+
if (typeof value !== "string" || !parseTopicId(value)) {
|
|
156
|
+
errors.push(`${key} must be a numeric Telegram forum topic ID string.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return errors;
|
|
160
|
+
}
|
|
161
|
+
async function resolveDigestThreadId(ctx, token, chatId, configuredTopicId) {
|
|
162
|
+
const configured = parseTopicId(configuredTopicId);
|
|
163
|
+
if (configured)
|
|
164
|
+
return configured;
|
|
165
|
+
return await isForum(ctx, token, chatId) ? GENERAL_TOPIC_THREAD_ID : undefined;
|
|
166
|
+
}
|
|
167
|
+
async function resolveCompanyId(ctx, chatId) {
|
|
168
|
+
const mapping = await ctx.state.get({
|
|
169
|
+
scopeKind: "instance",
|
|
170
|
+
stateKey: `chat_${chatId}`,
|
|
171
|
+
});
|
|
172
|
+
return mapping?.companyId ?? mapping?.companyName ?? chatId;
|
|
173
|
+
}
|
|
174
|
+
// --- Agent file-send action (ant013 TEL-8 / TEL-23) ---
|
|
175
|
+
// Ported from ant013/paperclip-plugin-telegram: agent-callable "send to
|
|
176
|
+
// Telegram" action with native markdown upload plus project-key file routing.
|
|
177
|
+
const MAX_OUTBOUND_MARKDOWN_BYTES = 256 * 1024;
|
|
178
|
+
const MAX_MARKDOWN_CAPTION_BYTES = 1024;
|
|
179
|
+
const DEFAULT_MARKDOWN_FILENAME = "paperclip-message.md";
|
|
180
|
+
const SECRET_FILENAME_TOKENS = /(?:^|[^0-9A-Za-z])(?:secret|token|credential|password|private\-key)(?:$|[^0-9A-Za-z])/i;
|
|
181
|
+
const UNSAFE_FILENAME_CHARS = /[\\/\u0000-\u001F\u007F]/;
|
|
182
|
+
const FORBIDDEN_MARKDOWN_SOURCE_FIELDS = new Set([
|
|
183
|
+
"filePath",
|
|
184
|
+
"path",
|
|
185
|
+
"fileUrl",
|
|
186
|
+
"url",
|
|
187
|
+
"fileURL",
|
|
188
|
+
"fileUri",
|
|
189
|
+
"file_uri",
|
|
190
|
+
"uri",
|
|
191
|
+
"telegramFileId",
|
|
192
|
+
"telegram_file_id",
|
|
193
|
+
"file_id",
|
|
194
|
+
"file",
|
|
195
|
+
"files",
|
|
196
|
+
"binary",
|
|
197
|
+
"binaryContent",
|
|
198
|
+
"fileContent",
|
|
199
|
+
"content",
|
|
200
|
+
]);
|
|
201
|
+
function validateOutboundThreadId(value) {
|
|
202
|
+
if (value === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1)
|
|
205
|
+
return "invalid";
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
function validateMarkdownFilename(name) {
|
|
209
|
+
if (!name.toLowerCase().endsWith(".md")) {
|
|
210
|
+
return { ok: false, code: "non_markdown_file", message: "Markdown file uploads must use a .md extension." };
|
|
211
|
+
}
|
|
212
|
+
if (/^[A-Za-z]:/.test(name)) {
|
|
213
|
+
return { ok: false, code: "invalid_markdown_filename", message: "Markdown filename must be a safe basename." };
|
|
214
|
+
}
|
|
215
|
+
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
|
|
216
|
+
return { ok: false, code: "invalid_markdown_filename", message: "Markdown filename must be a safe basename." };
|
|
217
|
+
}
|
|
218
|
+
if (UNSAFE_FILENAME_CHARS.test(name) || name.startsWith(".") || SECRET_FILENAME_TOKENS.test(name)) {
|
|
219
|
+
return { ok: false, code: "unsafe_filename", message: "Markdown filename is considered unsafe." };
|
|
220
|
+
}
|
|
221
|
+
return { ok: true };
|
|
222
|
+
}
|
|
223
|
+
function findUnsupportedMarkdownSourceField(params) {
|
|
224
|
+
for (const key of FORBIDDEN_MARKDOWN_SOURCE_FIELDS) {
|
|
225
|
+
if (Object.prototype.hasOwnProperty.call(params, key) && params[key] !== undefined) {
|
|
226
|
+
return key;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function makeTelegramToolError(code, message, metadata = {}) {
|
|
232
|
+
return { ok: false, code, message, ...metadata };
|
|
233
|
+
}
|
|
234
|
+
async function resolveIssueIdentifierForFileRoute(ctx, companyId, issueId) {
|
|
235
|
+
try {
|
|
236
|
+
const issue = await ctx.issues.get(issueId, companyId);
|
|
237
|
+
if (!issue)
|
|
238
|
+
return null;
|
|
239
|
+
const issueCompanyId = issue.companyId;
|
|
240
|
+
if (typeof issueCompanyId === "string" && issueCompanyId !== companyId)
|
|
241
|
+
return null;
|
|
242
|
+
return typeof issue.identifier === "string" ? issue.identifier : null;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// A route counts as enabled unless it is explicitly disabled (enabled === false).
|
|
249
|
+
// Legacy/hand-edited config that omits the flag is treated as enabled.
|
|
250
|
+
function routeEnabled(value) {
|
|
251
|
+
return value !== false;
|
|
252
|
+
}
|
|
253
|
+
export function resolveTelegramOpsDestination(routes, companyId, companyName) {
|
|
254
|
+
if (!Array.isArray(routes))
|
|
255
|
+
return null;
|
|
256
|
+
const normalizedCompanyName = asNonEmptyString(companyName)?.toLowerCase() ?? null;
|
|
257
|
+
for (const route of routes) {
|
|
258
|
+
if (!isRecord(route) || !routeEnabled(route.enabled))
|
|
259
|
+
continue;
|
|
260
|
+
const chatId = asNonEmptyString(route.chatId);
|
|
261
|
+
if (!chatId)
|
|
262
|
+
continue;
|
|
263
|
+
const routeCompanyId = asNonEmptyString(route.companyId);
|
|
264
|
+
const routeCompanyName = asNonEmptyString(route.companyName)?.toLowerCase() ?? null;
|
|
265
|
+
const matchesCompanyId = Boolean(routeCompanyId && routeCompanyId === companyId);
|
|
266
|
+
const matchesCompanyName = Boolean(routeCompanyName && normalizedCompanyName && routeCompanyName === normalizedCompanyName);
|
|
267
|
+
if (!matchesCompanyId && !matchesCompanyName)
|
|
268
|
+
continue;
|
|
269
|
+
return {
|
|
270
|
+
chatId,
|
|
271
|
+
topicId: asNonEmptyString(route.topicId) ?? undefined,
|
|
272
|
+
routeName: asNonEmptyString(route.name) ?? undefined,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
export async function resolveOpsDestinationForEvent(ctx, config, event) {
|
|
278
|
+
// First try the cheap path: direct companyId match needs no extra lookup.
|
|
279
|
+
const direct = resolveTelegramOpsDestination(config.opsRoutes, event.companyId);
|
|
280
|
+
if (direct)
|
|
281
|
+
return direct;
|
|
282
|
+
// Fall back to companyName matching (routes configured by name), resolving the
|
|
283
|
+
// company lazily so we never pay the lookup when no name-based route exists.
|
|
284
|
+
if (!Array.isArray(config.opsRoutes) || config.opsRoutes.length === 0)
|
|
285
|
+
return null;
|
|
286
|
+
try {
|
|
287
|
+
const company = await ctx.companies.get(event.companyId);
|
|
288
|
+
return resolveTelegramOpsDestination(config.opsRoutes, event.companyId, company?.name ?? null);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function logSendToTelegramAttempt(ctx, runCtx, details) {
|
|
295
|
+
ctx.logger.info("Telegram agent send routing decision", {
|
|
296
|
+
companyId: runCtx.companyId,
|
|
297
|
+
agentId: runCtx.agentId,
|
|
298
|
+
issueId: asNonEmptyString(details.params.issueId) ?? undefined,
|
|
299
|
+
issueIdentifier: details.issueIdentifier ?? asNonEmptyString(details.params.issueIdentifier) ?? undefined,
|
|
300
|
+
projectKey: details.projectKey ?? asNonEmptyString(details.params.projectKey) ?? undefined,
|
|
301
|
+
routeSource: details.routeSource,
|
|
302
|
+
routeName: details.routeName,
|
|
303
|
+
chatId: details.chatId,
|
|
304
|
+
topicId: details.threadId,
|
|
305
|
+
contentMode: details.mode,
|
|
306
|
+
errorCode: details.errorCode,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
export async function sendToTelegramTool(ctx, token, config, params, runCtx) {
|
|
310
|
+
const p = isRecord(params) ? params : {};
|
|
311
|
+
if (findUnsupportedMarkdownSourceField(p)) {
|
|
312
|
+
const result = makeTelegramToolError("unsupported_file_source", "Only text and markdownContent are supported.");
|
|
313
|
+
return { content: JSON.stringify(result), data: result };
|
|
314
|
+
}
|
|
315
|
+
const text = asNonEmptyString(p.text);
|
|
316
|
+
const markdownContent = asNonEmptyString(p.markdownContent);
|
|
317
|
+
if (!text && !markdownContent) {
|
|
318
|
+
const result = makeTelegramToolError("missing_content", "At least one of text or markdownContent is required.");
|
|
319
|
+
return { content: JSON.stringify(result), data: result };
|
|
320
|
+
}
|
|
321
|
+
const explicitChatId = asNonEmptyString(p.chatId);
|
|
322
|
+
const threadId = validateOutboundThreadId(p.threadId);
|
|
323
|
+
if (threadId === "invalid") {
|
|
324
|
+
const result = makeTelegramToolError("invalid_thread", "threadId must be a positive integer.");
|
|
325
|
+
return { content: JSON.stringify(result), data: result };
|
|
326
|
+
}
|
|
327
|
+
const replyToMessageId = validateOutboundThreadId(p.replyToMessageId);
|
|
328
|
+
if (replyToMessageId === "invalid") {
|
|
329
|
+
const result = makeTelegramToolError("invalid_thread", "replyToMessageId must be a positive integer.");
|
|
330
|
+
return { content: JSON.stringify(result), data: result };
|
|
331
|
+
}
|
|
332
|
+
const parseMode = p.parseMode === "MarkdownV2" || p.parseMode === "HTML" ? p.parseMode : undefined;
|
|
333
|
+
const requestedMarkdownFileName = asNonEmptyString(p.markdownFileName) ?? DEFAULT_MARKDOWN_FILENAME;
|
|
334
|
+
const sessionId = asNonEmptyString(p.sessionId);
|
|
335
|
+
if (markdownContent && Buffer.byteLength(markdownContent, "utf-8") > MAX_OUTBOUND_MARKDOWN_BYTES) {
|
|
336
|
+
const result = makeTelegramToolError("markdown_too_large", "Markdown content exceeds size limits.");
|
|
337
|
+
return { content: JSON.stringify(result), data: result };
|
|
338
|
+
}
|
|
339
|
+
if (markdownContent && text && Buffer.byteLength(text, "utf-8") > MAX_MARKDOWN_CAPTION_BYTES) {
|
|
340
|
+
const result = makeTelegramToolError("caption_too_large", "Caption exceeds Telegram caption size limits.");
|
|
341
|
+
return { content: JSON.stringify(result), data: result };
|
|
342
|
+
}
|
|
343
|
+
if (markdownContent) {
|
|
344
|
+
const markdownFileValidation = validateMarkdownFilename(requestedMarkdownFileName);
|
|
345
|
+
if (!markdownFileValidation.ok) {
|
|
346
|
+
const result = makeTelegramToolError(markdownFileValidation.code, markdownFileValidation.message);
|
|
347
|
+
return { content: JSON.stringify(result), data: result };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const destination = markdownContent
|
|
351
|
+
? await resolveTelegramFileDestination(config.fileRoutes, {
|
|
352
|
+
explicitChatId,
|
|
353
|
+
explicitThreadId: typeof threadId === "number" ? threadId : undefined,
|
|
354
|
+
issueId: asNonEmptyString(p.issueId),
|
|
355
|
+
issueIdentifier: asNonEmptyString(p.issueIdentifier),
|
|
356
|
+
projectKey: asNonEmptyString(p.projectKey),
|
|
357
|
+
lookupIssueIdentifier: (issueId) => resolveIssueIdentifierForFileRoute(ctx, runCtx.companyId, issueId),
|
|
358
|
+
})
|
|
359
|
+
: null;
|
|
360
|
+
if (destination && !destination.ok) {
|
|
361
|
+
const result = makeTelegramToolError(destination.code, destination.message, {
|
|
362
|
+
projectKey: destination.projectKey,
|
|
363
|
+
issueIdentifier: destination.issueIdentifier,
|
|
364
|
+
});
|
|
365
|
+
await logSendToTelegramAttempt(ctx, runCtx, {
|
|
366
|
+
params: p,
|
|
367
|
+
mode: markdownContent ? "document" : "message",
|
|
368
|
+
routeSource: "file_route",
|
|
369
|
+
projectKey: destination.projectKey,
|
|
370
|
+
issueIdentifier: destination.issueIdentifier,
|
|
371
|
+
errorCode: destination.code,
|
|
372
|
+
});
|
|
373
|
+
return { content: JSON.stringify(result), data: result };
|
|
374
|
+
}
|
|
375
|
+
const routeSource = destination?.ok ? destination.source : explicitChatId ? "explicit" : "legacy_fallback";
|
|
376
|
+
const chatId = destination?.ok && destination.source === "file_route"
|
|
377
|
+
? destination.chatId
|
|
378
|
+
: explicitChatId ?? await resolveChat(ctx, runCtx.companyId, config.defaultChatId);
|
|
379
|
+
if (!chatId) {
|
|
380
|
+
const result = makeTelegramToolError("disallowed_chat", "No Telegram chat configured.");
|
|
381
|
+
return { content: JSON.stringify(result), data: result };
|
|
382
|
+
}
|
|
383
|
+
const allowedChatIds = Array.isArray(config.allowedTelegramChatIds)
|
|
384
|
+
? config.allowedTelegramChatIds.map(String).filter(Boolean)
|
|
385
|
+
: [];
|
|
386
|
+
if (explicitChatId) {
|
|
387
|
+
if (allowedChatIds.length === 0) {
|
|
388
|
+
const result = makeTelegramToolError("disallowed_chat", "Explicit Telegram chat IDs are not allowed.");
|
|
389
|
+
return { content: JSON.stringify(result), data: result };
|
|
390
|
+
}
|
|
391
|
+
if (!allowedChatIds.includes(explicitChatId)) {
|
|
392
|
+
const result = makeTelegramToolError("disallowed_chat", "Telegram chat is not allowed for agent outbound delivery.");
|
|
393
|
+
return { content: JSON.stringify(result), data: result };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const outboundThreadId = destination?.ok && destination.source === "file_route"
|
|
397
|
+
? destination.topicId
|
|
398
|
+
: threadId;
|
|
399
|
+
const result = markdownContent
|
|
400
|
+
? await sendDocument(ctx, token, chatId, markdownContent, {
|
|
401
|
+
filename: requestedMarkdownFileName,
|
|
402
|
+
caption: text ?? undefined,
|
|
403
|
+
parseMode,
|
|
404
|
+
messageThreadId: outboundThreadId,
|
|
405
|
+
replyToMessageId: typeof replyToMessageId === "number" ? replyToMessageId : undefined,
|
|
406
|
+
disableNotification: p.silent === true,
|
|
407
|
+
}).then((messageId) => {
|
|
408
|
+
if (!messageId) {
|
|
409
|
+
return makeTelegramToolError("telegram_send_failed", "Telegram send failed.");
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
mode: "document",
|
|
414
|
+
chatId,
|
|
415
|
+
threadId: outboundThreadId,
|
|
416
|
+
messageId,
|
|
417
|
+
routeSource,
|
|
418
|
+
routeName: destination?.ok ? destination.routeName : undefined,
|
|
419
|
+
projectKey: destination?.ok ? destination.projectKey : undefined,
|
|
420
|
+
issueIdentifier: destination?.ok ? destination.issueIdentifier : undefined,
|
|
421
|
+
};
|
|
422
|
+
})
|
|
423
|
+
: await sendMessage(ctx, token, chatId, text, {
|
|
424
|
+
parseMode,
|
|
425
|
+
messageThreadId: outboundThreadId,
|
|
426
|
+
replyToMessageId: typeof replyToMessageId === "number" ? replyToMessageId : undefined,
|
|
427
|
+
disableNotification: p.silent === true,
|
|
428
|
+
}).then((messageId) => {
|
|
429
|
+
if (!messageId) {
|
|
430
|
+
return makeTelegramToolError("telegram_send_failed", "Telegram send failed.");
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
ok: true,
|
|
434
|
+
mode: "message",
|
|
435
|
+
chatId,
|
|
436
|
+
threadId: outboundThreadId,
|
|
437
|
+
messageId,
|
|
438
|
+
routeSource,
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
if (!result.ok) {
|
|
442
|
+
await logSendToTelegramAttempt(ctx, runCtx, {
|
|
443
|
+
params: p,
|
|
444
|
+
mode: markdownContent ? "document" : "message",
|
|
445
|
+
chatId,
|
|
446
|
+
threadId: outboundThreadId,
|
|
447
|
+
routeSource,
|
|
448
|
+
routeName: destination?.ok ? destination.routeName : undefined,
|
|
449
|
+
projectKey: destination?.ok ? destination.projectKey : undefined,
|
|
450
|
+
issueIdentifier: destination?.ok ? destination.issueIdentifier : undefined,
|
|
451
|
+
errorCode: result.code,
|
|
452
|
+
});
|
|
453
|
+
return { content: JSON.stringify(result), data: result };
|
|
454
|
+
}
|
|
455
|
+
if (sessionId) {
|
|
456
|
+
await ctx.state.set({ scopeKind: "instance", stateKey: `agent_msg_${chatId}_${result.messageId}` }, { sessionId });
|
|
457
|
+
}
|
|
458
|
+
await ctx.activity.log({
|
|
459
|
+
companyId: runCtx.companyId,
|
|
460
|
+
message: "Agent sent content to Telegram",
|
|
461
|
+
entityType: "agent",
|
|
462
|
+
entityId: runCtx.agentId,
|
|
463
|
+
metadata: {
|
|
464
|
+
chatId,
|
|
465
|
+
threadId: outboundThreadId,
|
|
466
|
+
mode: result.mode,
|
|
467
|
+
messageId: result.messageId,
|
|
468
|
+
routeSource: result.routeSource,
|
|
469
|
+
routeName: destination?.ok ? destination.routeName : undefined,
|
|
470
|
+
projectKey: destination?.ok ? destination.projectKey : undefined,
|
|
471
|
+
issueId: asNonEmptyString(p.issueId) ?? undefined,
|
|
472
|
+
issueIdentifier: destination?.ok ? destination.issueIdentifier : asNonEmptyString(p.issueIdentifier) ?? undefined,
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
return { content: JSON.stringify(result), data: result };
|
|
476
|
+
}
|
|
477
|
+
const plugin = definePlugin({
|
|
478
|
+
async setup(ctx) {
|
|
479
|
+
const rawConfig = await ctx.config.get();
|
|
480
|
+
ctx.logger.info("Telegram plugin config loaded");
|
|
481
|
+
const config = rawConfig;
|
|
482
|
+
const baseUrl = config.paperclipBaseUrl || "http://localhost:3100";
|
|
483
|
+
const publicUrl = config.paperclipPublicUrl || baseUrl;
|
|
484
|
+
ctx.data.register("board-access.read", async () => getBoardAccessRegistration(await loadBoardAccessState(ctx)));
|
|
485
|
+
ctx.actions.register("board-access.update", async (params) => {
|
|
486
|
+
const record = isRecord(params) ? params : {};
|
|
487
|
+
const paperclipBoardApiTokenRef = asNonEmptyString(record.paperclipBoardApiTokenRef);
|
|
488
|
+
const identity = asNonEmptyString(record.identity);
|
|
489
|
+
const companyId = asNonEmptyString(record.companyId);
|
|
490
|
+
const now = new Date().toISOString();
|
|
491
|
+
return persistBoardAccessState(ctx, {
|
|
492
|
+
paperclipBoardApiTokenRef,
|
|
493
|
+
identity,
|
|
494
|
+
companyId,
|
|
495
|
+
updatedAt: now,
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
if (!config.telegramBotTokenRef) {
|
|
499
|
+
ctx.logger.warn("No telegramBotTokenRef configured, plugin disabled");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const token = await ctx.secrets.resolve(config.telegramBotTokenRef);
|
|
503
|
+
// --- Agent file-send action (ant013 TEL-8 / TEL-23) ---
|
|
504
|
+
// Expose the send tool as a directly-invokable action so non-tool callers
|
|
505
|
+
// (smoke checks, other plugins) can route text/markdown to Telegram.
|
|
506
|
+
const runActionContext = (params) => ({
|
|
507
|
+
companyId: asNonEmptyString(params.companyId) ?? "system",
|
|
508
|
+
agentId: asNonEmptyString(params.agentId) ?? "system",
|
|
509
|
+
});
|
|
510
|
+
const invokeSendToTelegramAction = async (params) => {
|
|
511
|
+
const runCtx = runActionContext(params);
|
|
512
|
+
const result = await sendToTelegramTool(ctx, token, config, params, runCtx);
|
|
513
|
+
return { content: result.content, data: result.data };
|
|
514
|
+
};
|
|
515
|
+
ctx.actions.register("send_to_telegram", (params) => invokeSendToTelegramAction(params));
|
|
516
|
+
ctx.actions.register("send_file_to_telegram", (params) => invokeSendToTelegramAction(params));
|
|
517
|
+
// --- Register bot commands with Telegram ---
|
|
518
|
+
if (config.enableCommands) {
|
|
519
|
+
const allCommands = [
|
|
520
|
+
...BOT_COMMANDS,
|
|
521
|
+
{ command: "commands", description: "Manage custom workflow commands" },
|
|
522
|
+
];
|
|
523
|
+
// Non-blocking init: don't hold up worker initialize on external API.
|
|
524
|
+
// The host's worker-init RPC timeout is 15s; if api.telegram.org is
|
|
525
|
+
// slow/unreachable, awaiting this call causes the worker to be SIGKILLed
|
|
526
|
+
// before setup() completes. Fire-and-forget matches pollUpdates() below.
|
|
527
|
+
setMyCommands(ctx, token, allCommands)
|
|
528
|
+
.then((registered) => {
|
|
529
|
+
if (registered) {
|
|
530
|
+
ctx.logger.info("Bot commands registered with Telegram");
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
.catch((err) => {
|
|
534
|
+
ctx.logger.error("Failed to register bot commands", {
|
|
535
|
+
error: String(err),
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
// --- Long polling for inbound messages ---
|
|
540
|
+
let pollingActive = true;
|
|
541
|
+
let lastUpdateId = await getPersistedTelegramUpdateOffset(ctx);
|
|
542
|
+
async function pollUpdates() {
|
|
543
|
+
while (pollingActive) {
|
|
544
|
+
try {
|
|
545
|
+
const res = await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/getUpdates?offset=${lastUpdateId + 1}&timeout=10&allowed_updates=["message","callback_query"]`, { method: "GET" });
|
|
546
|
+
const data = (await res.json());
|
|
547
|
+
if (data.ok && data.result) {
|
|
548
|
+
lastUpdateId = await processTelegramUpdateBatch({
|
|
549
|
+
updates: data.result,
|
|
550
|
+
lastUpdateId,
|
|
551
|
+
handleUpdate: (update) => handleUpdate(ctx, token, config, update, baseUrl, publicUrl),
|
|
552
|
+
persistOffset: (updateId) => persistTelegramUpdateOffset(ctx, updateId),
|
|
553
|
+
logger: ctx.logger,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
ctx.logger.error("Telegram polling error", { error: String(err) });
|
|
559
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (config.enableCommands || config.enableInbound) {
|
|
564
|
+
pollUpdates().catch((err) => ctx.logger.error("Polling loop crashed", { error: String(err) }));
|
|
565
|
+
}
|
|
566
|
+
ctx.events.on("plugin.stopping", async () => {
|
|
567
|
+
pollingActive = false;
|
|
568
|
+
});
|
|
569
|
+
// --- Phase 2: ACP output listener (cross-plugin events) ---
|
|
570
|
+
setupAcpOutputListener(ctx, token);
|
|
571
|
+
// --- Event subscriptions ---
|
|
572
|
+
const issuePrefixCache = new Map();
|
|
573
|
+
async function resolveIssueLinksOpts(companyId) {
|
|
574
|
+
let prefix = issuePrefixCache.get(companyId);
|
|
575
|
+
if (!prefix) {
|
|
576
|
+
const company = await ctx.companies.get(companyId);
|
|
577
|
+
prefix = company?.issuePrefix ?? "";
|
|
578
|
+
if (prefix)
|
|
579
|
+
issuePrefixCache.set(companyId, prefix);
|
|
580
|
+
}
|
|
581
|
+
return { baseUrl: publicUrl, issuePrefix: prefix || undefined };
|
|
582
|
+
}
|
|
583
|
+
const notify = async (event, formatter, overrideChatId, overrideTopicId, deliveryKey) => {
|
|
584
|
+
const chatId = await resolveChat(ctx, event.companyId, overrideChatId || config.defaultChatId);
|
|
585
|
+
if (!chatId)
|
|
586
|
+
return;
|
|
587
|
+
const linksOpts = await resolveIssueLinksOpts(event.companyId);
|
|
588
|
+
const msg = formatter(event, linksOpts);
|
|
589
|
+
let messageThreadId = parseTopicId(overrideTopicId);
|
|
590
|
+
if (!messageThreadId) {
|
|
591
|
+
messageThreadId = await resolveNotificationThreadId(ctx, chatId, event, config.topicRouting);
|
|
592
|
+
}
|
|
593
|
+
if (messageThreadId) {
|
|
594
|
+
msg.options.messageThreadId = messageThreadId;
|
|
595
|
+
}
|
|
596
|
+
// Issue threading — if we've already sent a message for this entity in this
|
|
597
|
+
// chat+topic, reply to that anchor so all updates about a single entity stack
|
|
598
|
+
// as one Telegram thread on mobile (created → comments → done).
|
|
599
|
+
const anchorKey = event.entityId
|
|
600
|
+
? `anchor_${chatId}_${event.entityType}_${event.entityId}`
|
|
601
|
+
: null;
|
|
602
|
+
if (anchorKey) {
|
|
603
|
+
const anchor = (await ctx.state.get({
|
|
604
|
+
scopeKind: "instance",
|
|
605
|
+
stateKey: anchorKey,
|
|
606
|
+
}));
|
|
607
|
+
// Only thread when targeting the same topic — Telegram rejects cross-topic replies.
|
|
608
|
+
if (anchor?.messageId && anchor.messageThreadId === messageThreadId) {
|
|
609
|
+
msg.options.replyToMessageId = anchor.messageId;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Durable idempotency guard: claim a delivery slot before sending so a
|
|
613
|
+
// duplicate event re-emission or a retried worker run cannot send the
|
|
614
|
+
// same notification twice. A failed send releases the claim so a later
|
|
615
|
+
// retry can re-attempt. Keyed by chat+topic+logical-event so the same
|
|
616
|
+
// entity notified in different chats/topics is not falsely suppressed.
|
|
617
|
+
const effectiveDeliveryKey = buildDeliveryKey(chatId, messageThreadId ?? "", deliveryKey ?? `${event.eventType}:${event.entityId ?? event.eventId}`);
|
|
618
|
+
const messageId = await withIdempotentDelivery(ctx, effectiveDeliveryKey, () => sendMessage(ctx, token, chatId, msg.text, msg.options));
|
|
619
|
+
if (messageId) {
|
|
620
|
+
await ctx.state.set({
|
|
621
|
+
scopeKind: "instance",
|
|
622
|
+
stateKey: `msg_${chatId}_${messageId}`,
|
|
623
|
+
}, {
|
|
624
|
+
entityId: event.entityId,
|
|
625
|
+
entityType: event.entityType,
|
|
626
|
+
companyId: event.companyId,
|
|
627
|
+
eventType: event.eventType,
|
|
628
|
+
});
|
|
629
|
+
await ctx.activity.log({
|
|
630
|
+
companyId: event.companyId,
|
|
631
|
+
message: `Forwarded ${event.eventType} to Telegram`,
|
|
632
|
+
entityType: "plugin",
|
|
633
|
+
entityId: event.entityId,
|
|
634
|
+
});
|
|
635
|
+
// First-message-per-entity: store the anchor so future notifications about the
|
|
636
|
+
// same entity reply to this one. Never overwritten — the first message stays root.
|
|
637
|
+
if (anchorKey) {
|
|
638
|
+
const existing = (await ctx.state.get({
|
|
639
|
+
scopeKind: "instance",
|
|
640
|
+
stateKey: anchorKey,
|
|
641
|
+
}));
|
|
642
|
+
if (!existing) {
|
|
643
|
+
await ctx.state.set({ scopeKind: "instance", stateKey: anchorKey }, { messageId, messageThreadId });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
if (config.notifyOnIssueCreated) {
|
|
649
|
+
ctx.events.on("issue.created", (event) => notify(event, formatIssueCreated));
|
|
650
|
+
}
|
|
651
|
+
if (config.notifyOnIssueDone) {
|
|
652
|
+
const doneDedupe = makeUpdateDedupe();
|
|
653
|
+
ctx.events.on("issue.updated", async (event) => {
|
|
654
|
+
const payload = event.payload;
|
|
655
|
+
if (payload.status !== "done")
|
|
656
|
+
return;
|
|
657
|
+
if (!doneDedupe(`done|${event.entityId}`))
|
|
658
|
+
return;
|
|
659
|
+
// Enrich with title if missing (issue.updated events often omit it)
|
|
660
|
+
if (!payload.title && event.entityId) {
|
|
661
|
+
try {
|
|
662
|
+
const issue = await ctx.issues.get(event.entityId, event.companyId);
|
|
663
|
+
if (issue)
|
|
664
|
+
payload.title = issue.title;
|
|
665
|
+
}
|
|
666
|
+
catch { /* best effort */ }
|
|
667
|
+
}
|
|
668
|
+
// Enrich with latest comment (completion summary)
|
|
669
|
+
if (!payload.comment && event.entityId) {
|
|
670
|
+
try {
|
|
671
|
+
const comments = await ctx.issues.listComments(event.entityId, event.companyId);
|
|
672
|
+
if (comments.length > 0) {
|
|
673
|
+
const latest = comments.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
|
|
674
|
+
payload.comment = latest.body;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
catch { /* best effort */ }
|
|
678
|
+
}
|
|
679
|
+
await notify(event, formatIssueDone, undefined, undefined, `done|${event.entityId}`);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
if (config.notifyOnIssueAssigned) {
|
|
683
|
+
const assignmentDedupe = makeUpdateDedupe();
|
|
684
|
+
ctx.events.on("issue.updated", async (event) => {
|
|
685
|
+
const payload = event.payload;
|
|
686
|
+
const prev = payload._previous ?? {};
|
|
687
|
+
const userChanged = "assigneeUserId" in payload && payload.assigneeUserId !== prev.assigneeUserId;
|
|
688
|
+
const agentChanged = "assigneeAgentId" in payload && payload.assigneeAgentId !== prev.assigneeAgentId;
|
|
689
|
+
if (!userChanged && !agentChanged)
|
|
690
|
+
return;
|
|
691
|
+
if (config.onlyNotifyIfAssignedTo && payload.assigneeUserId !== config.onlyNotifyIfAssignedTo) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const dedupeKey = [
|
|
695
|
+
"assigned",
|
|
696
|
+
event.entityId,
|
|
697
|
+
String(prev.assigneeUserId ?? ""),
|
|
698
|
+
String(payload.assigneeUserId ?? ""),
|
|
699
|
+
String(prev.assigneeAgentId ?? ""),
|
|
700
|
+
String(payload.assigneeAgentId ?? ""),
|
|
701
|
+
].join("|");
|
|
702
|
+
if (!assignmentDedupe(dedupeKey))
|
|
703
|
+
return;
|
|
704
|
+
if ((!payload.title || !payload.assigneeName) && event.entityId) {
|
|
705
|
+
try {
|
|
706
|
+
const issue = await ctx.issues.get(event.entityId, event.companyId);
|
|
707
|
+
if (issue) {
|
|
708
|
+
payload.title ??= issue.title;
|
|
709
|
+
const name = issue.assigneeName;
|
|
710
|
+
if (name)
|
|
711
|
+
payload.assigneeName ??= name;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
catch { /* best effort */ }
|
|
715
|
+
}
|
|
716
|
+
await notify(event, formatIssueAssigned, undefined, undefined, dedupeKey);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
if (config.notifyOnApprovalCreated) {
|
|
720
|
+
ctx.events.on("approval.created", async (event) => {
|
|
721
|
+
if (!shouldNotifyApproval(event, config.onlyNotifyBoardApprovals))
|
|
722
|
+
return;
|
|
723
|
+
const payload = event.payload;
|
|
724
|
+
// Enrich with linked issue details (event only has issueIds)
|
|
725
|
+
const issueIds = Array.isArray(payload.issueIds) ? payload.issueIds : [];
|
|
726
|
+
if (issueIds.length > 0 && !payload.linkedIssues) {
|
|
727
|
+
try {
|
|
728
|
+
const issues = await Promise.all(issueIds.slice(0, 5).map((id) => ctx.issues.get(id, event.companyId)));
|
|
729
|
+
payload.linkedIssues = issues
|
|
730
|
+
.filter(Boolean)
|
|
731
|
+
.map((i) => ({
|
|
732
|
+
identifier: i.identifier,
|
|
733
|
+
title: i.title,
|
|
734
|
+
status: i.status,
|
|
735
|
+
priority: i.priority,
|
|
736
|
+
}));
|
|
737
|
+
// Use first issue's title as the approval title if missing
|
|
738
|
+
if (!payload.title && issues[0]) {
|
|
739
|
+
payload.title = issues[0].identifier
|
|
740
|
+
? `${issues[0].identifier}: ${issues[0].title}`
|
|
741
|
+
: issues[0].title;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
catch { /* best effort */ }
|
|
745
|
+
}
|
|
746
|
+
// Enrich agent name
|
|
747
|
+
if (payload.agentId && !payload.agentName) {
|
|
748
|
+
try {
|
|
749
|
+
const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
|
|
750
|
+
if (agent)
|
|
751
|
+
payload.agentName = agent.name;
|
|
752
|
+
}
|
|
753
|
+
catch { /* best effort */ }
|
|
754
|
+
}
|
|
755
|
+
// Build a meaningful title if still missing
|
|
756
|
+
if (!payload.title || payload.title === "Approval Requested") {
|
|
757
|
+
const approvalType = String(payload.type ?? "unknown").replace(/_/g, " ");
|
|
758
|
+
const agentLabel = payload.agentName ? String(payload.agentName) : null;
|
|
759
|
+
payload.title = agentLabel
|
|
760
|
+
? `${approvalType} — ${agentLabel}`
|
|
761
|
+
: approvalType;
|
|
762
|
+
}
|
|
763
|
+
await notify(event, formatApprovalCreated, config.approvalsChatId, config.approvalsTopicId);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (config.notifyOnAgentError) {
|
|
767
|
+
const agentErrorDedupe = makeUpdateDedupe(AGENT_ERROR_DEDUPLICATION_WINDOW_MS, 1000);
|
|
768
|
+
ctx.events.on("agent.run.failed", async (event) => {
|
|
769
|
+
const payload = event.payload;
|
|
770
|
+
const agentId = String(payload.agentId ?? event.entityId);
|
|
771
|
+
if (payload.agentId && !payload.agentName) {
|
|
772
|
+
try {
|
|
773
|
+
const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
|
|
774
|
+
if (agent)
|
|
775
|
+
payload.agentName = agent.name;
|
|
776
|
+
}
|
|
777
|
+
catch { /* best effort */ }
|
|
778
|
+
}
|
|
779
|
+
if (!payload.companyName) {
|
|
780
|
+
try {
|
|
781
|
+
const company = await ctx.companies.get(event.companyId);
|
|
782
|
+
if (company?.name)
|
|
783
|
+
payload.companyName = company.name;
|
|
784
|
+
}
|
|
785
|
+
catch { /* best effort */ }
|
|
786
|
+
}
|
|
787
|
+
if (payload.issueId && (!payload.issueIdentifier || !payload.issueTitle)) {
|
|
788
|
+
try {
|
|
789
|
+
const issue = await ctx.issues.get(String(payload.issueId), event.companyId);
|
|
790
|
+
if (issue) {
|
|
791
|
+
payload.issueIdentifier ??= issue.identifier;
|
|
792
|
+
payload.issueTitle ??= issue.title;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
catch { /* best effort */ }
|
|
796
|
+
}
|
|
797
|
+
const errorMessage = normalizeAgentErrorMessage(payload.error ?? payload.message);
|
|
798
|
+
const dedupeKey = ["agent.run.failed", event.companyId, agentId, errorMessage].join(":");
|
|
799
|
+
if (!agentErrorDedupe(dedupeKey))
|
|
800
|
+
return;
|
|
801
|
+
await notify(event, formatAgentError, config.errorsChatId, config.errorsTopicId, dedupeKey);
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
const enrichAgentName = async (event) => {
|
|
805
|
+
const payload = event.payload;
|
|
806
|
+
if (payload.agentId && !payload.agentName) {
|
|
807
|
+
try {
|
|
808
|
+
const agent = await ctx.agents.get(String(payload.agentId), event.companyId);
|
|
809
|
+
if (agent)
|
|
810
|
+
payload.agentName = agent.name;
|
|
811
|
+
}
|
|
812
|
+
catch { /* best effort */ }
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
// Ops events route to a dedicated ops chat when an ops route matches the
|
|
816
|
+
// company; otherwise they fall back to the normal default-chat routing.
|
|
817
|
+
const notifyOps = async (event, formatter) => {
|
|
818
|
+
const opsDestination = await resolveOpsDestinationForEvent(ctx, config, event);
|
|
819
|
+
if (opsDestination) {
|
|
820
|
+
ctx.logger.info("Telegram ops notification routed", {
|
|
821
|
+
eventType: event.eventType,
|
|
822
|
+
companyId: event.companyId,
|
|
823
|
+
routeName: opsDestination.routeName,
|
|
824
|
+
chatId: opsDestination.chatId,
|
|
825
|
+
topicId: opsDestination.topicId,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
await notify(event, formatter, opsDestination?.chatId, opsDestination?.topicId);
|
|
829
|
+
};
|
|
830
|
+
if (config.notifyOnAgentRunStarted) {
|
|
831
|
+
ctx.events.on("agent.run.started", async (event) => {
|
|
832
|
+
await enrichAgentName(event);
|
|
833
|
+
await notifyOps(event, formatAgentRunStarted);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
if (config.notifyOnAgentRunFinished) {
|
|
837
|
+
ctx.events.on("agent.run.finished", async (event) => {
|
|
838
|
+
await enrichAgentName(event);
|
|
839
|
+
await notifyOps(event, formatAgentRunFinished);
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
// --- Anti-flood filters (TWB-94, tue-Jonas/paperclip-plugin-telegram @03b6e99) ---
|
|
843
|
+
// Forward issue.updated as a "blocked" notification only when the issue is
|
|
844
|
+
// genuinely blocked AND a human/board user owns it (assigneeUserId non-null).
|
|
845
|
+
if (config.notifyOnIssueBlocked) {
|
|
846
|
+
const blockedDedupe = makeUpdateDedupe();
|
|
847
|
+
ctx.events.on("issue.updated", async (event) => {
|
|
848
|
+
const payload = event.payload;
|
|
849
|
+
// Cheap pre-gate so we never fetch the issue for non-blocked updates.
|
|
850
|
+
if (payload.status !== "blocked")
|
|
851
|
+
return;
|
|
852
|
+
if (!blockedDedupe(`blocked|${event.entityId}`))
|
|
853
|
+
return;
|
|
854
|
+
// issue.updated payloads frequently omit assignee/title, so enrich from
|
|
855
|
+
// the issue record before deciding whether a human/board user owns it.
|
|
856
|
+
if (event.entityId) {
|
|
857
|
+
try {
|
|
858
|
+
const issue = await ctx.issues.get(event.entityId, event.companyId);
|
|
859
|
+
if (issue) {
|
|
860
|
+
const anyIssue = issue;
|
|
861
|
+
if (payload.assigneeUserId == null && anyIssue.assigneeUserId != null) {
|
|
862
|
+
payload.assigneeUserId = anyIssue.assigneeUserId;
|
|
863
|
+
}
|
|
864
|
+
if (!payload.assigneeName && anyIssue.assigneeName) {
|
|
865
|
+
payload.assigneeName = anyIssue.assigneeName;
|
|
866
|
+
}
|
|
867
|
+
if (!payload.title && issue.title)
|
|
868
|
+
payload.title = issue.title;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
catch { /* best effort */ }
|
|
872
|
+
}
|
|
873
|
+
// Skip agent-only blocks (no human/board assignee). The rule itself lives
|
|
874
|
+
// in shouldNotifyIssueBlocked and now sees the enriched assigneeUserId.
|
|
875
|
+
if (!shouldNotifyIssueBlocked(event, true))
|
|
876
|
+
return;
|
|
877
|
+
// Enrich with latest comment (likely the blocker reason)
|
|
878
|
+
if (!payload.comment && event.entityId) {
|
|
879
|
+
try {
|
|
880
|
+
const comments = await ctx.issues.listComments(event.entityId, event.companyId);
|
|
881
|
+
if (comments.length > 0) {
|
|
882
|
+
const latest = comments.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
|
|
883
|
+
payload.comment = latest.body;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
catch { /* best effort */ }
|
|
887
|
+
}
|
|
888
|
+
await notify(event, formatIssueBlocked);
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
// Forward issue.comment.created only when a configured board username is
|
|
892
|
+
// @-mentioned (word-boundary aware, case-insensitive).
|
|
893
|
+
const boardUsernames = parseBoardUsernames(config.boardUsernames);
|
|
894
|
+
if (config.notifyOnBoardMention && boardUsernames.length > 0) {
|
|
895
|
+
ctx.events.on("issue.comment.created", async (event) => {
|
|
896
|
+
if (!shouldNotifyBoardMention(event, true, boardUsernames))
|
|
897
|
+
return;
|
|
898
|
+
const payload = event.payload;
|
|
899
|
+
// Enrich with issue identifier/title for a useful link + heading
|
|
900
|
+
const issueId = payload.issueId ??
|
|
901
|
+
payload.issueIdentifier ??
|
|
902
|
+
undefined;
|
|
903
|
+
if (issueId && (!payload.identifier || !payload.title)) {
|
|
904
|
+
try {
|
|
905
|
+
const issue = await ctx.issues.get(String(issueId), event.companyId);
|
|
906
|
+
if (issue) {
|
|
907
|
+
payload.identifier ??= issue.identifier;
|
|
908
|
+
payload.title ??= issue.title;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
catch { /* best effort */ }
|
|
912
|
+
}
|
|
913
|
+
await notify(event, formatBoardMention);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
// --- Per-company chat overrides ---
|
|
917
|
+
ctx.data.register("chat-mapping", async (params) => {
|
|
918
|
+
const companyId = String(params.companyId);
|
|
919
|
+
const saved = await ctx.state.get({
|
|
920
|
+
scopeKind: "company",
|
|
921
|
+
scopeId: companyId,
|
|
922
|
+
stateKey: "telegram-chat",
|
|
923
|
+
});
|
|
924
|
+
return { chatId: saved ?? config.defaultChatId };
|
|
925
|
+
});
|
|
926
|
+
ctx.actions.register("set-chat", async (params) => {
|
|
927
|
+
const companyId = String(params.companyId);
|
|
928
|
+
const chatId = String(params.chatId);
|
|
929
|
+
await ctx.state.set({ scopeKind: "company", scopeId: companyId, stateKey: "telegram-chat" }, chatId);
|
|
930
|
+
ctx.logger.info("Updated Telegram chat mapping", { companyId, chatId });
|
|
931
|
+
return { ok: true };
|
|
932
|
+
});
|
|
933
|
+
// --- Daily digest job ---
|
|
934
|
+
// Support legacy dailyDigestEnabled boolean
|
|
935
|
+
const effectiveDigestMode = config.dailyDigestEnabled === true && config.digestMode === "off"
|
|
936
|
+
? "daily"
|
|
937
|
+
: config.digestMode ?? "off";
|
|
938
|
+
if (effectiveDigestMode !== "off") {
|
|
939
|
+
ctx.jobs.register("telegram-daily-digest", async () => {
|
|
940
|
+
// Check if current UTC hour matches a configured digest time
|
|
941
|
+
const nowHour = new Date().getUTCHours();
|
|
942
|
+
const nowMin = new Date().getUTCMinutes();
|
|
943
|
+
if (nowMin >= 5)
|
|
944
|
+
return; // only fire within first 5 min of the hour
|
|
945
|
+
const parseHour = (t) => {
|
|
946
|
+
const [h] = (t || "").split(":");
|
|
947
|
+
return parseInt(h ?? "", 10);
|
|
948
|
+
};
|
|
949
|
+
const firstHour = parseHour(config.dailyDigestTime);
|
|
950
|
+
const secondHour = parseHour(config.bidailySecondTime);
|
|
951
|
+
const tridailyHours = (config.tridailyTimes || "07:00,13:00,19:00")
|
|
952
|
+
.split(",")
|
|
953
|
+
.map((t) => parseHour(t.trim()));
|
|
954
|
+
let shouldSend = false;
|
|
955
|
+
if (effectiveDigestMode === "daily") {
|
|
956
|
+
shouldSend = nowHour === firstHour;
|
|
957
|
+
}
|
|
958
|
+
else if (effectiveDigestMode === "bidaily") {
|
|
959
|
+
shouldSend = nowHour === firstHour || nowHour === secondHour;
|
|
960
|
+
}
|
|
961
|
+
else if (effectiveDigestMode === "tridaily") {
|
|
962
|
+
shouldSend = tridailyHours.includes(nowHour);
|
|
963
|
+
}
|
|
964
|
+
if (!shouldSend)
|
|
965
|
+
return;
|
|
966
|
+
const companies = await ctx.companies.list();
|
|
967
|
+
for (const company of companies) {
|
|
968
|
+
const chatId = await resolveChat(ctx, company.id, config.digestChatId || config.defaultChatId);
|
|
969
|
+
if (!chatId)
|
|
970
|
+
continue;
|
|
971
|
+
try {
|
|
972
|
+
const agents = await ctx.agents.list({ companyId: company.id });
|
|
973
|
+
const activeAgents = agents.filter((a) => a.status === "active");
|
|
974
|
+
const issues = await ctx.issues.list({ companyId: company.id, limit: 50 });
|
|
975
|
+
const now = Date.now();
|
|
976
|
+
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
977
|
+
const completedToday = issues.filter((i) => i.status === "done" && i.completedAt && (now - new Date(i.completedAt).getTime()) < oneDayMs);
|
|
978
|
+
const createdToday = issues.filter((i) => (now - new Date(i.createdAt).getTime()) < oneDayMs);
|
|
979
|
+
const issuePrefix = company.issuePrefix;
|
|
980
|
+
const inProgress = issues.filter((i) => i.status === "in_progress");
|
|
981
|
+
const inReview = issues.filter((i) => i.status === "in_review");
|
|
982
|
+
const blocked = issues.filter((i) => i.status === "blocked");
|
|
983
|
+
const dateStr = new Date().toISOString().split("T")[0];
|
|
984
|
+
const companyLabel = company.name ? ` \\- ${escapeMarkdownV2(company.name)}` : "";
|
|
985
|
+
const digestLabel = effectiveDigestMode === "bidaily" ? "Digest" : "Daily Digest";
|
|
986
|
+
const lines = [
|
|
987
|
+
escapeMarkdownV2("\ud83d\udcca") + ` *${escapeMarkdownV2(digestLabel)}${companyLabel} \\- ${escapeMarkdownV2(dateStr)}*`,
|
|
988
|
+
"",
|
|
989
|
+
`${escapeMarkdownV2("\u2705")} Tasks completed: *${completedToday.length}*`,
|
|
990
|
+
`${escapeMarkdownV2("\ud83d\udccb")} Tasks created: *${createdToday.length}*`,
|
|
991
|
+
`${escapeMarkdownV2("\ud83e\udd16")} Active agents: *${activeAgents.length}*/${escapeMarkdownV2(String(agents.length))}`,
|
|
992
|
+
];
|
|
993
|
+
if (activeAgents.length > 0) {
|
|
994
|
+
const topAgent = activeAgents[0].name;
|
|
995
|
+
lines.push(`${escapeMarkdownV2("\u2b50")} Top performer: *${escapeMarkdownV2(topAgent)}*`);
|
|
996
|
+
}
|
|
997
|
+
const formatIssueItem = (i) => {
|
|
998
|
+
const id = i.identifier ?? i.id;
|
|
999
|
+
const idText = issuePrefix
|
|
1000
|
+
? `[${escapeMarkdownV2(id)}](${publicUrl}/${issuePrefix}/issues/${id})`
|
|
1001
|
+
: escapeMarkdownV2(id);
|
|
1002
|
+
return ` ${idText} \\- ${escapeMarkdownV2(i.title)}`;
|
|
1003
|
+
};
|
|
1004
|
+
if (inProgress.length > 0) {
|
|
1005
|
+
lines.push("", `${escapeMarkdownV2("\ud83d\udd04")} *In Progress \\(${inProgress.length}\\)*`);
|
|
1006
|
+
for (const i of inProgress.slice(0, 10))
|
|
1007
|
+
lines.push(formatIssueItem(i));
|
|
1008
|
+
}
|
|
1009
|
+
if (inReview.length > 0) {
|
|
1010
|
+
lines.push("", `${escapeMarkdownV2("\ud83d\udd0d")} *In Review \\(${inReview.length}\\)*`);
|
|
1011
|
+
for (const i of inReview.slice(0, 10))
|
|
1012
|
+
lines.push(formatIssueItem(i));
|
|
1013
|
+
}
|
|
1014
|
+
if (blocked.length > 0) {
|
|
1015
|
+
lines.push("", `${escapeMarkdownV2("\ud83d\udeab")} *Blocked \\(${blocked.length}\\)*`);
|
|
1016
|
+
for (const i of blocked.slice(0, 10))
|
|
1017
|
+
lines.push(formatIssueItem(i));
|
|
1018
|
+
}
|
|
1019
|
+
const digestThreadId = await resolveDigestThreadId(ctx, token, chatId, config.digestTopicId);
|
|
1020
|
+
await sendMessage(ctx, token, chatId, lines.join("\n"), {
|
|
1021
|
+
parseMode: "MarkdownV2",
|
|
1022
|
+
messageThreadId: digestThreadId,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
catch (err) {
|
|
1026
|
+
ctx.logger.error("Daily digest failed for company", { companyId: company.id, error: String(err) });
|
|
1027
|
+
const text = [
|
|
1028
|
+
escapeMarkdownV2("\ud83d\udcca") + " *Daily Digest*",
|
|
1029
|
+
"",
|
|
1030
|
+
escapeMarkdownV2("Could not generate digest. Check plugin logs for details."),
|
|
1031
|
+
].join("\n");
|
|
1032
|
+
const errorThreadId = await resolveDigestThreadId(ctx, token, chatId, config.errorsTopicId || config.digestTopicId);
|
|
1033
|
+
await sendMessage(ctx, token, chatId, text, {
|
|
1034
|
+
parseMode: "MarkdownV2",
|
|
1035
|
+
messageThreadId: errorThreadId,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
// --- Phase 1: Escalation support ---
|
|
1042
|
+
const escalationManager = new EscalationManager();
|
|
1043
|
+
// Register escalate_to_human tool - 3-arg signature with ToolRunContext
|
|
1044
|
+
ctx.tools.register("escalate_to_human", {
|
|
1045
|
+
displayName: "Escalate to Human",
|
|
1046
|
+
description: "Escalate a conversation to a human when you cannot handle it confidently",
|
|
1047
|
+
parametersSchema: {
|
|
1048
|
+
type: "object",
|
|
1049
|
+
properties: {
|
|
1050
|
+
reason: {
|
|
1051
|
+
type: "string",
|
|
1052
|
+
enum: ["low_confidence", "explicit_request", "policy_violation", "unknown_intent"],
|
|
1053
|
+
description: "Why this conversation needs human attention",
|
|
1054
|
+
},
|
|
1055
|
+
conversationSummary: {
|
|
1056
|
+
type: "string",
|
|
1057
|
+
description: "Brief summary of the conversation context and what the user needs",
|
|
1058
|
+
},
|
|
1059
|
+
suggestedActions: {
|
|
1060
|
+
type: "array",
|
|
1061
|
+
items: { type: "string" },
|
|
1062
|
+
description: "Suggested actions the human responder could take",
|
|
1063
|
+
},
|
|
1064
|
+
suggestedReply: {
|
|
1065
|
+
type: "string",
|
|
1066
|
+
description: "A draft reply the human can send or modify",
|
|
1067
|
+
},
|
|
1068
|
+
confidenceScore: {
|
|
1069
|
+
type: "number",
|
|
1070
|
+
minimum: 0,
|
|
1071
|
+
maximum: 1,
|
|
1072
|
+
description: "How confident the agent is (0-1). Lower values indicate greater need for human help",
|
|
1073
|
+
},
|
|
1074
|
+
originChatId: { type: "string" },
|
|
1075
|
+
originThreadId: { type: "string" },
|
|
1076
|
+
originMessageId: { type: "string" },
|
|
1077
|
+
sessionId: { type: "string", description: "Session ID for routing reply back" },
|
|
1078
|
+
transport: { type: "string", enum: ["native", "acp"], description: "Transport type for reply routing" },
|
|
1079
|
+
},
|
|
1080
|
+
required: ["reason", "conversationSummary"],
|
|
1081
|
+
},
|
|
1082
|
+
}, async (params, runCtx) => {
|
|
1083
|
+
const p = params;
|
|
1084
|
+
const escalationId = crypto.randomUUID();
|
|
1085
|
+
const timeoutMs = config.escalationTimeoutMs || 900000;
|
|
1086
|
+
const defaultAction = config.escalationDefaultAction || "defer";
|
|
1087
|
+
const resolvedEscalationChatId = await resolveChat(ctx, runCtx.companyId, config.escalationChatId);
|
|
1088
|
+
if (!resolvedEscalationChatId) {
|
|
1089
|
+
ctx.logger.warn("Escalation received but no escalationChatId configured");
|
|
1090
|
+
return { error: "No escalation channel configured" };
|
|
1091
|
+
}
|
|
1092
|
+
const escalationEvent = {
|
|
1093
|
+
escalationId,
|
|
1094
|
+
agentId: runCtx.agentId,
|
|
1095
|
+
companyId: runCtx.companyId,
|
|
1096
|
+
reason: p.reason,
|
|
1097
|
+
context: {
|
|
1098
|
+
conversationHistory: [],
|
|
1099
|
+
agentReasoning: String(p.conversationSummary ?? ""),
|
|
1100
|
+
suggestedActions: p.suggestedActions ?? [],
|
|
1101
|
+
suggestedReply: p.suggestedReply ? String(p.suggestedReply) : undefined,
|
|
1102
|
+
confidenceScore: typeof p.confidenceScore === "number" ? p.confidenceScore : undefined,
|
|
1103
|
+
},
|
|
1104
|
+
timeout: {
|
|
1105
|
+
durationMs: timeoutMs,
|
|
1106
|
+
defaultAction,
|
|
1107
|
+
},
|
|
1108
|
+
originChatId: p.originChatId ? String(p.originChatId) : undefined,
|
|
1109
|
+
originThreadId: p.originThreadId ? String(p.originThreadId) : undefined,
|
|
1110
|
+
originMessageId: p.originMessageId ? String(p.originMessageId) : undefined,
|
|
1111
|
+
transport: p.transport,
|
|
1112
|
+
sessionId: p.sessionId ? String(p.sessionId) : undefined,
|
|
1113
|
+
};
|
|
1114
|
+
await escalationManager.create(ctx, token, escalationEvent, resolvedEscalationChatId);
|
|
1115
|
+
// Send hold message to the originating chat if configured
|
|
1116
|
+
if (config.escalationHoldMessage && escalationEvent.originChatId) {
|
|
1117
|
+
const holdText = escapeMarkdownV2(config.escalationHoldMessage);
|
|
1118
|
+
await sendMessage(ctx, token, escalationEvent.originChatId, holdText, {
|
|
1119
|
+
parseMode: "MarkdownV2",
|
|
1120
|
+
messageThreadId: escalationEvent.originThreadId ? Number(escalationEvent.originThreadId) : undefined,
|
|
1121
|
+
replyToMessageId: escalationEvent.originMessageId ? Number(escalationEvent.originMessageId) : undefined,
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
return { content: JSON.stringify({ status: "escalated", escalationId }) };
|
|
1125
|
+
});
|
|
1126
|
+
// --- Phase 2: Register handoff_to_agent tool ---
|
|
1127
|
+
ctx.tools.register("handoff_to_agent", {
|
|
1128
|
+
displayName: "Handoff to Agent",
|
|
1129
|
+
description: "Hand off work to another agent in this thread",
|
|
1130
|
+
parametersSchema: {
|
|
1131
|
+
type: "object",
|
|
1132
|
+
properties: {
|
|
1133
|
+
targetAgent: { type: "string", description: "Name of agent to hand off to" },
|
|
1134
|
+
reason: { type: "string", description: "Why you're handing off" },
|
|
1135
|
+
contextSummary: { type: "string", description: "Summary for the target agent" },
|
|
1136
|
+
requiresApproval: { type: "boolean", default: true, description: "Wait for human approval before target starts" },
|
|
1137
|
+
chatId: { type: "string", description: "Telegram chat ID" },
|
|
1138
|
+
threadId: { type: "number", description: "Telegram thread ID" },
|
|
1139
|
+
},
|
|
1140
|
+
required: ["targetAgent", "reason", "contextSummary"],
|
|
1141
|
+
},
|
|
1142
|
+
}, async (params, runCtx) => {
|
|
1143
|
+
return handleHandoffToolCall(ctx, token, params, runCtx.companyId, runCtx.agentId);
|
|
1144
|
+
});
|
|
1145
|
+
// --- Phase 2: Register discuss_with_agent tool ---
|
|
1146
|
+
ctx.tools.register("discuss_with_agent", {
|
|
1147
|
+
displayName: "Discuss with Agent",
|
|
1148
|
+
description: "Start a back-and-forth conversation with another agent",
|
|
1149
|
+
parametersSchema: {
|
|
1150
|
+
type: "object",
|
|
1151
|
+
properties: {
|
|
1152
|
+
targetAgent: { type: "string", description: "Name of agent to discuss with" },
|
|
1153
|
+
topic: { type: "string", description: "Discussion topic" },
|
|
1154
|
+
initialMessage: { type: "string", description: "First message to send" },
|
|
1155
|
+
maxTurns: { type: "number", default: 10, description: "Maximum conversation turns" },
|
|
1156
|
+
humanCheckpointAt: { type: "number", description: "Pause for human approval at this turn" },
|
|
1157
|
+
chatId: { type: "string", description: "Telegram chat ID" },
|
|
1158
|
+
threadId: { type: "number", description: "Telegram thread ID" },
|
|
1159
|
+
},
|
|
1160
|
+
required: ["targetAgent", "topic", "initialMessage"],
|
|
1161
|
+
},
|
|
1162
|
+
}, async (params, runCtx) => {
|
|
1163
|
+
return handleDiscussToolCall(ctx, token, params, runCtx.companyId, runCtx.agentId);
|
|
1164
|
+
});
|
|
1165
|
+
// --- Agent file-send tool (ant013 TEL-8 / TEL-23) ---
|
|
1166
|
+
const sendToTelegram = (params, runCtx) => sendToTelegramTool(ctx, token, config, params, runCtx);
|
|
1167
|
+
const sendToTelegramParametersSchema = {
|
|
1168
|
+
type: "object",
|
|
1169
|
+
properties: {
|
|
1170
|
+
chatId: {
|
|
1171
|
+
type: "string",
|
|
1172
|
+
description: "Telegram chat ID. Defaults to the configured company chat when omitted.",
|
|
1173
|
+
},
|
|
1174
|
+
threadId: {
|
|
1175
|
+
type: "number",
|
|
1176
|
+
description: "Optional Telegram forum topic ID.",
|
|
1177
|
+
},
|
|
1178
|
+
text: {
|
|
1179
|
+
type: "string",
|
|
1180
|
+
description: "Text message or Markdown caption if markdownContent is used.",
|
|
1181
|
+
},
|
|
1182
|
+
markdownContent: {
|
|
1183
|
+
type: "string",
|
|
1184
|
+
description: "Markdown document content for upload.",
|
|
1185
|
+
},
|
|
1186
|
+
markdownFileName: {
|
|
1187
|
+
type: "string",
|
|
1188
|
+
description: "Optional .md filename when markdownContent is provided.",
|
|
1189
|
+
},
|
|
1190
|
+
projectKey: {
|
|
1191
|
+
type: "string",
|
|
1192
|
+
description: "Optional Paperclip project key for Markdown document file routing, such as TEL.",
|
|
1193
|
+
},
|
|
1194
|
+
issueIdentifier: {
|
|
1195
|
+
type: "string",
|
|
1196
|
+
description: "Optional Paperclip issue key for Markdown document file routing, such as TEL-8.",
|
|
1197
|
+
},
|
|
1198
|
+
issueId: {
|
|
1199
|
+
type: "string",
|
|
1200
|
+
description: "Optional Paperclip issue ID used to resolve a project-key file route.",
|
|
1201
|
+
},
|
|
1202
|
+
parseMode: {
|
|
1203
|
+
type: "string",
|
|
1204
|
+
enum: ["MarkdownV2", "HTML"],
|
|
1205
|
+
description: "Optional parse mode for text/caption.",
|
|
1206
|
+
},
|
|
1207
|
+
replyToMessageId: {
|
|
1208
|
+
type: "number",
|
|
1209
|
+
description: "Optional Telegram message ID to reply to.",
|
|
1210
|
+
},
|
|
1211
|
+
silent: {
|
|
1212
|
+
type: "boolean",
|
|
1213
|
+
description: "Send without notification.",
|
|
1214
|
+
},
|
|
1215
|
+
sessionId: {
|
|
1216
|
+
type: "string",
|
|
1217
|
+
description: "Optional Paperclip session ID for routing Telegram replies back to the agent session.",
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
anyOf: [{ required: ["text"] }, { required: ["markdownContent"] }],
|
|
1221
|
+
};
|
|
1222
|
+
ctx.tools.register("send_to_telegram", {
|
|
1223
|
+
displayName: "Send Telegram Message",
|
|
1224
|
+
description: "Send text and Markdown content to Telegram.",
|
|
1225
|
+
parametersSchema: sendToTelegramParametersSchema,
|
|
1226
|
+
}, (params, runCtx) => sendToTelegram(params, runCtx));
|
|
1227
|
+
// Keep the previous tool name as a compatibility alias.
|
|
1228
|
+
ctx.tools.register("send_file_to_telegram", {
|
|
1229
|
+
displayName: "Send File to Telegram",
|
|
1230
|
+
description: "Deprecated: send text and Markdown content to Telegram.",
|
|
1231
|
+
parametersSchema: sendToTelegramParametersSchema,
|
|
1232
|
+
}, (params, runCtx) => sendToTelegram(params, runCtx));
|
|
1233
|
+
// --- Phase 5: Register register_watch tool ---
|
|
1234
|
+
ctx.tools.register("register_watch", {
|
|
1235
|
+
displayName: "Register Watch",
|
|
1236
|
+
description: "Register a proactive watch that monitors entities and sends suggestions",
|
|
1237
|
+
parametersSchema: {
|
|
1238
|
+
type: "object",
|
|
1239
|
+
properties: {
|
|
1240
|
+
name: { type: "string", description: "Name of the watch" },
|
|
1241
|
+
description: { type: "string", description: "What this watch monitors" },
|
|
1242
|
+
entityType: { type: "string", enum: ["issue", "agent", "company", "custom"], description: "Type of entity to watch" },
|
|
1243
|
+
conditions: {
|
|
1244
|
+
type: "array",
|
|
1245
|
+
items: {
|
|
1246
|
+
type: "object",
|
|
1247
|
+
properties: {
|
|
1248
|
+
field: { type: "string" },
|
|
1249
|
+
operator: { type: "string", enum: ["gt", "lt", "eq", "ne", "contains", "exists"] },
|
|
1250
|
+
value: {},
|
|
1251
|
+
},
|
|
1252
|
+
required: ["field", "operator", "value"],
|
|
1253
|
+
},
|
|
1254
|
+
description: "Conditions that trigger the watch",
|
|
1255
|
+
},
|
|
1256
|
+
template: { type: "string", description: "Message template with {{field}} placeholders" },
|
|
1257
|
+
builtinTemplate: { type: "string", enum: ["invoice-overdue", "lead-stale"], description: "Use a built-in template instead" },
|
|
1258
|
+
chatId: { type: "string", description: "Telegram chat ID for suggestions" },
|
|
1259
|
+
threadId: { type: "number", description: "Telegram thread ID for suggestions" },
|
|
1260
|
+
},
|
|
1261
|
+
required: ["chatId"],
|
|
1262
|
+
},
|
|
1263
|
+
}, async (params, runCtx) => {
|
|
1264
|
+
return handleRegisterWatch(ctx, params, runCtx.companyId);
|
|
1265
|
+
});
|
|
1266
|
+
// --- Phase 1: Escalation timeout checker job ---
|
|
1267
|
+
ctx.jobs.register("check-escalation-timeouts", async () => {
|
|
1268
|
+
try {
|
|
1269
|
+
await escalationManager.checkTimeouts(ctx, token);
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
ctx.logger.error("Escalation timeout check failed", { error: String(err) });
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
// --- Phase 5: Watch checker job ---
|
|
1276
|
+
ctx.jobs.register("check-watches", async () => {
|
|
1277
|
+
try {
|
|
1278
|
+
await checkWatches(ctx, token, {
|
|
1279
|
+
maxSuggestionsPerHourPerCompany: config.maxSuggestionsPerHourPerCompany ?? 10,
|
|
1280
|
+
watchDeduplicationWindowMs: config.watchDeduplicationWindowMs ?? 86400000,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
ctx.logger.error("Watch check failed", { error: String(err) });
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
ctx.logger.info("Telegram bot plugin started (Chat OS v2 - all 5 phases)");
|
|
1288
|
+
},
|
|
1289
|
+
async onValidateConfig(config) {
|
|
1290
|
+
const secretRefErrors = validateSecretRefFields(config);
|
|
1291
|
+
if (secretRefErrors.length > 0) {
|
|
1292
|
+
return { ok: false, errors: secretRefErrors };
|
|
1293
|
+
}
|
|
1294
|
+
const allowlistErrors = validateTelegramAllowlists(config);
|
|
1295
|
+
if (allowlistErrors.length > 0) {
|
|
1296
|
+
return { ok: false, errors: allowlistErrors };
|
|
1297
|
+
}
|
|
1298
|
+
const topicErrors = validateConfiguredTopicIds(config);
|
|
1299
|
+
if (topicErrors.length > 0) {
|
|
1300
|
+
return { ok: false, errors: topicErrors };
|
|
1301
|
+
}
|
|
1302
|
+
const fileRouteErrors = getTelegramFileRouteSaveErrors(config.fileRoutes);
|
|
1303
|
+
if (fileRouteErrors.length > 0) {
|
|
1304
|
+
return { ok: false, errors: fileRouteErrors };
|
|
1305
|
+
}
|
|
1306
|
+
return { ok: true };
|
|
1307
|
+
},
|
|
1308
|
+
async onHealth() {
|
|
1309
|
+
return { status: "ok" };
|
|
1310
|
+
},
|
|
1311
|
+
});
|
|
1312
|
+
async function handleUpdate(ctx, token, config, update, baseUrl, publicUrl, boardApiToken) {
|
|
1313
|
+
if (!isTelegramUpdateAllowed(config, update)) {
|
|
1314
|
+
const fromId = update.message?.from?.id ?? update.callback_query?.from.id;
|
|
1315
|
+
const chatId = update.message?.chat.id ?? update.callback_query?.message?.chat.id;
|
|
1316
|
+
ctx.logger.warn("Blocked unauthorized Telegram update", {
|
|
1317
|
+
updateId: update.update_id,
|
|
1318
|
+
fromId,
|
|
1319
|
+
chatId,
|
|
1320
|
+
});
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (update.callback_query) {
|
|
1324
|
+
const companyId = await resolveCallbackCompanyId(ctx, update.callback_query);
|
|
1325
|
+
const boardApiToken = await resolveBoardApiToken(ctx, config, companyId);
|
|
1326
|
+
await handleCallbackQuery(ctx, token, update.callback_query, baseUrl, boardApiToken);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const msg = update.message;
|
|
1330
|
+
if (!msg)
|
|
1331
|
+
return;
|
|
1332
|
+
const chatId = String(msg.chat.id);
|
|
1333
|
+
const threadId = msg.message_thread_id;
|
|
1334
|
+
// Phase 3: Handle media messages
|
|
1335
|
+
const hasMedia = !!(msg.voice || msg.audio || msg.video_note || msg.document || msg.photo);
|
|
1336
|
+
if (hasMedia) {
|
|
1337
|
+
const companyId = await resolveCompanyId(ctx, chatId);
|
|
1338
|
+
const handled = await handleMediaMessage(ctx, token, msg, {
|
|
1339
|
+
briefAgentId: config.briefAgentId ?? "",
|
|
1340
|
+
briefAgentChatIds: config.briefAgentChatIds ?? [],
|
|
1341
|
+
transcriptionApiKeyRef: config.transcriptionApiKeyRef ?? "",
|
|
1342
|
+
publicUrl,
|
|
1343
|
+
}, companyId);
|
|
1344
|
+
if (handled)
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
if (!msg.text)
|
|
1348
|
+
return;
|
|
1349
|
+
const text = msg.text;
|
|
1350
|
+
// Route thread messages to agent sessions
|
|
1351
|
+
if (threadId) {
|
|
1352
|
+
const isCommand = text.startsWith("/");
|
|
1353
|
+
if (!isCommand) {
|
|
1354
|
+
const companyId = await resolveCompanyId(ctx, chatId);
|
|
1355
|
+
const replyToId = msg.reply_to_message?.message_id;
|
|
1356
|
+
const routed = await routeMessageToAgent(ctx, token, chatId, threadId, text, replyToId, companyId);
|
|
1357
|
+
if (routed)
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const botCommand = msg.entities?.find((e) => e.type === "bot_command" && e.offset === 0);
|
|
1362
|
+
if (botCommand && config.enableCommands) {
|
|
1363
|
+
const fullCommand = text.slice(botCommand.offset, botCommand.offset + botCommand.length);
|
|
1364
|
+
const command = fullCommand.replace(/^\//, "").replace(/@.*$/, "");
|
|
1365
|
+
const args = text.slice(botCommand.offset + botCommand.length).trim();
|
|
1366
|
+
const companyId = await resolveCompanyId(ctx, chatId);
|
|
1367
|
+
// Phase 4: Check custom commands first
|
|
1368
|
+
if (command === "commands") {
|
|
1369
|
+
await handleCommandsCommand(ctx, token, chatId, args, threadId, companyId);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const handledCustom = await tryCustomCommand(ctx, token, chatId, command, args, threadId, companyId);
|
|
1373
|
+
if (handledCustom)
|
|
1374
|
+
return;
|
|
1375
|
+
// Built-in commands
|
|
1376
|
+
const boardApiToken = command === "approve" ? await resolveBoardApiToken(ctx, config, companyId) : undefined;
|
|
1377
|
+
await handleCommand(ctx, token, chatId, command, args, threadId, baseUrl, publicUrl, companyId, boardApiToken, config.maxAgentsPerThread);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
if (config.enableInbound && msg.reply_to_message?.from?.is_bot) {
|
|
1381
|
+
const replyToId = msg.reply_to_message.message_id;
|
|
1382
|
+
const mapping = await ctx.state.get({
|
|
1383
|
+
scopeKind: "instance",
|
|
1384
|
+
stateKey: `msg_${chatId}_${replyToId}`,
|
|
1385
|
+
});
|
|
1386
|
+
if (mapping && mapping.entityType === "escalation") {
|
|
1387
|
+
const escalationManager = new EscalationManager();
|
|
1388
|
+
const responderId = `telegram:${msg.from?.username ?? msg.from?.id ?? chatId}`;
|
|
1389
|
+
await escalationManager.respond(ctx, token, mapping.entityId, {
|
|
1390
|
+
escalationId: mapping.entityId,
|
|
1391
|
+
responderId,
|
|
1392
|
+
responseText: text,
|
|
1393
|
+
action: "reply_to_customer",
|
|
1394
|
+
});
|
|
1395
|
+
await ctx.metrics.write(METRIC_NAMES.inboundRouted, 1);
|
|
1396
|
+
ctx.logger.info("Routed Telegram reply to escalation", {
|
|
1397
|
+
escalationId: mapping.entityId,
|
|
1398
|
+
from: msg.from?.username,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
else if (mapping && mapping.entityType === "issue") {
|
|
1402
|
+
try {
|
|
1403
|
+
// Use the SDK (not ctx.http.fetch) because the plugin sandbox blocks
|
|
1404
|
+
// outbound fetches to private IPs like 127.0.0.1 for SSRF protection.
|
|
1405
|
+
// The SDK's createComment goes through the plugin RPC bridge instead.
|
|
1406
|
+
await ctx.issues.createComment(mapping.entityId, text, mapping.companyId);
|
|
1407
|
+
await ctx.metrics.write(METRIC_NAMES.inboundRouted, 1);
|
|
1408
|
+
ctx.logger.info("Routed Telegram reply to issue comment", {
|
|
1409
|
+
issueId: mapping.entityId,
|
|
1410
|
+
from: msg.from?.username,
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
catch (err) {
|
|
1414
|
+
ctx.logger.error("Failed to route inbound message", {
|
|
1415
|
+
issueId: mapping.entityId,
|
|
1416
|
+
error: String(err),
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Graceful fallback for a decision-button callback whose underlying approval is
|
|
1424
|
+
* no longer actionable (TWX-328: stale inline-button presses). When the failure
|
|
1425
|
+
* is an "already resolved/decided" conflict — the approval was decided through
|
|
1426
|
+
* another channel, expired, or already resolved in the opposite direction — we
|
|
1427
|
+
* acknowledge it quietly and update the card instead of surfacing a raw API
|
|
1428
|
+
* error string to the board member. Any other error still surfaces as a failure.
|
|
1429
|
+
*/
|
|
1430
|
+
async function handleDecisionCallbackError(ctx, token, query, chatId, messageId, decision, err) {
|
|
1431
|
+
if (isAlreadyResolvedConflict(err)) {
|
|
1432
|
+
ctx.logger.info("Ignored stale Telegram decision callback", {
|
|
1433
|
+
kind: decision.kind,
|
|
1434
|
+
id: decision.id,
|
|
1435
|
+
actor: decision.actor,
|
|
1436
|
+
});
|
|
1437
|
+
await answerCallbackQuery(ctx, token, query.id, "Already resolved");
|
|
1438
|
+
if (chatId && messageId) {
|
|
1439
|
+
await editMessage(ctx, token, chatId, messageId, escapeMarkdownV2("This decision was already resolved."), { parseMode: "MarkdownV2" });
|
|
1440
|
+
}
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
await answerCallbackQuery(ctx, token, query.id, `Failed: ${String(err)}`);
|
|
1444
|
+
}
|
|
1445
|
+
export async function handleCallbackQuery(ctx, token, query, baseUrl, boardApiToken) {
|
|
1446
|
+
const data = query.data;
|
|
1447
|
+
if (!data)
|
|
1448
|
+
return;
|
|
1449
|
+
const actor = query.from.username ?? query.from.first_name ?? String(query.from.id);
|
|
1450
|
+
const chatId = query.message?.chat.id ? String(query.message.chat.id) : null;
|
|
1451
|
+
const messageId = query.message?.message_id;
|
|
1452
|
+
if (data.startsWith("approve_")) {
|
|
1453
|
+
const approvalId = data.replace("approve_", "");
|
|
1454
|
+
ctx.logger.info("Approval button clicked", { approvalId, actor });
|
|
1455
|
+
try {
|
|
1456
|
+
await fetchPaperclipApi(ctx, `${baseUrl}/api/approvals/${approvalId}/approve`, {
|
|
1457
|
+
method: "POST",
|
|
1458
|
+
headers: {
|
|
1459
|
+
"Content-Type": "application/json",
|
|
1460
|
+
...buildPaperclipAuthHeaders(boardApiToken),
|
|
1461
|
+
},
|
|
1462
|
+
body: JSON.stringify({ decidedByUserId: `telegram:${actor}` }),
|
|
1463
|
+
});
|
|
1464
|
+
await answerCallbackQuery(ctx, token, query.id, "Approved");
|
|
1465
|
+
if (chatId && messageId) {
|
|
1466
|
+
await editMessage(ctx, token, chatId, messageId, formatResolvedDecision(query.message?.text, "approved", actor), { parseMode: "MarkdownV2" });
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
catch (err) {
|
|
1470
|
+
await handleDecisionCallbackError(ctx, token, query, chatId, messageId, { kind: "approval_approve", id: approvalId, actor }, err);
|
|
1471
|
+
}
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (data.startsWith("esc_")) {
|
|
1475
|
+
const parts = data.split("_");
|
|
1476
|
+
const action = parts[1] ?? "";
|
|
1477
|
+
const escalationId = parts.slice(2).join("_");
|
|
1478
|
+
const escalationManager = new EscalationManager();
|
|
1479
|
+
await escalationManager.handleCallback(ctx, token, action, escalationId, actor, query.id, chatId, messageId);
|
|
1480
|
+
await answerCallbackQuery(ctx, token, query.id, `Escalation: ${action}`);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (data.startsWith("reject_")) {
|
|
1484
|
+
const approvalId = data.replace("reject_", "");
|
|
1485
|
+
ctx.logger.info("Rejection button clicked", { approvalId, actor });
|
|
1486
|
+
try {
|
|
1487
|
+
await fetchPaperclipApi(ctx, `${baseUrl}/api/approvals/${approvalId}/reject`, {
|
|
1488
|
+
method: "POST",
|
|
1489
|
+
headers: {
|
|
1490
|
+
"Content-Type": "application/json",
|
|
1491
|
+
...buildPaperclipAuthHeaders(boardApiToken),
|
|
1492
|
+
},
|
|
1493
|
+
body: JSON.stringify({ decidedByUserId: `telegram:${actor}` }),
|
|
1494
|
+
});
|
|
1495
|
+
await answerCallbackQuery(ctx, token, query.id, "Rejected");
|
|
1496
|
+
if (chatId && messageId) {
|
|
1497
|
+
await editMessage(ctx, token, chatId, messageId, formatResolvedDecision(query.message?.text, "rejected", actor), { parseMode: "MarkdownV2" });
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
catch (err) {
|
|
1501
|
+
await handleDecisionCallbackError(ctx, token, query, chatId, messageId, { kind: "approval_reject", id: approvalId, actor }, err);
|
|
1502
|
+
}
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
if (data.startsWith("handoff_approve_")) {
|
|
1506
|
+
const handoffId = data.replace("handoff_approve_", "");
|
|
1507
|
+
await handleHandoffApproval(ctx, token, handoffId, actor, query.id, chatId, messageId);
|
|
1508
|
+
await answerCallbackQuery(ctx, token, query.id, "Handoff approved");
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (data.startsWith("handoff_reject_")) {
|
|
1512
|
+
const handoffId = data.replace("handoff_reject_", "");
|
|
1513
|
+
await handleHandoffRejection(ctx, token, handoffId, actor, query.id, chatId, messageId);
|
|
1514
|
+
await answerCallbackQuery(ctx, token, query.id, "Handoff rejected");
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
await answerCallbackQuery(ctx, token, query.id, "Unknown action");
|
|
1518
|
+
}
|
|
1519
|
+
runWorker(plugin, import.meta.url);
|
|
1520
|
+
//# sourceMappingURL=worker.js.map
|