@copilotkit/channels-telegram 0.0.3
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 +250 -0
- package/dist/adapter.d.ts +117 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +584 -0
- package/dist/built-in-context.d.ts +22 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +77 -0
- package/dist/built-in-tools.d.ts +23 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +46 -0
- package/dist/chunked-edit-stream.d.ts +70 -0
- package/dist/chunked-edit-stream.d.ts.map +1 -0
- package/dist/chunked-edit-stream.js +197 -0
- package/dist/conversation-store.d.ts +56 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +98 -0
- package/dist/download-files.d.ts +68 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +157 -0
- package/dist/event-renderer.d.ts +39 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +295 -0
- package/dist/format-fallback.d.ts +6 -0
- package/dist/format-fallback.d.ts.map +1 -0
- package/dist/format-fallback.js +21 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/interaction.d.ts +65 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +159 -0
- package/dist/listener.d.ts +16 -0
- package/dist/listener.d.ts.map +1 -0
- package/dist/listener.js +419 -0
- package/dist/render/budget.d.ts +18 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +24 -0
- package/dist/render/telegram.d.ts +11 -0
- package/dist/render/telegram.d.ts.map +1 -0
- package/dist/render/telegram.js +429 -0
- package/dist/telegram-html.d.ts +22 -0
- package/dist/telegram-html.d.ts.map +1 -0
- package/dist/telegram-html.js +98 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +57 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound file transport. Telegram messages can carry file attachments; this
|
|
3
|
+
* turns them into AG-UI multimodal message content the agent's model can
|
|
4
|
+
* read — images, audio, video, and documents as their respective binary parts —
|
|
5
|
+
* downloading each from the Telegram Bot API file endpoint.
|
|
6
|
+
*
|
|
7
|
+
* The bridge is transport-only: it delivers the bytes to the agent and lets
|
|
8
|
+
* the app decide what to do with them. Anything it can't represent is skipped
|
|
9
|
+
* with a short note so the agent knows a file was dropped and why.
|
|
10
|
+
*/
|
|
11
|
+
/** Redact all occurrences of `token` from `msg` so the bot secret never leaks into notes. */
|
|
12
|
+
function redactToken(msg, token) {
|
|
13
|
+
if (!token)
|
|
14
|
+
return msg;
|
|
15
|
+
return msg.split(token).join("<redacted>");
|
|
16
|
+
}
|
|
17
|
+
const DEFAULTS = {
|
|
18
|
+
maxBytesPerFile: 8 * 1024 * 1024,
|
|
19
|
+
maxFiles: 5,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The AG-UI media part type that carries this MIME, or "document" as fallback.
|
|
23
|
+
* Images/audio/video go by their top-level type; everything else is document.
|
|
24
|
+
*/
|
|
25
|
+
function mediaPartType(mime) {
|
|
26
|
+
if (mime.startsWith("image/"))
|
|
27
|
+
return "image";
|
|
28
|
+
if (mime.startsWith("audio/"))
|
|
29
|
+
return "audio";
|
|
30
|
+
if (mime.startsWith("video/"))
|
|
31
|
+
return "video";
|
|
32
|
+
return "document";
|
|
33
|
+
}
|
|
34
|
+
/** Non-`text/*` MIME types that are still UTF-8 text the model should read. */
|
|
35
|
+
const TEXT_APP_TYPES = new Set([
|
|
36
|
+
"application/json",
|
|
37
|
+
"application/ld+json",
|
|
38
|
+
"application/xml",
|
|
39
|
+
"application/csv",
|
|
40
|
+
"application/x-ndjson",
|
|
41
|
+
"application/yaml",
|
|
42
|
+
"application/x-yaml",
|
|
43
|
+
"application/javascript",
|
|
44
|
+
"application/sql",
|
|
45
|
+
"application/toml",
|
|
46
|
+
]);
|
|
47
|
+
/** Filename extensions that indicate text content when the MIME is unhelpful. */
|
|
48
|
+
const TEXT_EXTENSIONS = /\.(csv|tsv|txt|md|markdown|json|ndjson|xml|ya?ml|log|html?|js|ts|css|sql|ini|toml|tex)$/i;
|
|
49
|
+
/** Cap decoded text so a large upload can't blow the model's context window. */
|
|
50
|
+
const MAX_TEXT_CHARS = 200_000;
|
|
51
|
+
/**
|
|
52
|
+
* Whether a file is UTF-8 text the model should read inline (CSV/JSON/XML/plain
|
|
53
|
+
* text/…) rather than a binary attachment. Most models reject these as binary
|
|
54
|
+
* "file" parts (e.g. "media type text/csv not supported"), and the agent wants
|
|
55
|
+
* to read/parse the content anyway (e.g. parse a CSV → render_chart).
|
|
56
|
+
*/
|
|
57
|
+
function isTextLike(mime, fileName) {
|
|
58
|
+
if (mime.startsWith("text/"))
|
|
59
|
+
return true;
|
|
60
|
+
if (TEXT_APP_TYPES.has(mime))
|
|
61
|
+
return true;
|
|
62
|
+
if (fileName && TEXT_EXTENSIONS.test(fileName))
|
|
63
|
+
return true;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Download a message's files and turn them into AG-UI content parts. Returns
|
|
68
|
+
* the parts plus human-readable `notes` for anything skipped (appended to the
|
|
69
|
+
* message as a text part by the caller).
|
|
70
|
+
*
|
|
71
|
+
* @param files List of Telegram file references to process.
|
|
72
|
+
* @param token Telegram bot token used to build the download URL.
|
|
73
|
+
* @param getFilePath Resolves a fileId to a Telegram file path (e.g. via getFile API).
|
|
74
|
+
* @param config Optional delivery configuration.
|
|
75
|
+
*/
|
|
76
|
+
export async function buildFileContentParts(files, token, getFilePath, config = {}) {
|
|
77
|
+
const maxBytes = config.maxBytesPerFile ?? DEFAULTS.maxBytesPerFile;
|
|
78
|
+
const maxFiles = config.maxFiles ?? DEFAULTS.maxFiles;
|
|
79
|
+
const parts = [];
|
|
80
|
+
const notes = [];
|
|
81
|
+
const considered = files.slice(0, maxFiles);
|
|
82
|
+
if (files.length > maxFiles) {
|
|
83
|
+
notes.push(`(only the first ${maxFiles} of ${files.length} files processed)`);
|
|
84
|
+
}
|
|
85
|
+
for (const f of considered) {
|
|
86
|
+
const label = f.fileName ?? f.fileId;
|
|
87
|
+
const mime = (f.mimeType ?? "application/octet-stream").toLowerCase();
|
|
88
|
+
if (typeof f.size === "number" && f.size > maxBytes) {
|
|
89
|
+
notes.push(`skipped "${label}": ${f.size} bytes too large (cap is ${maxBytes} bytes)`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let filePath;
|
|
93
|
+
try {
|
|
94
|
+
filePath = await getFilePath(f.fileId);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
98
|
+
notes.push(`skipped "${label}": could not resolve file path — ${redactToken(msg, token)}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
|
|
102
|
+
let bytes;
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(url);
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
notes.push(`skipped "${label}": download failed (HTTP ${res.status})`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// Pre-check Content-Length to avoid buffering oversized responses entirely
|
|
110
|
+
// into memory (memory-DoS guard for cases where f.size is absent, e.g. photos).
|
|
111
|
+
const contentLength = res.headers.get("content-length");
|
|
112
|
+
if (contentLength !== null) {
|
|
113
|
+
const declared = parseInt(contentLength, 10);
|
|
114
|
+
if (!isNaN(declared) && declared > maxBytes) {
|
|
115
|
+
notes.push(`skipped "${label}": Content-Length ${declared} bytes too large (cap is ${maxBytes} bytes)`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
bytes = Buffer.from(await res.arrayBuffer());
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
123
|
+
notes.push(`skipped "${label}": ${redactToken(msg, token)}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// Backstop: reject if actual body exceeds cap (covers responses without Content-Length).
|
|
127
|
+
if (bytes.byteLength > maxBytes) {
|
|
128
|
+
notes.push(`skipped "${label}": ${bytes.byteLength} bytes too large (cap is ${maxBytes} bytes)`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Text-like files become a decoded TEXT part (the model can't take them as
|
|
132
|
+
// binary "file" parts, and the agent wants to read/parse the content).
|
|
133
|
+
if (isTextLike(mime, f.fileName)) {
|
|
134
|
+
let text = bytes.toString("utf8");
|
|
135
|
+
let truncated = "";
|
|
136
|
+
if (text.length > MAX_TEXT_CHARS) {
|
|
137
|
+
truncated = ` [truncated to ${MAX_TEXT_CHARS} of ${text.length} chars]`;
|
|
138
|
+
text = text.slice(0, MAX_TEXT_CHARS);
|
|
139
|
+
}
|
|
140
|
+
parts.push({
|
|
141
|
+
type: "text",
|
|
142
|
+
text: `Attached file "${label}" (${mime})${truncated}:\n\n${text}`,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const partType = mediaPartType(mime);
|
|
147
|
+
parts.push({
|
|
148
|
+
type: partType,
|
|
149
|
+
source: {
|
|
150
|
+
type: "data",
|
|
151
|
+
value: bytes.toString("base64"),
|
|
152
|
+
mimeType: mime,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return { parts, notes };
|
|
157
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RunRenderer } from "@copilotkit/channels";
|
|
2
|
+
export interface CreateRunRendererArgs {
|
|
3
|
+
/** Posts a new Telegram message with placeholder text; resolves with its message id. */
|
|
4
|
+
postPlaceholder: (text: string) => Promise<number>;
|
|
5
|
+
/** Edits the Telegram message with the given id to contain `text`. */
|
|
6
|
+
editAt: (messageId: number, text: string) => Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Optional native typing indicator. When provided it is used in place of a
|
|
9
|
+
* posted `🔧 using <tool>…` status line on tool-call start.
|
|
10
|
+
*/
|
|
11
|
+
setTyping?: () => Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Custom-event names that should be treated as interrupts — captured for
|
|
14
|
+
* later dispatch to an `InterruptHandler`. Defaults to `on_interrupt` (the
|
|
15
|
+
* name LangGraph's AG-UI adapter emits).
|
|
16
|
+
*/
|
|
17
|
+
interruptEventNames?: ReadonlySet<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Whether tool calls should surface a `🔧 using <tool>…` → done status line.
|
|
20
|
+
* Defaults to `true`. Tool calls are ALWAYS captured regardless of this flag.
|
|
21
|
+
*/
|
|
22
|
+
showToolStatus?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Construct a {@link RunRenderer} for a single agent run on Telegram.
|
|
26
|
+
*
|
|
27
|
+
* Mirrors the Slack renderer's lifecycle, swapping Slack's `chat.update`
|
|
28
|
+
* transport for {@link ChunkedEditStream} (numeric message ids + telegram
|
|
29
|
+
* HTML transform) and Slack's composer status for `setTyping()` / a posted
|
|
30
|
+
* status line.
|
|
31
|
+
*
|
|
32
|
+
* The `subscriber` is passed to `runAgent`. After the run resolves, the
|
|
33
|
+
* run-loop reads `getCapturedToolCalls()` and `getPendingInterrupt()`.
|
|
34
|
+
* `markInterrupted()` finalises EVERY in-flight stream (so no in-flight
|
|
35
|
+
* placeholder is abandoned), appending an `_(interrupted)_` marker to any
|
|
36
|
+
* stream that has real partial content.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createRunRenderer(args: CreateRunRendererArgs): RunRenderer;
|
|
39
|
+
//# sourceMappingURL=event-renderer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-renderer.d.ts","sourceRoot":"","sources":["../src/event-renderer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EAGZ,MAAM,sBAAsB,CAAC;AAM9B,MAAM,WAAW,qBAAqB;IACpC,wFAAwF;IACxF,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACnD,sEAAsE;IACtE,MAAM,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC1C;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,GAAG,WAAW,CAsT1E"}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { ChunkedEditStream } from "./chunked-edit-stream.js";
|
|
2
|
+
import { telegramHtml } from "./telegram-html.js";
|
|
3
|
+
const INTERRUPTED_SUFFIX = "\n_(interrupted)_";
|
|
4
|
+
/**
|
|
5
|
+
* Construct a {@link RunRenderer} for a single agent run on Telegram.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the Slack renderer's lifecycle, swapping Slack's `chat.update`
|
|
8
|
+
* transport for {@link ChunkedEditStream} (numeric message ids + telegram
|
|
9
|
+
* HTML transform) and Slack's composer status for `setTyping()` / a posted
|
|
10
|
+
* status line.
|
|
11
|
+
*
|
|
12
|
+
* The `subscriber` is passed to `runAgent`. After the run resolves, the
|
|
13
|
+
* run-loop reads `getCapturedToolCalls()` and `getPendingInterrupt()`.
|
|
14
|
+
* `markInterrupted()` finalises EVERY in-flight stream (so no in-flight
|
|
15
|
+
* placeholder is abandoned), appending an `_(interrupted)_` marker to any
|
|
16
|
+
* stream that has real partial content.
|
|
17
|
+
*/
|
|
18
|
+
export function createRunRenderer(args) {
|
|
19
|
+
const interruptEventNames = args.interruptEventNames ?? new Set(["on_interrupt"]);
|
|
20
|
+
const showToolStatus = args.showToolStatus ?? true;
|
|
21
|
+
/** Per-AG-UI-message accumulated text (we accumulate deltas locally). */
|
|
22
|
+
const buffers = new Map();
|
|
23
|
+
/** Per-AG-UI-message text stream. Lazily created on first content. */
|
|
24
|
+
const streams = new Map();
|
|
25
|
+
/** Per-tool-call status message id so we can edit it on END. */
|
|
26
|
+
const toolStatusIds = new Map();
|
|
27
|
+
/**
|
|
28
|
+
* Once a stream has been finalised (via TEXT_MESSAGE_END or markInterrupted)
|
|
29
|
+
* we drop it. Late-arriving events for the same messageId are ignored.
|
|
30
|
+
*/
|
|
31
|
+
const finalised = new Set();
|
|
32
|
+
/**
|
|
33
|
+
* Set when the caller intentionally aborted the run (a new turn arrived for
|
|
34
|
+
* the same conversation). Suppresses the RUN_ERROR notice and stops
|
|
35
|
+
* accepting further AG-UI events — the `_(interrupted)_` marker conveys the
|
|
36
|
+
* state visually.
|
|
37
|
+
*/
|
|
38
|
+
let aborted = false;
|
|
39
|
+
/** Tool calls observed in this run, in event order. */
|
|
40
|
+
const capturedToolCalls = [];
|
|
41
|
+
/** Interrupt observed via a matching `onCustomEvent`; read after runAgent. */
|
|
42
|
+
let pendingInterrupt;
|
|
43
|
+
const ensureStream = (messageId) => {
|
|
44
|
+
if (finalised.has(messageId))
|
|
45
|
+
return undefined;
|
|
46
|
+
let s = streams.get(messageId);
|
|
47
|
+
if (!s) {
|
|
48
|
+
s = new ChunkedEditStream({
|
|
49
|
+
postPlaceholder: args.postPlaceholder,
|
|
50
|
+
editAt: args.editAt,
|
|
51
|
+
transform: telegramHtml,
|
|
52
|
+
});
|
|
53
|
+
streams.set(messageId, s);
|
|
54
|
+
}
|
|
55
|
+
return s;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* BEST-EFFORT cleanup of tool-status placeholders that were posted on START
|
|
59
|
+
* but never resolved by an END (the run ended/errored/was interrupted while
|
|
60
|
+
* the tool call was still in flight). Edits each remaining placeholder to a
|
|
61
|
+
* terminal marker so the stale `🔧 using <tool>…` line doesn't linger. This
|
|
62
|
+
* is not a hard guarantee: each `editAt` can fail (message too old, deleted,
|
|
63
|
+
* not-modified, flood-wait) and on failure it is caught + logged, NOT
|
|
64
|
+
* retried — so a placeholder can still end up stranded if Telegram rejects
|
|
65
|
+
* the edit. `terminal` is the marker each placeholder is edited to (e.g.
|
|
66
|
+
* `✅` on finish, `⚠️ … (cancelled)` on error/interrupt).
|
|
67
|
+
*/
|
|
68
|
+
const drainToolStatuses = async (terminal) => {
|
|
69
|
+
if (toolStatusIds.size === 0)
|
|
70
|
+
return;
|
|
71
|
+
const tasks = [];
|
|
72
|
+
for (const [toolCallId, messageId] of Array.from(toolStatusIds.entries())) {
|
|
73
|
+
const captured = capturedToolCalls.find((c) => c.toolCallId === toolCallId);
|
|
74
|
+
const toolName = captured?.toolCallName ?? "tool";
|
|
75
|
+
tasks.push(args
|
|
76
|
+
.editAt(messageId, telegramHtml(terminal(toolName)))
|
|
77
|
+
.catch((err) => console.error("[telegram-renderer] tool-status drain failed:", err)));
|
|
78
|
+
}
|
|
79
|
+
toolStatusIds.clear();
|
|
80
|
+
await Promise.all(tasks);
|
|
81
|
+
};
|
|
82
|
+
const captureToolCall = (toolCallId, toolCallName, toolCallArgs) => {
|
|
83
|
+
const existing = capturedToolCalls.find((c) => c.toolCallId === toolCallId);
|
|
84
|
+
if (existing) {
|
|
85
|
+
existing.toolCallName = toolCallName;
|
|
86
|
+
existing.toolCallArgs = toolCallArgs;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
capturedToolCalls.push({ toolCallId, toolCallName, toolCallArgs });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const subscriber = {
|
|
93
|
+
// ── 1. Text streaming ──────────────────────────────────────────────
|
|
94
|
+
async onTextMessageStartEvent({ event }) {
|
|
95
|
+
if (aborted)
|
|
96
|
+
return;
|
|
97
|
+
// A reused messageId (legal START→END→START across steps in some AG-UI
|
|
98
|
+
// adapters) must start fresh. The first message's stream may have eagerly
|
|
99
|
+
// posted a "…" placeholder and queued its content; FINISH it (flushing
|
|
100
|
+
// the first message into its placeholder) before resetting. Deleting it
|
|
101
|
+
// without finishing would silently lose the first message's content AND
|
|
102
|
+
// leave its placeholder orphaned in the chat forever.
|
|
103
|
+
const existing = streams.get(event.messageId);
|
|
104
|
+
if (existing) {
|
|
105
|
+
await existing
|
|
106
|
+
.finish()
|
|
107
|
+
.catch((e) => console.error("[bot-telegram] stream finalize failed:", e));
|
|
108
|
+
streams.delete(event.messageId);
|
|
109
|
+
}
|
|
110
|
+
// Clear the finalised guard and reset the buffer so ensureStream
|
|
111
|
+
// recreates a fresh stream for the second message's content.
|
|
112
|
+
finalised.delete(event.messageId);
|
|
113
|
+
buffers.set(event.messageId, "");
|
|
114
|
+
},
|
|
115
|
+
onTextMessageContentEvent({ event }) {
|
|
116
|
+
if (aborted)
|
|
117
|
+
return;
|
|
118
|
+
const next = (buffers.get(event.messageId) ?? "") + (event.delta ?? "");
|
|
119
|
+
buffers.set(event.messageId, next);
|
|
120
|
+
ensureStream(event.messageId)?.append(next);
|
|
121
|
+
},
|
|
122
|
+
onTextMessageEndEvent({ event }) {
|
|
123
|
+
if (aborted)
|
|
124
|
+
return;
|
|
125
|
+
// Keep the stream alive; the final flush happens in onRunFinishedEvent.
|
|
126
|
+
// Marking finalised here would reopen-guard a stream that may still
|
|
127
|
+
// receive its flush, so we only stop accepting NEW deltas.
|
|
128
|
+
finalised.add(event.messageId);
|
|
129
|
+
},
|
|
130
|
+
// ── 2. Tool-call surfacing + capture ──────────────────────────────
|
|
131
|
+
async onToolCallStartEvent({ event }) {
|
|
132
|
+
if (aborted)
|
|
133
|
+
return;
|
|
134
|
+
// Always capture — the run-loop filters by which tools are registered.
|
|
135
|
+
captureToolCall(event.toolCallId, event.toolCallName, {});
|
|
136
|
+
if (!showToolStatus)
|
|
137
|
+
return;
|
|
138
|
+
if (args.setTyping) {
|
|
139
|
+
await args.setTyping();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Dedup by toolCallId so a re-emitted START can't post a second line.
|
|
143
|
+
if (toolStatusIds.has(event.toolCallId))
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
const id = await args.postPlaceholder(telegramHtml(`🔧 using ${event.toolCallName}…`));
|
|
147
|
+
toolStatusIds.set(event.toolCallId, id);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.error("[telegram-renderer] tool-start post failed:", err);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
onToolCallArgsEvent({ event, toolCallName, partialToolCallArgs }) {
|
|
154
|
+
if (aborted)
|
|
155
|
+
return;
|
|
156
|
+
captureToolCall(event.toolCallId, toolCallName, (partialToolCallArgs ?? {}));
|
|
157
|
+
},
|
|
158
|
+
async onToolCallEndEvent({ event, toolCallName, toolCallArgs }) {
|
|
159
|
+
if (aborted)
|
|
160
|
+
return;
|
|
161
|
+
captureToolCall(event.toolCallId, toolCallName, (toolCallArgs ?? {}));
|
|
162
|
+
if (!showToolStatus)
|
|
163
|
+
return;
|
|
164
|
+
const id = toolStatusIds.get(event.toolCallId);
|
|
165
|
+
if (id === undefined)
|
|
166
|
+
return;
|
|
167
|
+
try {
|
|
168
|
+
await args.editAt(id, telegramHtml(`✅ ${toolCallName}`));
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
console.error("[telegram-renderer] tool-end edit failed:", err);
|
|
172
|
+
}
|
|
173
|
+
toolStatusIds.delete(event.toolCallId);
|
|
174
|
+
},
|
|
175
|
+
// ── 3. Interrupts (LangGraph `interrupt()` → AG-UI custom event) ─
|
|
176
|
+
onCustomEvent({ event }) {
|
|
177
|
+
if (aborted)
|
|
178
|
+
return;
|
|
179
|
+
const e = event;
|
|
180
|
+
if (!e.name || !interruptEventNames.has(e.name))
|
|
181
|
+
return;
|
|
182
|
+
// LangGraph's AG-UI adapter ships the interrupt value as a JSON string
|
|
183
|
+
// in some shapes (and as an object in others). Normalize here so
|
|
184
|
+
// downstream schema validation always sees the parsed shape.
|
|
185
|
+
let value = e.value;
|
|
186
|
+
if (typeof value === "string") {
|
|
187
|
+
try {
|
|
188
|
+
value = JSON.parse(value);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Leave it as a string — the handler's schema will reject it
|
|
192
|
+
// explicitly with a clearer error.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
pendingInterrupt = { eventName: e.name, value };
|
|
196
|
+
},
|
|
197
|
+
// ── 4. Run lifecycle: finalise streams ─────────────────────────────
|
|
198
|
+
async onRunFinishedEvent() {
|
|
199
|
+
if (aborted)
|
|
200
|
+
return;
|
|
201
|
+
const tasks = [];
|
|
202
|
+
for (const [id, stream] of Array.from(streams.entries())) {
|
|
203
|
+
// Best-effort: a terminal-edit rejection (message too old, deleted,
|
|
204
|
+
// not-modified, flood-wait) must not bubble into the AG-UI run loop.
|
|
205
|
+
tasks.push(stream
|
|
206
|
+
.finish()
|
|
207
|
+
.catch((e) => console.error("[bot-telegram] stream finalize failed:", e)));
|
|
208
|
+
streams.delete(id);
|
|
209
|
+
finalised.add(id);
|
|
210
|
+
}
|
|
211
|
+
buffers.clear();
|
|
212
|
+
await Promise.all(tasks);
|
|
213
|
+
// Drain any tool-status placeholders left over from a tool call whose
|
|
214
|
+
// END never arrived before the run finished — flip them to done so they
|
|
215
|
+
// aren't orphaned as a perpetual "🔧 using <tool>…".
|
|
216
|
+
await drainToolStatuses((tool) => `✅ ${tool}`);
|
|
217
|
+
},
|
|
218
|
+
// ── 5. Errors ─────────────────────────────────────────────────────
|
|
219
|
+
async onRunErrorEvent({ event }) {
|
|
220
|
+
// Don't post a notice if we're the ones aborting; the `_(interrupted)_`
|
|
221
|
+
// marker on the partial reply is the user-visible signal in that case.
|
|
222
|
+
if (aborted)
|
|
223
|
+
return;
|
|
224
|
+
// Drain any in-flight text streams so partial replies are finalized
|
|
225
|
+
// rather than left dangling (a _thinking…_ or partial message that
|
|
226
|
+
// never resolves). Mirror the flush from onRunFinishedEvent.
|
|
227
|
+
const tasks = [];
|
|
228
|
+
for (const [id, stream] of Array.from(streams.entries())) {
|
|
229
|
+
tasks.push(stream
|
|
230
|
+
.finish()
|
|
231
|
+
.catch((e) => console.error("[bot-telegram] stream finalize failed:", e)));
|
|
232
|
+
streams.delete(id);
|
|
233
|
+
finalised.add(id);
|
|
234
|
+
}
|
|
235
|
+
buffers.clear();
|
|
236
|
+
await Promise.all(tasks);
|
|
237
|
+
// Clean up any tool-status placeholders whose END never arrived before
|
|
238
|
+
// the error — mark them cancelled rather than leaving "🔧 using <tool>…"
|
|
239
|
+
// orphaned in the chat.
|
|
240
|
+
await drainToolStatuses((tool) => `⚠️ ${tool} (cancelled)`);
|
|
241
|
+
try {
|
|
242
|
+
await args.postPlaceholder(telegramHtml(`⚠️ Agent error: ${event.message ?? "unknown error"}`));
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
console.error("[telegram-renderer] error notice failed:", err);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
subscriber,
|
|
251
|
+
getCapturedToolCalls: () => capturedToolCalls,
|
|
252
|
+
getPendingInterrupt: () => pendingInterrupt,
|
|
253
|
+
clearPendingInterrupt: () => {
|
|
254
|
+
pendingInterrupt = undefined;
|
|
255
|
+
},
|
|
256
|
+
async markInterrupted() {
|
|
257
|
+
// Idempotent. Mark BEFORE any await so subsequent subscriber callbacks
|
|
258
|
+
// (including the RUN_ERROR that AG-UI fires when we abort) bail.
|
|
259
|
+
if (aborted)
|
|
260
|
+
return;
|
|
261
|
+
aborted = true;
|
|
262
|
+
// Settle EVERY in-flight text stream. `ChunkedEditStream.append`
|
|
263
|
+
// schedules its placeholder post asynchronously (on setupPromise), so a
|
|
264
|
+
// stream that just received content may still report chunkCount === 0
|
|
265
|
+
// until that post resolves. If we deleted such a stream without
|
|
266
|
+
// finishing it, its in-flight postPlaceholder would still resolve and
|
|
267
|
+
// leave a stray "…" message orphaned forever. `finish()` awaits
|
|
268
|
+
// setupPromise, so it deterministically creates-and-flushes any pending
|
|
269
|
+
// placeholder rather than abandoning it.
|
|
270
|
+
const tasks = [];
|
|
271
|
+
for (const [id, stream] of Array.from(streams.entries())) {
|
|
272
|
+
const buf = buffers.get(id) ?? "";
|
|
273
|
+
if (buf.trim().length > 0) {
|
|
274
|
+
// Real content: surface the interrupted marker on the partial reply
|
|
275
|
+
// before finishing.
|
|
276
|
+
stream.append(buf + INTERRUPTED_SUFFIX);
|
|
277
|
+
}
|
|
278
|
+
// Empty/whitespace buffer: just finish(). If nothing was ever posted
|
|
279
|
+
// it's a no-op; if a placeholder post is in flight it gets flushed/
|
|
280
|
+
// edited rather than left as a stray "…".
|
|
281
|
+
tasks.push(stream
|
|
282
|
+
.finish()
|
|
283
|
+
.catch((e) => console.error("[bot-telegram] stream finalize failed:", e)));
|
|
284
|
+
streams.delete(id);
|
|
285
|
+
finalised.add(id);
|
|
286
|
+
}
|
|
287
|
+
buffers.clear();
|
|
288
|
+
await Promise.all(tasks);
|
|
289
|
+
// Clean up any tool-status placeholders whose END never arrived before
|
|
290
|
+
// the interrupt — mark them cancelled rather than leaving the stale
|
|
291
|
+
// "🔧 using <tool>…" line orphaned in the chat.
|
|
292
|
+
await drainToolStatuses((tool) => `⚠️ ${tool} (cancelled)`);
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format-fallback.d.ts","sourceRoot":"","sources":["../src/format-fallback.ts"],"names":[],"mappings":"AAAA,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAQ3C;AAED,wBAAsB,0BAA0B,CAAC,CAAC,EAChD,IAAI,EAAE,CAAC,IAAI,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,KAAK,OAAO,CAAC,CAAC,CAAC,EAChE,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,CAAC,CAAC,CAcZ"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function stripHtml(s) {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/<[^>]*>/g, "")
|
|
4
|
+
.replace(/</g, "<")
|
|
5
|
+
.replace(/>/g, ">")
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, "'")
|
|
8
|
+
.replace(/&/g, "&");
|
|
9
|
+
}
|
|
10
|
+
export async function withTelegramFormatFallback(send, text) {
|
|
11
|
+
try {
|
|
12
|
+
return await send({ parseMode: "HTML", text });
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
if (err instanceof Error &&
|
|
16
|
+
/can't parse (?:caption )?entities|unsupported start tag|can't find end tag|tag .* is not (?:allowed|closed)/i.test(err.message)) {
|
|
17
|
+
return send({ text: stripHtml(text) });
|
|
18
|
+
}
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { telegram, TelegramAdapter } from "./adapter.js";
|
|
2
|
+
export { createRunRenderer } from "./event-renderer.js";
|
|
3
|
+
export type { CreateRunRendererArgs } from "./event-renderer.js";
|
|
4
|
+
export { decodeInteraction, conversationKeyOf, deriveConversationKey, toPlatformUser, } from "./interaction.js";
|
|
5
|
+
export { renderTelegram } from "./render/telegram.js";
|
|
6
|
+
export { TELEGRAM_LIMITS, truncateText, clampArray, byteLen, } from "./render/budget.js";
|
|
7
|
+
export { defaultTelegramTools, lookupTelegramUserTool, } from "./built-in-tools.js";
|
|
8
|
+
export { defaultTelegramContext, telegramTaggingContext, telegramFormattingContext, telegramConversationModelContext, } from "./built-in-context.js";
|
|
9
|
+
export { telegramHtml, escapeHtml } from "./telegram-html.js";
|
|
10
|
+
export { withTelegramFormatFallback, stripHtml } from "./format-fallback.js";
|
|
11
|
+
export { TelegramConversationStore } from "./conversation-store.js";
|
|
12
|
+
export { ChunkedEditStream } from "./chunked-edit-stream.js";
|
|
13
|
+
export type { ChunkedEditStreamConfig } from "./chunked-edit-stream.js";
|
|
14
|
+
export { attachTelegramListener } from "./listener.js";
|
|
15
|
+
export type { ListenerConfig } from "./listener.js";
|
|
16
|
+
export { buildFileContentParts } from "./download-files.js";
|
|
17
|
+
export type { TelegramFileRef, AgentContentPart, FileDeliveryConfig, } from "./download-files.js";
|
|
18
|
+
export type { ConversationKey, ReplyTarget, TelegramMessageRef, TelegramInlineButton, TelegramPayload, TelegramAdapterOptions, } from "./types.js";
|
|
19
|
+
export { DM_SCOPE } from "./types.js";
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEzD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,qBAAqB,EACrB,cAAc,GACf,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,EACL,eAAe,EACf,YAAY,EACZ,UAAU,EACV,OAAO,GACR,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,sBAAsB,EACtB,sBAAsB,EACtB,yBAAyB,EACzB,gCAAgC,GACjC,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAE9D,OAAO,EAAE,0BAA0B,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAE7E,OAAO,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAEpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAExE,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,YAAY,EACV,eAAe,EACf,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,qBAAqB,CAAC;AAE7B,YAAY,EACV,eAAe,EACf,WAAW,EACX,kBAAkB,EAClB,oBAAoB,EACpB,eAAe,EACf,sBAAsB,GACvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { telegram, TelegramAdapter } from "./adapter.js";
|
|
2
|
+
export { createRunRenderer } from "./event-renderer.js";
|
|
3
|
+
export { decodeInteraction, conversationKeyOf, deriveConversationKey, toPlatformUser, } from "./interaction.js";
|
|
4
|
+
export { renderTelegram } from "./render/telegram.js";
|
|
5
|
+
export { TELEGRAM_LIMITS, truncateText, clampArray, byteLen, } from "./render/budget.js";
|
|
6
|
+
export { defaultTelegramTools, lookupTelegramUserTool, } from "./built-in-tools.js";
|
|
7
|
+
export { defaultTelegramContext, telegramTaggingContext, telegramFormattingContext, telegramConversationModelContext, } from "./built-in-context.js";
|
|
8
|
+
export { telegramHtml, escapeHtml } from "./telegram-html.js";
|
|
9
|
+
export { withTelegramFormatFallback, stripHtml } from "./format-fallback.js";
|
|
10
|
+
export { TelegramConversationStore } from "./conversation-store.js";
|
|
11
|
+
export { ChunkedEditStream } from "./chunked-edit-stream.js";
|
|
12
|
+
export { attachTelegramListener } from "./listener.js";
|
|
13
|
+
export { buildFileContentParts } from "./download-files.js";
|
|
14
|
+
export { DM_SCOPE } from "./types.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { InteractionEvent, IncomingReaction } from "@copilotkit/channels";
|
|
2
|
+
import type { PlatformUser } from "@copilotkit/channels-ui";
|
|
3
|
+
import type { ConversationKey } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Stable string key shared by ingress (listener) and interaction decoding so
|
|
6
|
+
* the bot's awaitChoice waiters resolve. Both paths MUST derive the
|
|
7
|
+
* conversation key from this single helper — a mismatch silently strands
|
|
8
|
+
* the waiter.
|
|
9
|
+
*/
|
|
10
|
+
export declare function conversationKeyOf(key: ConversationKey): string;
|
|
11
|
+
/**
|
|
12
|
+
* Derive a ConversationKey from a Telegram message/chat object.
|
|
13
|
+
*
|
|
14
|
+
* - Private chat → DM_SCOPE
|
|
15
|
+
* - Forum supergroup with message_thread_id → "topic:<thread_id>"
|
|
16
|
+
* - Other group → "user:<senderUserId>"
|
|
17
|
+
*
|
|
18
|
+
* Non-forum groups are keyed by the SENDER's user id (not message ids) so that
|
|
19
|
+
* each user has one ongoing conversation per group: a fresh @mention (which has
|
|
20
|
+
* no reply, hence a unique message_id) continues the same conversation rather
|
|
21
|
+
* than spawning a new one, and a callback_query (whose `message` is the BOT's
|
|
22
|
+
* own message) still resolves to the clicking user's conversation. Pass an
|
|
23
|
+
* explicit `userId` to override `message.from?.id` (callbacks must pass the
|
|
24
|
+
* clicking user's id so the key matches ingress).
|
|
25
|
+
*/
|
|
26
|
+
export declare function deriveConversationKey(message: {
|
|
27
|
+
message_id: number;
|
|
28
|
+
message_thread_id?: number;
|
|
29
|
+
chat: {
|
|
30
|
+
id: number | string;
|
|
31
|
+
type: string;
|
|
32
|
+
is_forum?: boolean;
|
|
33
|
+
};
|
|
34
|
+
reply_to_message?: {
|
|
35
|
+
message_id: number;
|
|
36
|
+
};
|
|
37
|
+
from?: {
|
|
38
|
+
id: number;
|
|
39
|
+
};
|
|
40
|
+
}, userId?: number): ConversationKey;
|
|
41
|
+
/**
|
|
42
|
+
* Map a Telegram `from` user to a PlatformUser.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toPlatformUser(from: {
|
|
45
|
+
id: number;
|
|
46
|
+
first_name?: string;
|
|
47
|
+
last_name?: string;
|
|
48
|
+
username?: string;
|
|
49
|
+
}): PlatformUser | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Decode a Telegram `message_reaction` update into zero or more
|
|
52
|
+
* {@link IncomingReaction} events — one per emoji that was added or removed.
|
|
53
|
+
* Only `type === "emoji"` entries are considered; custom_emoji are ignored.
|
|
54
|
+
*/
|
|
55
|
+
export declare function decodeReaction(update: unknown): IncomingReaction[];
|
|
56
|
+
/**
|
|
57
|
+
* Decode a Telegram callback_query update (grammY or raw Bot API) into a
|
|
58
|
+
* bot InteractionEvent.
|
|
59
|
+
*
|
|
60
|
+
* Accepts either:
|
|
61
|
+
* - A grammY update: `{ callback_query: { ... } }`
|
|
62
|
+
* - A bare callback query object: `{ id, data, from, message }`
|
|
63
|
+
*/
|
|
64
|
+
export declare function decodeInteraction(raw: unknown): InteractionEvent | undefined;
|
|
65
|
+
//# sourceMappingURL=interaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interaction.d.ts","sourceRoot":"","sources":["../src/interaction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC/E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAE5D,OAAO,KAAK,EACV,eAAe,EAGhB,MAAM,YAAY,CAAC;AAEpB;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAE9D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE;IACP,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAChE,gBAAgB,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,IAAI,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC;CACvB,EACD,MAAM,CAAC,EAAE,MAAM,GACd,eAAe,CAiBjB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,YAAY,GAAG,SAAS,CAS3B;AAsBD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,gBAAgB,EAAE,CA0ClE;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,GAAG,SAAS,CA2D5E"}
|