@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,159 @@
|
|
|
1
|
+
import { DM_SCOPE } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Stable string key shared by ingress (listener) and interaction decoding so
|
|
4
|
+
* the bot's awaitChoice waiters resolve. Both paths MUST derive the
|
|
5
|
+
* conversation key from this single helper — a mismatch silently strands
|
|
6
|
+
* the waiter.
|
|
7
|
+
*/
|
|
8
|
+
export function conversationKeyOf(key) {
|
|
9
|
+
return `tg:${key.chatId}:${key.scope}`;
|
|
10
|
+
}
|
|
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 function deriveConversationKey(message, userId) {
|
|
27
|
+
const { chat } = message;
|
|
28
|
+
const chatId = String(chat.id);
|
|
29
|
+
if (chat.type === "private") {
|
|
30
|
+
return { chatId, scope: DM_SCOPE };
|
|
31
|
+
}
|
|
32
|
+
// Treat message_thread_id as a forum topic ONLY in forum supergroups. In a
|
|
33
|
+
// regular (non-forum) supergroup Telegram also sets message_thread_id on any
|
|
34
|
+
// REPLY message (it doubles as the reply-thread id), so keying off it there
|
|
35
|
+
// would defeat per-user keying (each reply would spawn a new conversation).
|
|
36
|
+
if (message.message_thread_id !== undefined && message.chat?.is_forum) {
|
|
37
|
+
return { chatId, scope: `topic:${message.message_thread_id}` };
|
|
38
|
+
}
|
|
39
|
+
return { chatId, scope: `user:${userId ?? message.from?.id ?? "unknown"}` };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Map a Telegram `from` user to a PlatformUser.
|
|
43
|
+
*/
|
|
44
|
+
export function toPlatformUser(from) {
|
|
45
|
+
if (!from)
|
|
46
|
+
return undefined;
|
|
47
|
+
const name = [from.first_name, from.last_name].filter(Boolean).join(" ") || undefined;
|
|
48
|
+
return {
|
|
49
|
+
id: String(from.id),
|
|
50
|
+
name,
|
|
51
|
+
handle: from.username,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const emojiSet = (list) => new Set((list ?? [])
|
|
55
|
+
.filter((r) => r.type === "emoji" && r.emoji)
|
|
56
|
+
.map((r) => r.emoji));
|
|
57
|
+
/**
|
|
58
|
+
* Decode a Telegram `message_reaction` update into zero or more
|
|
59
|
+
* {@link IncomingReaction} events — one per emoji that was added or removed.
|
|
60
|
+
* Only `type === "emoji"` entries are considered; custom_emoji are ignored.
|
|
61
|
+
*/
|
|
62
|
+
export function decodeReaction(update) {
|
|
63
|
+
const mr = update
|
|
64
|
+
.message_reaction;
|
|
65
|
+
if (!mr?.chat?.id || mr.message_id === undefined)
|
|
66
|
+
return [];
|
|
67
|
+
const oldSet = emojiSet(mr.old_reaction);
|
|
68
|
+
const newSet = emojiSet(mr.new_reaction);
|
|
69
|
+
const chatId = mr.chat.id;
|
|
70
|
+
const message = {
|
|
71
|
+
chat: mr.chat,
|
|
72
|
+
message_id: mr.message_id,
|
|
73
|
+
message_thread_id: mr.message_thread_id,
|
|
74
|
+
};
|
|
75
|
+
const ck = deriveConversationKey(message, mr.user?.id);
|
|
76
|
+
const conversationKey = conversationKeyOf(ck);
|
|
77
|
+
const replyTarget = {
|
|
78
|
+
chatId,
|
|
79
|
+
messageThreadId: mr.chat.is_forum ? mr.message_thread_id : undefined,
|
|
80
|
+
conversationKey,
|
|
81
|
+
};
|
|
82
|
+
const user = mr.user
|
|
83
|
+
? {
|
|
84
|
+
id: String(mr.user.id),
|
|
85
|
+
name: mr.user.first_name,
|
|
86
|
+
handle: mr.user.username,
|
|
87
|
+
}
|
|
88
|
+
: undefined;
|
|
89
|
+
const base = {
|
|
90
|
+
user,
|
|
91
|
+
conversationKey,
|
|
92
|
+
replyTarget,
|
|
93
|
+
messageId: String(mr.message_id),
|
|
94
|
+
// Update-capable ref (chatId + numeric messageId) so an onReaction handler
|
|
95
|
+
// can edit the reacted message in place via thread.update.
|
|
96
|
+
messageRef: { id: String(mr.message_id), chatId, messageId: mr.message_id },
|
|
97
|
+
raw: update,
|
|
98
|
+
};
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const e of newSet)
|
|
101
|
+
if (!oldSet.has(e))
|
|
102
|
+
out.push({ ...base, rawEmoji: e, added: true });
|
|
103
|
+
for (const e of oldSet)
|
|
104
|
+
if (!newSet.has(e))
|
|
105
|
+
out.push({ ...base, rawEmoji: e, added: false });
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Decode a Telegram callback_query update (grammY or raw Bot API) into a
|
|
110
|
+
* bot InteractionEvent.
|
|
111
|
+
*
|
|
112
|
+
* Accepts either:
|
|
113
|
+
* - A grammY update: `{ callback_query: { ... } }`
|
|
114
|
+
* - A bare callback query object: `{ id, data, from, message }`
|
|
115
|
+
*/
|
|
116
|
+
export function decodeInteraction(raw) {
|
|
117
|
+
if (!raw || typeof raw !== "object")
|
|
118
|
+
return undefined;
|
|
119
|
+
const obj = raw;
|
|
120
|
+
// Unwrap grammY update wrapper if present.
|
|
121
|
+
const cq = (obj.callback_query ?? obj);
|
|
122
|
+
if (!cq || typeof cq !== "object")
|
|
123
|
+
return undefined;
|
|
124
|
+
// Must have callback data.
|
|
125
|
+
if (!cq.data || typeof cq.data !== "string")
|
|
126
|
+
return undefined;
|
|
127
|
+
const message = cq.message;
|
|
128
|
+
if (!message)
|
|
129
|
+
return undefined;
|
|
130
|
+
const from = cq.from;
|
|
131
|
+
// A callback_query's `message` is the BOT's message, so its `from` is the
|
|
132
|
+
// bot. Derive the key from the CLICKING user's id (cq.from) so non-forum
|
|
133
|
+
// group keys match the ingress key produced by the listener.
|
|
134
|
+
const ck = deriveConversationKey(message, from?.id);
|
|
135
|
+
const conversationKey = conversationKeyOf(ck);
|
|
136
|
+
const replyTarget = {
|
|
137
|
+
chatId: message.chat.id,
|
|
138
|
+
// Only attach a forum thread id in forum supergroups. In non-forum chats
|
|
139
|
+
// message_thread_id is the reply-thread id and Telegram rejects sends that
|
|
140
|
+
// attach it ("message thread not found").
|
|
141
|
+
messageThreadId: message.chat?.is_forum
|
|
142
|
+
? message.message_thread_id
|
|
143
|
+
: undefined,
|
|
144
|
+
conversationKey,
|
|
145
|
+
};
|
|
146
|
+
const messageRef = {
|
|
147
|
+
id: `${message.chat.id}:${message.message_id}`,
|
|
148
|
+
chatId: message.chat.id,
|
|
149
|
+
messageId: message.message_id,
|
|
150
|
+
};
|
|
151
|
+
const user = from ? toPlatformUser(from) : undefined;
|
|
152
|
+
return {
|
|
153
|
+
id: cq.data,
|
|
154
|
+
conversationKey,
|
|
155
|
+
replyTarget,
|
|
156
|
+
messageRef,
|
|
157
|
+
user,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Bot } from "grammy";
|
|
2
|
+
import type { IngressSink } from "@copilotkit/channels";
|
|
3
|
+
import type { TelegramConversationStore } from "./conversation-store.js";
|
|
4
|
+
export interface ListenerConfig {
|
|
5
|
+
bot: Bot;
|
|
6
|
+
store: TelegramConversationStore;
|
|
7
|
+
botUsername: string;
|
|
8
|
+
botUserId: number;
|
|
9
|
+
sink: IngressSink;
|
|
10
|
+
/** Telegram bot token, used to download inbound files. */
|
|
11
|
+
botToken: string;
|
|
12
|
+
/** Resolve a Telegram fileId to its file path (e.g. via the getFile API). */
|
|
13
|
+
getFilePath: (fileId: string) => Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
export declare function attachTelegramListener(config: ListenerConfig): void;
|
|
16
|
+
//# sourceMappingURL=listener.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAC;AAClC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAgBzE,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,KAAK,EAAE,yBAAyB,CAAC;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,WAAW,CAAC;IAClB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CAClD;AA2ED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CA6cnE"}
|
package/dist/listener.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { conversationKeyOf, deriveConversationKey, toPlatformUser, decodeInteraction, decodeReaction, } from "./interaction.js";
|
|
2
|
+
import { buildFileContentParts } from "./download-files.js";
|
|
3
|
+
/** Escape special regex characters in a string so it can be used in a RegExp. */
|
|
4
|
+
function escapeRegExp(s) {
|
|
5
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Extract downloadable file refs from any media-bearing message (used for the
|
|
9
|
+
* message a user *replied to*). Mirrors the per-type extraction in the
|
|
10
|
+
* `message:*` handlers — photo (largest size), document, video, audio, voice.
|
|
11
|
+
*/
|
|
12
|
+
function fileRefsFromMessage(m) {
|
|
13
|
+
if (m.photo?.length) {
|
|
14
|
+
const largest = m.photo[m.photo.length - 1];
|
|
15
|
+
// Telegram re-encodes most photos to JPEG (see the message:photo handler).
|
|
16
|
+
if (largest)
|
|
17
|
+
return [{ fileId: largest.file_id, mimeType: "image/jpeg" }];
|
|
18
|
+
}
|
|
19
|
+
if (m.document) {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
fileId: m.document.file_id,
|
|
23
|
+
fileName: m.document.file_name,
|
|
24
|
+
mimeType: m.document.mime_type,
|
|
25
|
+
size: m.document.file_size,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
for (const media of [m.video, m.audio, m.voice]) {
|
|
30
|
+
if (media) {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
fileId: media.file_id,
|
|
34
|
+
mimeType: media.mime_type,
|
|
35
|
+
size: media.file_size,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
export function attachTelegramListener(config) {
|
|
43
|
+
const { bot, botUsername, botUserId, sink, store, botToken, getFilePath } = config;
|
|
44
|
+
/**
|
|
45
|
+
* When the inbound message is a Telegram *reply*, fold the referenced
|
|
46
|
+
* message's content into the turn so the agent knows what the user is
|
|
47
|
+
* pointing at — e.g. replying to an earlier image with "what's in it". Its
|
|
48
|
+
* media is downloaded (reusing the inbound-file pipeline) and its text is
|
|
49
|
+
* quoted. Returns [] when the replied-to message carries nothing usable.
|
|
50
|
+
*/
|
|
51
|
+
async function buildReplyContextParts(reply) {
|
|
52
|
+
const refs = fileRefsFromMessage(reply);
|
|
53
|
+
const quotedRaw = (reply.text ?? reply.caption ?? "").trim();
|
|
54
|
+
const quoted = quotedRaw.length > 500 ? `${quotedRaw.slice(0, 500)}…` : quotedRaw;
|
|
55
|
+
if (refs.length === 0 && !quoted)
|
|
56
|
+
return [];
|
|
57
|
+
const desc = [
|
|
58
|
+
quoted ? `text: "${quoted}"` : null,
|
|
59
|
+
refs.length ? "an attached file" : null,
|
|
60
|
+
]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(" and ");
|
|
63
|
+
const parts = [
|
|
64
|
+
{ type: "text", text: `[In reply to an earlier message — ${desc}:]` },
|
|
65
|
+
];
|
|
66
|
+
if (refs.length) {
|
|
67
|
+
const { parts: fileParts, notes } = await buildFileContentParts(refs, botToken, getFilePath);
|
|
68
|
+
parts.push(...fileParts);
|
|
69
|
+
if (notes.length) {
|
|
70
|
+
parts.push({
|
|
71
|
+
type: "text",
|
|
72
|
+
text: `[attachment notes: ${notes.join("; ")}]`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return parts;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Apply loop-guard + group gating and (when answered) enqueue the user's
|
|
80
|
+
* message onto the store and emit `onTurn`. Returns without effect when the
|
|
81
|
+
* turn should be ignored (own message, or un-addressed group message).
|
|
82
|
+
*
|
|
83
|
+
* @param ctx grammY context (provides `chat`).
|
|
84
|
+
* @param msg the inbound message.
|
|
85
|
+
* @param userText the addressing text used for gating + handler context
|
|
86
|
+
* (text body or media caption; "" when absent).
|
|
87
|
+
* @param buildContent produces the agent message content for an answered
|
|
88
|
+
* turn — the mention-stripped string for text, or an
|
|
89
|
+
* array of content parts for media.
|
|
90
|
+
*/
|
|
91
|
+
async function handleTurn(ctx, msg, userText, buildContent) {
|
|
92
|
+
const from = msg.from;
|
|
93
|
+
// LOOP GUARD: ignore our own messages.
|
|
94
|
+
if (from?.id === botUserId)
|
|
95
|
+
return;
|
|
96
|
+
// Bug 4 fix: guard against an empty botUsername. `@${escapeRegExp("")}\b`
|
|
97
|
+
// would be `/@\b/`, which matches a bare "@". When botUsername is empty
|
|
98
|
+
// there is no mention to match, so use a regex that never matches.
|
|
99
|
+
const mentionRegex = botUsername
|
|
100
|
+
? new RegExp(`@${escapeRegExp(botUsername)}\\b`, "i")
|
|
101
|
+
: undefined;
|
|
102
|
+
// Decide whether to answer.
|
|
103
|
+
const chatType = ctx.chat.type;
|
|
104
|
+
let shouldAnswer = false;
|
|
105
|
+
if (chatType === "private") {
|
|
106
|
+
shouldAnswer = true;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// group / supergroup: answer if @mentioned (case-insensitive, word-boundary)
|
|
110
|
+
// or reply to bot's message.
|
|
111
|
+
const mentionedBot = mentionRegex?.test(userText) ?? false;
|
|
112
|
+
const replyToBot = msg.reply_to_message?.from?.id === botUserId;
|
|
113
|
+
shouldAnswer = mentionedBot || replyToBot;
|
|
114
|
+
}
|
|
115
|
+
if (!shouldAnswer)
|
|
116
|
+
return;
|
|
117
|
+
// Bug 1 & 2 fix: strip the @mention wherever it appears (first occurrence),
|
|
118
|
+
// case-insensitively, collapsing surrounding whitespace so the agent gets
|
|
119
|
+
// clean text. With no botUsername there is nothing to strip.
|
|
120
|
+
let strippedText = (mentionRegex ? userText.replace(mentionRegex, "") : userText)
|
|
121
|
+
.replace(/\s{2,}/g, " ")
|
|
122
|
+
.trim();
|
|
123
|
+
const turnConversationKey = conversationKeyOf(deriveConversationKey(msg));
|
|
124
|
+
// Build the agent content, then fold in any replied-to message so the
|
|
125
|
+
// agent can resolve a Telegram reply ("what's in this image" pointing at an
|
|
126
|
+
// earlier photo). Reply context is appended after the user's own content.
|
|
127
|
+
let content = buildContent(strippedText);
|
|
128
|
+
if (msg.reply_to_message) {
|
|
129
|
+
const replyParts = await buildReplyContextParts(msg.reply_to_message);
|
|
130
|
+
if (replyParts.length) {
|
|
131
|
+
const baseParts = typeof content === "string"
|
|
132
|
+
? content
|
|
133
|
+
? [{ type: "text", text: content }]
|
|
134
|
+
: []
|
|
135
|
+
: content;
|
|
136
|
+
content = [...baseParts, ...replyParts];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Enqueue the user's message so getOrCreate delivers it to the agent. The
|
|
140
|
+
// listener runs before the bot handler that calls runAgent → getOrCreate,
|
|
141
|
+
// so the drain happens at exactly the right time.
|
|
142
|
+
store.enqueueUserMessage(turnConversationKey, content);
|
|
143
|
+
// Bug 3 fix: record the inbound user turn so getMessages() includes it.
|
|
144
|
+
// Record the STRIPPED text (the same clean text handed to the agent, not
|
|
145
|
+
// the raw text including the @mention) and stamp `ts` so history ordering
|
|
146
|
+
// matches the outbound records.
|
|
147
|
+
config.store.recordMessage(turnConversationKey, {
|
|
148
|
+
text: strippedText,
|
|
149
|
+
ts: String(msg.message_id),
|
|
150
|
+
isBot: false,
|
|
151
|
+
user: from ? toPlatformUser(from) : undefined,
|
|
152
|
+
});
|
|
153
|
+
// CRITICAL: run the turn WITHOUT blocking grammY's poll loop. grammY's
|
|
154
|
+
// built-in long polling processes updates SEQUENTIALLY — it awaits one
|
|
155
|
+
// update's handler before fetching the next. If we awaited the full turn
|
|
156
|
+
// here, a blocking human-in-the-loop step (confirm_write → awaitChoice)
|
|
157
|
+
// would pause polling indefinitely: the callback_query that resolves the
|
|
158
|
+
// choice can only arrive via the next getUpdates, which never happens while
|
|
159
|
+
// this handler is blocked. With no in-flight poll request (and only a
|
|
160
|
+
// pending promise, which does not ref the event loop) the process would
|
|
161
|
+
// then drain and exit(0) silently — a deadlock, not a crash. Firing the
|
|
162
|
+
// turn async lets polling continue and deliver that callback. Errors are
|
|
163
|
+
// logged here since nothing awaits the promise.
|
|
164
|
+
void Promise.resolve(sink.onTurn({
|
|
165
|
+
conversationKey: turnConversationKey,
|
|
166
|
+
replyTarget: {
|
|
167
|
+
chatId: ctx.chat.id,
|
|
168
|
+
// Only attach a forum thread id in forum supergroups. In non-forum
|
|
169
|
+
// chats Telegram sets message_thread_id on reply messages, but
|
|
170
|
+
// attaching it to a send is rejected ("message thread not found").
|
|
171
|
+
messageThreadId: ctx.chat.is_forum
|
|
172
|
+
? msg.message_thread_id
|
|
173
|
+
: undefined,
|
|
174
|
+
replyToMessageId: msg.message_id,
|
|
175
|
+
conversationKey: turnConversationKey,
|
|
176
|
+
},
|
|
177
|
+
userText: strippedText,
|
|
178
|
+
user: from ? toPlatformUser(from) : undefined,
|
|
179
|
+
platform: "telegram",
|
|
180
|
+
})).catch((e) => {
|
|
181
|
+
console.error(`[bot-telegram] turn failed for conversationKey=${turnConversationKey}:`, e);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Build media content parts from file refs + caption, then route the turn. */
|
|
185
|
+
async function handleMedia(ctx, fileRefs) {
|
|
186
|
+
const msg = ctx.message;
|
|
187
|
+
if (!msg)
|
|
188
|
+
return;
|
|
189
|
+
// LOOP GUARD up-front to avoid downloading files for our own messages.
|
|
190
|
+
if (msg.from?.id === botUserId)
|
|
191
|
+
return;
|
|
192
|
+
const caption = msg.caption ?? "";
|
|
193
|
+
// Gating is applied inside handleTurn against the caption; only build the
|
|
194
|
+
// (potentially expensive) file parts once we know the turn is answered.
|
|
195
|
+
// Bug 1 fix: use case-insensitive, word-boundary regex for mention check.
|
|
196
|
+
// Bug 4 fix: guard against an empty botUsername (see handleTurn).
|
|
197
|
+
const mediaMentionRegex = botUsername
|
|
198
|
+
? new RegExp(`@${escapeRegExp(botUsername)}\\b`, "i")
|
|
199
|
+
: undefined;
|
|
200
|
+
const chatType = ctx.chat.type;
|
|
201
|
+
let shouldAnswer = chatType === "private";
|
|
202
|
+
if (!shouldAnswer) {
|
|
203
|
+
const mentionedBot = mediaMentionRegex?.test(caption) ?? false;
|
|
204
|
+
const replyToBot = msg.reply_to_message?.from?.id === botUserId;
|
|
205
|
+
shouldAnswer = mentionedBot || replyToBot;
|
|
206
|
+
}
|
|
207
|
+
if (!shouldAnswer)
|
|
208
|
+
return;
|
|
209
|
+
const { parts, notes } = await buildFileContentParts(fileRefs, botToken, getFilePath);
|
|
210
|
+
await handleTurn(ctx, msg, caption, (strippedCaption) => [
|
|
211
|
+
...(strippedCaption
|
|
212
|
+
? [{ type: "text", text: strippedCaption }]
|
|
213
|
+
: []),
|
|
214
|
+
...parts,
|
|
215
|
+
...(notes.length
|
|
216
|
+
? [
|
|
217
|
+
{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: `[attachment notes: ${notes.join("; ")}]`,
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
: []),
|
|
223
|
+
]);
|
|
224
|
+
}
|
|
225
|
+
// ── Text messages ──────────────────────────────────────────────────
|
|
226
|
+
bot.on("message:text", async (ctx) => {
|
|
227
|
+
const msg = ctx.message;
|
|
228
|
+
const from = msg.from;
|
|
229
|
+
// LOOP GUARD: ignore our own messages.
|
|
230
|
+
if (from?.id === botUserId)
|
|
231
|
+
return;
|
|
232
|
+
const text = msg.text ?? "";
|
|
233
|
+
const entities = msg
|
|
234
|
+
.entities ?? [];
|
|
235
|
+
const cmdEntity = entities.find((e) => e.type === "bot_command" && e.offset === 0);
|
|
236
|
+
if (cmdEntity) {
|
|
237
|
+
// Extract the command token (e.g. "/triage" or "/triage@cpk_bot")
|
|
238
|
+
const cmdToken = text.slice(cmdEntity.offset, cmdEntity.offset + cmdEntity.length);
|
|
239
|
+
// Parse: /cmd[@bot]
|
|
240
|
+
const withoutSlash = cmdToken.slice(1); // strip leading "/"
|
|
241
|
+
const atIdx = withoutSlash.indexOf("@");
|
|
242
|
+
let cmd;
|
|
243
|
+
let targetBot;
|
|
244
|
+
if (atIdx !== -1) {
|
|
245
|
+
cmd = withoutSlash.slice(0, atIdx);
|
|
246
|
+
targetBot = withoutSlash.slice(atIdx + 1);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
cmd = withoutSlash;
|
|
250
|
+
targetBot = undefined;
|
|
251
|
+
}
|
|
252
|
+
// If targeted at another bot, ignore. Guard against an undefined/empty
|
|
253
|
+
// botUsername (consistent with the mention-path guard): when we have no
|
|
254
|
+
// username we cannot prove the command targets us, so skip the compare
|
|
255
|
+
// and let it through rather than throwing on `botUsername.toLowerCase()`.
|
|
256
|
+
if (targetBot !== undefined &&
|
|
257
|
+
botUsername &&
|
|
258
|
+
targetBot.toLowerCase() !== botUsername.toLowerCase()) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Bug 2 fix: `/start` is handled exclusively by the dedicated
|
|
262
|
+
// bot.command("start") handler (which emits onThreadStarted). Returning
|
|
263
|
+
// early here prevents a double-dispatch where both onCommand("start") and
|
|
264
|
+
// onThreadStarted fire for the same /start message.
|
|
265
|
+
if (cmd.toLowerCase() === "start") {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Rest of the text after the command token
|
|
269
|
+
const rest = text.slice(cmdEntity.offset + cmdEntity.length).trim();
|
|
270
|
+
const commandConversationKey = conversationKeyOf(deriveConversationKey(msg));
|
|
271
|
+
// Commands do NOT enqueue: their args are injected by the command handler
|
|
272
|
+
// via runAgent({ prompt }), so enqueuing here would double-deliver.
|
|
273
|
+
// Fire async (not awaited) for the same reason as onTurn above — a command
|
|
274
|
+
// whose agent hits confirm_write must not block grammY's poll loop.
|
|
275
|
+
void Promise.resolve(sink.onCommand({
|
|
276
|
+
command: cmd.toLowerCase(),
|
|
277
|
+
text: rest,
|
|
278
|
+
conversationKey: commandConversationKey,
|
|
279
|
+
replyTarget: {
|
|
280
|
+
chatId: ctx.chat.id,
|
|
281
|
+
// Forum thread id only in forum supergroups (see onTurn above).
|
|
282
|
+
messageThreadId: ctx.chat.is_forum
|
|
283
|
+
? msg.message_thread_id
|
|
284
|
+
: undefined,
|
|
285
|
+
conversationKey: commandConversationKey,
|
|
286
|
+
},
|
|
287
|
+
user: from ? toPlatformUser(from) : undefined,
|
|
288
|
+
platform: "telegram",
|
|
289
|
+
})).catch((e) => console.error("[bot-telegram] command failed:", e));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Non-command text: gate, enqueue the (mention-stripped) text, emit onTurn.
|
|
293
|
+
await handleTurn(ctx, msg, text, (strippedText) => strippedText);
|
|
294
|
+
});
|
|
295
|
+
// ── Media messages ─────────────────────────────────────────────────
|
|
296
|
+
bot.on("message:photo", async (ctx) => {
|
|
297
|
+
const photos = ctx.message.photo ?? [];
|
|
298
|
+
const largest = photos[photos.length - 1];
|
|
299
|
+
if (!largest)
|
|
300
|
+
return;
|
|
301
|
+
// Bug 5: at this point we only have the file_id, not the bytes, so we
|
|
302
|
+
// cannot sniff the real MIME type here. Telegram re-encodes most photos to
|
|
303
|
+
// JPEG, so "image/jpeg" is a reasonable default. Telegram photos can in
|
|
304
|
+
// principle be PNG/WebP; refining the MIME from the download response's
|
|
305
|
+
// Content-Type belongs in the download layer (out of scope for this file).
|
|
306
|
+
await handleMedia(ctx, [
|
|
307
|
+
{ fileId: largest.file_id, mimeType: "image/jpeg" },
|
|
308
|
+
]);
|
|
309
|
+
});
|
|
310
|
+
bot.on("message:document", async (ctx) => {
|
|
311
|
+
const doc = ctx.message.document;
|
|
312
|
+
if (!doc)
|
|
313
|
+
return;
|
|
314
|
+
await handleMedia(ctx, [
|
|
315
|
+
{
|
|
316
|
+
fileId: doc.file_id,
|
|
317
|
+
fileName: doc.file_name,
|
|
318
|
+
mimeType: doc.mime_type,
|
|
319
|
+
size: doc.file_size,
|
|
320
|
+
},
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
bot.on("message:video", async (ctx) => {
|
|
324
|
+
const video = ctx.message.video;
|
|
325
|
+
if (!video)
|
|
326
|
+
return;
|
|
327
|
+
await handleMedia(ctx, [
|
|
328
|
+
{
|
|
329
|
+
fileId: video.file_id,
|
|
330
|
+
mimeType: video.mime_type,
|
|
331
|
+
size: video.file_size,
|
|
332
|
+
},
|
|
333
|
+
]);
|
|
334
|
+
});
|
|
335
|
+
bot.on("message:audio", async (ctx) => {
|
|
336
|
+
const audio = ctx.message.audio;
|
|
337
|
+
if (!audio)
|
|
338
|
+
return;
|
|
339
|
+
await handleMedia(ctx, [
|
|
340
|
+
{
|
|
341
|
+
fileId: audio.file_id,
|
|
342
|
+
mimeType: audio.mime_type,
|
|
343
|
+
size: audio.file_size,
|
|
344
|
+
},
|
|
345
|
+
]);
|
|
346
|
+
});
|
|
347
|
+
bot.on("message:voice", async (ctx) => {
|
|
348
|
+
const voice = ctx.message.voice;
|
|
349
|
+
if (!voice)
|
|
350
|
+
return;
|
|
351
|
+
await handleMedia(ctx, [
|
|
352
|
+
{
|
|
353
|
+
fileId: voice.file_id,
|
|
354
|
+
mimeType: voice.mime_type,
|
|
355
|
+
size: voice.file_size,
|
|
356
|
+
},
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
// ── Callback queries (inline keyboard interactions) ─────────────────
|
|
360
|
+
bot.on("callback_query:data", async (ctx) => {
|
|
361
|
+
// Ack FIRST to clear the client spinner. The ack itself may throw (e.g. a
|
|
362
|
+
// stale button → "query is too old"); that must NOT block decode/dispatch,
|
|
363
|
+
// otherwise the awaitChoice waiter is stranded. Wrap it in its own
|
|
364
|
+
// try/catch, log on failure, then ALWAYS decode + dispatch.
|
|
365
|
+
try {
|
|
366
|
+
await ctx.answerCallbackQuery();
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
console.error("[bot-telegram] callback ack failed:", err);
|
|
370
|
+
}
|
|
371
|
+
// Guard the dispatch: a throw here (decode, action handler, or the resumed
|
|
372
|
+
// agent turn) must NOT escape into grammy's poll loop and crash the bot.
|
|
373
|
+
try {
|
|
374
|
+
const evt = decodeInteraction(ctx.update);
|
|
375
|
+
if (evt) {
|
|
376
|
+
await sink.onInteraction(evt);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
console.error("[bot-telegram] onInteraction failed:", err);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
// ── Emoji reactions ────────────────────────────────────────────────
|
|
384
|
+
bot.on("message_reaction", async (ctx) => {
|
|
385
|
+
// LOOP GUARD: ignore the bot's OWN reactions. If our setMessageReaction
|
|
386
|
+
// egress is ever echoed back as a message_reaction update, dispatching it
|
|
387
|
+
// to sink.onReaction would treat it as a user reaction (loop risk for a
|
|
388
|
+
// catch-all handler). Mirrors the from?.id === botUserId guard the message
|
|
389
|
+
// handlers use, and Discord's user?.bot skip.
|
|
390
|
+
const reactor = ctx.update.message_reaction?.user;
|
|
391
|
+
if (reactor?.id === botUserId)
|
|
392
|
+
return;
|
|
393
|
+
try {
|
|
394
|
+
for (const evt of decodeReaction(ctx.update))
|
|
395
|
+
await sink.onReaction(evt);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
console.error("[bot-telegram] onReaction failed:", err);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
// ── /start command (private chats only) ────────────────────────────
|
|
402
|
+
bot.command("start", async (ctx) => {
|
|
403
|
+
if (ctx.chat.type !== "private")
|
|
404
|
+
return;
|
|
405
|
+
const msg = ctx.message;
|
|
406
|
+
if (!msg)
|
|
407
|
+
return;
|
|
408
|
+
const startConversationKey = conversationKeyOf(deriveConversationKey(msg));
|
|
409
|
+
await sink.onThreadStarted({
|
|
410
|
+
conversationKey: startConversationKey,
|
|
411
|
+
replyTarget: {
|
|
412
|
+
chatId: ctx.chat.id,
|
|
413
|
+
conversationKey: startConversationKey,
|
|
414
|
+
},
|
|
415
|
+
user: msg.from ? toPlatformUser(msg.from) : undefined,
|
|
416
|
+
platform: "telegram",
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const TELEGRAM_LIMITS: {
|
|
2
|
+
readonly messageText: 4096;
|
|
3
|
+
readonly caption: 1024;
|
|
4
|
+
readonly callbackData: 64;
|
|
5
|
+
readonly buttonsPerRow: 8;
|
|
6
|
+
readonly buttonsPerMessage: 100;
|
|
7
|
+
readonly buttonText: 64;
|
|
8
|
+
readonly photosPerMessage: 10;
|
|
9
|
+
};
|
|
10
|
+
export declare const byteLen: (s: string) => number;
|
|
11
|
+
/** Truncate to max chars, appending an ellipsis marker if the input was longer. Never returns >max. */
|
|
12
|
+
export declare function truncateText(text: string, max: number): string;
|
|
13
|
+
/** Clamp an array to max items; return the kept items plus how many overflowed (for an overflow signal). */
|
|
14
|
+
export declare function clampArray<T>(items: readonly T[], max: number): {
|
|
15
|
+
items: T[];
|
|
16
|
+
overflow: number;
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"budget.d.ts","sourceRoot":"","sources":["../../src/render/budget.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe;;;;;;;;CAQlB,CAAC;AAEX,eAAO,MAAM,OAAO,GAAI,GAAG,MAAM,WAAiC,CAAC;AAEnE,uGAAuG;AACvG,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAI9D;AAED,4GAA4G;AAC5G,wBAAgB,UAAU,CAAC,CAAC,EAC1B,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,GAAG,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAGlC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const TELEGRAM_LIMITS = {
|
|
2
|
+
messageText: 4096,
|
|
3
|
+
caption: 1024,
|
|
4
|
+
callbackData: 64,
|
|
5
|
+
buttonsPerRow: 8,
|
|
6
|
+
buttonsPerMessage: 100,
|
|
7
|
+
buttonText: 64,
|
|
8
|
+
photosPerMessage: 10,
|
|
9
|
+
};
|
|
10
|
+
export const byteLen = (s) => Buffer.byteLength(s, "utf8");
|
|
11
|
+
/** Truncate to max chars, appending an ellipsis marker if the input was longer. Never returns >max. */
|
|
12
|
+
export function truncateText(text, max) {
|
|
13
|
+
if (text.length <= max)
|
|
14
|
+
return text;
|
|
15
|
+
if (max <= 1)
|
|
16
|
+
return text.slice(0, max);
|
|
17
|
+
return text.slice(0, max - 1) + "…"; // …
|
|
18
|
+
}
|
|
19
|
+
/** Clamp an array to max items; return the kept items plus how many overflowed (for an overflow signal). */
|
|
20
|
+
export function clampArray(items, max) {
|
|
21
|
+
if (items.length <= max)
|
|
22
|
+
return { items: [...items], overflow: 0 };
|
|
23
|
+
return { items: items.slice(0, max), overflow: items.length - max };
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BotNode } from "@copilotkit/channels-ui";
|
|
2
|
+
import type { TelegramPayload } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Render a cross-platform component IR tree into a Telegram Bot API payload.
|
|
5
|
+
*
|
|
6
|
+
* The renderer is total: unknown intrinsic types are skipped rather than
|
|
7
|
+
* throwing. Telegram limits are enforced via {@link truncateText},
|
|
8
|
+
* {@link clampArray}, and {@link byteLen}.
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderTelegram(ir: BotNode[]): TelegramPayload;
|
|
11
|
+
//# sourceMappingURL=telegram.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telegram.d.ts","sourceRoot":"","sources":["../../src/render/telegram.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AACvD,OAAO,KAAK,EAAwB,eAAe,EAAE,MAAM,aAAa,CAAC;AA6BzE;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,eAAe,CAoD7D"}
|