@segmentstream/agent 0.5.4 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -18
- package/dist/extension/connections/ontology.mjs +2 -2
- package/dist/extension/instructions.md +10 -9
- package/dist/extension/lib/attachment-tools.mjs +1 -3
- package/dist/extension/lib/chat-post-confirmation.d.ts +23 -0
- package/dist/extension/lib/chat-post-confirmation.mjs +61 -0
- package/dist/extension/lib/confirmation-description.d.ts +2 -0
- package/dist/extension/lib/confirmations.d.ts +5 -0
- package/dist/extension/lib/confirmations.mjs +25 -5
- package/dist/extension/lib/conversations.d.ts +12 -1
- package/dist/extension/lib/conversations.mjs +4 -3
- package/dist/extension/lib/ontology-connection.d.ts +4 -0
- package/dist/extension/lib/ontology-connection.mjs +46 -3
- package/dist/extension/lib/ontology-tools.d.ts +1 -0
- package/dist/extension/lib/ontology-tools.mjs +27 -2
- package/dist/extension/lib/telegram/adapter.d.ts +1 -1
- package/dist/extension/lib/telegram/adapter.mjs +18 -28
- package/dist/extension/lib/telegram/attachments-send.d.ts +7 -3
- package/dist/extension/lib/telegram/attachments-send.mjs +25 -7
- package/dist/extension/lib/telegram/chat-post-card.d.ts +15 -0
- package/dist/extension/lib/telegram/chat-post-card.mjs +65 -0
- package/dist/extension/lib/telegram/confirmations.d.ts +3 -2
- package/dist/extension/lib/telegram/confirmations.mjs +140 -21
- package/dist/extension/lib/telegram/conversations.mjs +5 -3
- package/dist/extension/lib/telegram/customer-text.d.ts +13 -1
- package/dist/extension/lib/telegram/customer-text.mjs +108 -7
- package/dist/extension/lib/telegram/format.d.ts +17 -0
- package/dist/extension/lib/telegram/format.mjs +171 -0
- package/dist/extension/lib/telegram/health.mjs +1 -1
- package/dist/extension/lib/telegram/inbound-request.d.ts +1 -1
- package/dist/extension/lib/telegram/proxy-fetch.d.ts +1 -1
- package/dist/extension/lib/telegram/proxy-fetch.mjs +14 -6
- package/dist/extension/lib/telegram/runtime-request.d.ts +8 -0
- package/dist/extension/lib/telegram/runtime-request.mjs +36 -0
- package/dist/extension/lib/telegram/send-queue.d.ts +4 -0
- package/dist/extension/lib/telegram/send-queue.mjs +35 -0
- package/dist/extension/lib/telegram/silent.d.ts +3 -3
- package/dist/extension/lib/telegram/silent.mjs +19 -4
- package/package.json +2 -1
- package/dist/extension/lib/telegram/requests.d.ts +0 -80
- package/dist/extension/lib/telegram/requests.mjs +0 -186
|
@@ -4,8 +4,11 @@ import { createRequire as __eveCreateRequire } from "node:module";
|
|
|
4
4
|
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
|
+
import { isExactChatPostCard } from "./chat-post-card.mjs";
|
|
7
8
|
import { redactDiagnostic } from "../diagnostics.mjs";
|
|
8
9
|
import { currentInboundRequest } from "./inbound-request.mjs";
|
|
10
|
+
import { plainTelegramText, splitTelegramText, telegramMarkdown } from "./format.mjs";
|
|
11
|
+
import { inTelegramSendQueue } from "./send-queue.mjs";
|
|
9
12
|
import { randomBytes } from "node:crypto";
|
|
10
13
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
14
|
const deliveryLocale = new AsyncLocalStorage();
|
|
@@ -83,7 +86,47 @@ function customerText(text, locale = currentLocale()) {
|
|
|
83
86
|
if (!isTechnicalText(text)) return text;
|
|
84
87
|
return failureText(reportTelegramError("technical_text_blocked", { text }), "response", locale);
|
|
85
88
|
}
|
|
86
|
-
function
|
|
89
|
+
function customerMessage(text, locale = currentLocale()) {
|
|
90
|
+
let message;
|
|
91
|
+
try {
|
|
92
|
+
message = telegramMarkdown(text);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
reportTelegramError("markdown_render_failed", error);
|
|
95
|
+
message = {
|
|
96
|
+
text: plainTelegramText(text),
|
|
97
|
+
entities: []
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const links = message.entities.flatMap((entity) => entity.url === void 0 ? [] : [entity.url]);
|
|
101
|
+
const visible = customerText(message.text, locale);
|
|
102
|
+
if (visible !== message.text) return {
|
|
103
|
+
text: visible,
|
|
104
|
+
entities: []
|
|
105
|
+
};
|
|
106
|
+
if (links.some(isTechnicalText)) return {
|
|
107
|
+
text: failureText(reportTelegramError("unsafe_markdown_link", new Error("Credential-bearing link refused.")), "response", locale),
|
|
108
|
+
entities: []
|
|
109
|
+
};
|
|
110
|
+
return message;
|
|
111
|
+
}
|
|
112
|
+
async function formattingRefused(response) {
|
|
113
|
+
if (response.status !== 400) return false;
|
|
114
|
+
const body = await response.clone().json().catch(() => null);
|
|
115
|
+
return body?.ok === false && typeof body.description === "string" && (/can't parse entities|cannot parse entities|entity (?:bounds|offset|length)|entities (?:are|too)/iu.test(body.description) || /unsupported (?:start tag|parse mode)/iu.test(body.description));
|
|
116
|
+
}
|
|
117
|
+
var TelegramDeliveryError = class extends Error {
|
|
118
|
+
reference;
|
|
119
|
+
delivery;
|
|
120
|
+
messageIds;
|
|
121
|
+
name = "TelegramDeliveryError";
|
|
122
|
+
constructor(reference, delivery, messageIds) {
|
|
123
|
+
super(`Telegram delivery ${delivery}; reference ${reference}. Do not automatically resend.`);
|
|
124
|
+
this.reference = reference;
|
|
125
|
+
this.delivery = delivery;
|
|
126
|
+
this.messageIds = messageIds;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
function safeTelegramFetch(fetchImpl, scope = "telegram") {
|
|
87
130
|
const methods = new Set([
|
|
88
131
|
"sendMessage",
|
|
89
132
|
"editMessageText",
|
|
@@ -97,7 +140,18 @@ function safeTelegramFetch(fetchImpl) {
|
|
|
97
140
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
98
141
|
if (!methods.has(url.split("/").at(-1) ?? "") || typeof init?.body !== "string") return fetchImpl(input, init);
|
|
99
142
|
const body = JSON.parse(init.body);
|
|
100
|
-
for (const field of ["text", "caption"]) if (typeof body[field] === "string"
|
|
143
|
+
for (const field of ["text", "caption"]) if (typeof body[field] === "string" && !isExactChatPostCard(body[field])) {
|
|
144
|
+
const entityField = field === "text" ? "entities" : "caption_entities";
|
|
145
|
+
const existing = body[entityField];
|
|
146
|
+
const message = Array.isArray(existing) ? {
|
|
147
|
+
text: body[field],
|
|
148
|
+
entities: existing
|
|
149
|
+
} : customerMessage(body[field]);
|
|
150
|
+
const guarded = customerText(message.text);
|
|
151
|
+
body[field] = message.entities.some((entity) => typeof entity.url === "string" && isTechnicalText(entity.url)) ? failureText(reportTelegramError("unsafe_entity_link", new Error("Credential-bearing link refused.")), "response") : guarded;
|
|
152
|
+
if (!url.endsWith("/answerCallbackQuery")) body[entityField] = body[field] === message.text ? message.entities : [];
|
|
153
|
+
delete body.parse_mode;
|
|
154
|
+
}
|
|
101
155
|
if (Array.isArray(body.media)) body.media = body.media.map((item) => ({
|
|
102
156
|
...item,
|
|
103
157
|
...typeof item.caption === "string" ? { caption: customerText(item.caption) } : {}
|
|
@@ -111,10 +165,57 @@ function safeTelegramFetch(fetchImpl) {
|
|
|
111
165
|
text: currentLocale().startsWith("ru") ? "Продолжить" : "Continue"
|
|
112
166
|
};
|
|
113
167
|
}));
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
168
|
+
const send = async () => {
|
|
169
|
+
const parts = url.endsWith("/sendMessage") && typeof body.text === "string" && !isExactChatPostCard(body.text) ? splitTelegramText({
|
|
170
|
+
text: body.text,
|
|
171
|
+
entities: body.entities ?? []
|
|
172
|
+
}) : [null];
|
|
173
|
+
let first;
|
|
174
|
+
const messageIds = [];
|
|
175
|
+
const attempt = async (outgoing) => {
|
|
176
|
+
try {
|
|
177
|
+
return await fetchImpl(input, {
|
|
178
|
+
...init,
|
|
179
|
+
body: JSON.stringify(outgoing)
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new TelegramDeliveryError(reportTelegramError("telegram_send_unknown", error, { messageIds }), "unknown", messageIds);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
for (const [index, part] of parts.entries()) {
|
|
186
|
+
const outgoing = part === null ? { ...body } : {
|
|
187
|
+
...body,
|
|
188
|
+
text: part.text,
|
|
189
|
+
entities: part.entities
|
|
190
|
+
};
|
|
191
|
+
if (index < parts.length - 1) delete outgoing.reply_markup;
|
|
192
|
+
let response = await attempt(outgoing);
|
|
193
|
+
const entityField = typeof outgoing.text === "string" ? "entities" : "caption_entities";
|
|
194
|
+
if (Array.isArray(outgoing[entityField]) && outgoing[entityField].length > 0 && await formattingRefused(response)) {
|
|
195
|
+
delete outgoing[entityField];
|
|
196
|
+
delete outgoing.parse_mode;
|
|
197
|
+
response = await attempt(outgoing);
|
|
198
|
+
}
|
|
199
|
+
const result = await response.clone().json().catch(() => null);
|
|
200
|
+
if (url.endsWith("/sendMessage")) {
|
|
201
|
+
const id = result?.result?.message_id;
|
|
202
|
+
if (!response.ok || result?.ok !== true || typeof id !== "number" || !Number.isSafeInteger(id) || id < 1) {
|
|
203
|
+
const delivery = result?.ok === false ? "failed" : "unknown";
|
|
204
|
+
throw new TelegramDeliveryError(reportTelegramError("telegram_send_unacknowledged", {
|
|
205
|
+
status: response.status,
|
|
206
|
+
result,
|
|
207
|
+
messageIds,
|
|
208
|
+
delivery
|
|
209
|
+
}), delivery, messageIds);
|
|
210
|
+
}
|
|
211
|
+
messageIds.push(id);
|
|
212
|
+
} else if (!response.ok || result?.ok !== true) return response;
|
|
213
|
+
first ??= response;
|
|
214
|
+
}
|
|
215
|
+
if (first === void 0) throw new Error("Telegram message has no visible text; nothing was sent.");
|
|
216
|
+
return first;
|
|
217
|
+
};
|
|
218
|
+
return typeof body.chat_id === "string" || typeof body.chat_id === "number" ? inTelegramSendQueue(scope, String(body.chat_id), send) : send();
|
|
118
219
|
};
|
|
119
220
|
}
|
|
120
|
-
export { currentLocale, currentThreadId, customerCopy, customerText, failureText, isTechnicalText, reportTelegramError, safeTelegramFetch, withDeliveryLocale };
|
|
221
|
+
export { TelegramDeliveryError, currentLocale, currentThreadId, customerCopy, customerMessage, customerText, failureText, formattingRefused, isTechnicalText, reportTelegramError, safeTelegramFetch, withDeliveryLocale };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface TelegramEntity {
|
|
2
|
+
readonly type: string;
|
|
3
|
+
readonly offset: number;
|
|
4
|
+
readonly length: number;
|
|
5
|
+
readonly url?: string;
|
|
6
|
+
readonly language?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TelegramText {
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly entities: readonly TelegramEntity[];
|
|
11
|
+
}
|
|
12
|
+
/** Render Markdown as visible text plus UTF-16 entities; no HTML/Markdown parse mode is needed. */
|
|
13
|
+
export declare function telegramMarkdown(markdown: string): TelegramText;
|
|
14
|
+
/** A failed renderer still produces customer prose, without obvious Markdown/decorative markers. */
|
|
15
|
+
export declare function plainTelegramText(text: string): string;
|
|
16
|
+
/** Paragraph, line, sentence, word, then hard boundaries. All lengths/offsets are UTF-16. */
|
|
17
|
+
export declare function splitTelegramText(message: TelegramText, maximum?: number): readonly TelegramText[];
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { fileURLToPath as __eveFileURLToPath } from "node:url";
|
|
2
|
+
import { dirname as __eveDirname } from "node:path";
|
|
3
|
+
import { createRequire as __eveCreateRequire } from "node:module";
|
|
4
|
+
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
|
+
__eveDirname(__filename);
|
|
6
|
+
__eveCreateRequire(import.meta.url);
|
|
7
|
+
import { Lexer } from "marked";
|
|
8
|
+
function telegramMarkdown(markdown) {
|
|
9
|
+
const source = markdown.replace(/^[ \t]*[━─═]{3,}[ \t]*$/gmu, "");
|
|
10
|
+
const entities = [];
|
|
11
|
+
let text = "";
|
|
12
|
+
let nodes = 0;
|
|
13
|
+
const append = (value) => {
|
|
14
|
+
text += value;
|
|
15
|
+
};
|
|
16
|
+
function span(type, run, extra = {}) {
|
|
17
|
+
const offset = text.length;
|
|
18
|
+
run();
|
|
19
|
+
if (text.length > offset) entities.push({
|
|
20
|
+
type,
|
|
21
|
+
offset,
|
|
22
|
+
length: text.length - offset,
|
|
23
|
+
...extra
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function render(tokens = [], depth = 0) {
|
|
27
|
+
if (depth > 32) throw new Error("Markdown nesting exceeds the Telegram rendering limit.");
|
|
28
|
+
for (const token of tokens) {
|
|
29
|
+
if (++nodes > 2e4) throw new Error("Markdown exceeds the Telegram rendering limit.");
|
|
30
|
+
switch (token.type) {
|
|
31
|
+
case "strong":
|
|
32
|
+
case "em":
|
|
33
|
+
case "del":
|
|
34
|
+
span(token.type === "strong" ? "bold" : token.type === "em" ? "italic" : "strikethrough", () => render(token.tokens, depth + 1));
|
|
35
|
+
break;
|
|
36
|
+
case "heading":
|
|
37
|
+
span("bold", () => render(token.tokens, depth + 1));
|
|
38
|
+
append("\n\n");
|
|
39
|
+
break;
|
|
40
|
+
case "paragraph":
|
|
41
|
+
render(token.tokens, depth + 1);
|
|
42
|
+
append("\n\n");
|
|
43
|
+
break;
|
|
44
|
+
case "blockquote":
|
|
45
|
+
span("blockquote", () => render(token.tokens, depth + 1));
|
|
46
|
+
break;
|
|
47
|
+
case "codespan":
|
|
48
|
+
span("code", () => append(token.text));
|
|
49
|
+
break;
|
|
50
|
+
case "code":
|
|
51
|
+
span("pre", () => append(token.text), token.lang ? { language: token.lang.split(/\s/u)[0] } : {});
|
|
52
|
+
append("\n\n");
|
|
53
|
+
break;
|
|
54
|
+
case "link": {
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(token.href);
|
|
58
|
+
} catch {
|
|
59
|
+
render(token.tokens, depth + 1);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (![
|
|
63
|
+
"https:",
|
|
64
|
+
"http:",
|
|
65
|
+
"mailto:",
|
|
66
|
+
"tel:"
|
|
67
|
+
].includes(url.protocol) || url.username || url.password) throw new Error("Markdown link has an unsupported or credential-bearing destination.");
|
|
68
|
+
span("text_link", () => render(token.tokens, depth + 1), { url: token.href });
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "list":
|
|
72
|
+
for (const [index, item] of token.items.entries()) {
|
|
73
|
+
append(token.ordered ? `${Number(token.start) + index}. ` : "• ");
|
|
74
|
+
if (item.task) append(item.checked ? "☑ " : "☐ ");
|
|
75
|
+
render(item.tokens, depth + 1);
|
|
76
|
+
if (!text.endsWith("\n")) append("\n");
|
|
77
|
+
}
|
|
78
|
+
append("\n");
|
|
79
|
+
break;
|
|
80
|
+
case "table":
|
|
81
|
+
for (const [index, row] of [token.header, ...token.rows].entries()) {
|
|
82
|
+
for (const [cellIndex, cell] of row.entries()) {
|
|
83
|
+
if (cellIndex > 0) append(" | ");
|
|
84
|
+
if (index === 0) span("bold", () => render(cell.tokens, depth + 1));
|
|
85
|
+
else render(cell.tokens, depth + 1);
|
|
86
|
+
}
|
|
87
|
+
append("\n");
|
|
88
|
+
}
|
|
89
|
+
append("\n");
|
|
90
|
+
break;
|
|
91
|
+
case "image":
|
|
92
|
+
append(token.text);
|
|
93
|
+
break;
|
|
94
|
+
case "hr":
|
|
95
|
+
append("\n\n");
|
|
96
|
+
break;
|
|
97
|
+
case "br":
|
|
98
|
+
append("\n");
|
|
99
|
+
break;
|
|
100
|
+
case "space": break;
|
|
101
|
+
case "def": break;
|
|
102
|
+
case "text":
|
|
103
|
+
if (token.tokens) render(token.tokens, depth + 1);
|
|
104
|
+
else append(token.text);
|
|
105
|
+
break;
|
|
106
|
+
default: append("text" in token && typeof token.text === "string" ? token.text : token.raw);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
render(Lexer.lex(source, { gfm: true }));
|
|
111
|
+
text = text.trimEnd();
|
|
112
|
+
for (const match of text.matchAll(/(?<![\w@])@[a-z][a-z0-9_]{4,31}(?!\w)/giu)) {
|
|
113
|
+
const offset = match.index;
|
|
114
|
+
const end = offset + match[0].length;
|
|
115
|
+
if (!entities.some((entity) => [
|
|
116
|
+
"code",
|
|
117
|
+
"pre",
|
|
118
|
+
"text_link"
|
|
119
|
+
].includes(entity.type) && entity.offset < end && entity.offset + entity.length > offset)) entities.push({
|
|
120
|
+
type: "mention",
|
|
121
|
+
offset,
|
|
122
|
+
length: match[0].length
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
text,
|
|
127
|
+
entities: entities.map((entity) => ({
|
|
128
|
+
...entity,
|
|
129
|
+
length: Math.min(entity.length, text.length - entity.offset)
|
|
130
|
+
})).filter((entity) => entity.length > 0).sort((a, b) => a.offset - b.offset || b.length - a.length)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function plainTelegramText(text) {
|
|
134
|
+
return text.replace(/^[ \t]*[━─═]{3,}[ \t]*$/gmu, "").replace(/^\s*```[^\n]*\n?|```\s*$/gmu, "").replace(/!?\[([^\]]+)\]\([^\n)]+\)/gu, "$1").replace(/(?:\*\*|__|~~|`)/gu, "").replace(/(?<![\w@])([*_])([^\n*_]+)\1(?!\w)/gu, "$2").replace(/^ {0,3}(?:>\s*)+/gmu, "").replace(/^ {0,3}#{1,6}\s+/gmu, "").replace(/^ {0,3}[-*+]\s+/gmu, "• ").trim();
|
|
135
|
+
}
|
|
136
|
+
function splitTelegramText(message, maximum = 4096) {
|
|
137
|
+
if (!Number.isSafeInteger(maximum) || maximum < 2) throw new RangeError("Telegram split size must be at least 2 UTF-16 units.");
|
|
138
|
+
const parts = [];
|
|
139
|
+
let start = 0;
|
|
140
|
+
while (start < message.text.length) {
|
|
141
|
+
let end = Math.min(start + maximum, message.text.length);
|
|
142
|
+
if (end < message.text.length) {
|
|
143
|
+
const window = message.text.slice(start, end);
|
|
144
|
+
const paragraph = window.lastIndexOf("\n\n");
|
|
145
|
+
const line = window.lastIndexOf("\n");
|
|
146
|
+
const sentence = [...window.matchAll(/[.!?。!?][ \t]+/gu)].at(-1);
|
|
147
|
+
const word = window.lastIndexOf(" ");
|
|
148
|
+
const boundary = paragraph > 0 ? paragraph + 2 : line > 0 ? line + 1 : sentence?.index !== void 0 ? sentence.index + sentence[0].length : word > 0 ? word + 1 : maximum;
|
|
149
|
+
end = window.slice(0, boundary).trim() === "" ? Math.min(start + maximum, message.text.length) : start + boundary;
|
|
150
|
+
for (const entity of message.entities) if (entity.offset > start && entity.offset < end && entity.offset + entity.length > end) end = entity.offset;
|
|
151
|
+
if (/^[\uDC00-\uDFFF]$/u.test(message.text[end] ?? "") && /^[\uD800-\uDBFF]$/u.test(message.text[end - 1] ?? "")) end -= 1;
|
|
152
|
+
}
|
|
153
|
+
if (end <= start) end = Math.min(start + maximum, message.text.length);
|
|
154
|
+
const entities = message.entities.flatMap((entity) => {
|
|
155
|
+
const left = Math.max(start, entity.offset);
|
|
156
|
+
const right = Math.min(end, entity.offset + entity.length);
|
|
157
|
+
return right > left ? [{
|
|
158
|
+
...entity,
|
|
159
|
+
offset: left - start,
|
|
160
|
+
length: right - left
|
|
161
|
+
}] : [];
|
|
162
|
+
});
|
|
163
|
+
parts.push({
|
|
164
|
+
text: message.text.slice(start, end),
|
|
165
|
+
entities
|
|
166
|
+
});
|
|
167
|
+
start = end;
|
|
168
|
+
}
|
|
169
|
+
return parts;
|
|
170
|
+
}
|
|
171
|
+
export { plainTelegramText, splitTelegramText, telegramMarkdown };
|
|
@@ -4,7 +4,7 @@ import { createRequire as __eveCreateRequire } from "node:module";
|
|
|
4
4
|
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
|
-
import { runtimeRequestFailure } from "./
|
|
7
|
+
import { runtimeRequestFailure } from "./runtime-request.mjs";
|
|
8
8
|
import { GET } from "eve/channels";
|
|
9
9
|
const HEALTH_ROUTE = "/eve/v1/telegram/health";
|
|
10
10
|
function healthRoute(input) {
|
|
@@ -42,7 +42,7 @@ export declare class InboundRequest {
|
|
|
42
42
|
waitUntil: ((task: Promise<unknown>) => void) | undefined;
|
|
43
43
|
/** Continues the exact pending question's session, including under thread scope. */
|
|
44
44
|
address: string | undefined;
|
|
45
|
-
respond: ((address: string, requestId: string, decision: "approve" | "cancel", principal: SessionAuthContext) => Promise<unknown>) | undefined;
|
|
45
|
+
respond: ((address: string, requestId: string, decision: "approve" | "cancel", principal: SessionAuthContext, text?: string) => Promise<unknown>) | undefined;
|
|
46
46
|
constructor(
|
|
47
47
|
/** The agent's description from the compiled manifest, for the routing decision. */
|
|
48
48
|
agentDescription: () => Promise<string>);
|
|
@@ -25,4 +25,4 @@ export declare function telegramProxyRoute(url: string): {
|
|
|
25
25
|
readonly path: string;
|
|
26
26
|
};
|
|
27
27
|
/** The `fetch` eve's Telegram channel calls; see the module comment. */
|
|
28
|
-
export declare function telegramProxyFetch(proxy: ConnectionProxy): typeof fetch;
|
|
28
|
+
export declare function telegramProxyFetch(proxy: ConnectionProxy, scope?: string): typeof fetch;
|
|
@@ -4,6 +4,7 @@ import { createRequire as __eveCreateRequire } from "node:module";
|
|
|
4
4
|
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
5
|
__eveDirname(__filename);
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
|
+
import { inTelegramSendQueue } from "./send-queue.mjs";
|
|
7
8
|
const TELEGRAM_PROXY_ORIGIN = "https://telegram.segmentstream.invalid";
|
|
8
9
|
const TELEGRAM_PLACEHOLDER_TOKEN = "segmentstream-connection";
|
|
9
10
|
const METHOD_PREFIX = `${TELEGRAM_PROXY_ORIGIN}/bot${TELEGRAM_PLACEHOLDER_TOKEN}/`;
|
|
@@ -46,7 +47,7 @@ function proxyMethod(method) {
|
|
|
46
47
|
function hasNullBody(status) {
|
|
47
48
|
return status === 101 || status === 204 || status === 205 || status === 304;
|
|
48
49
|
}
|
|
49
|
-
function telegramProxyFetch(proxy) {
|
|
50
|
+
function telegramProxyFetch(proxy, scope = "telegram") {
|
|
50
51
|
return async (input, init) => {
|
|
51
52
|
const route = telegramProxyRoute(requestUrl(input));
|
|
52
53
|
const request = new Request(input, init);
|
|
@@ -57,11 +58,18 @@ function telegramProxyFetch(proxy) {
|
|
|
57
58
|
headers: headerPairs(request.headers),
|
|
58
59
|
body: new Uint8Array(await request.arrayBuffer())
|
|
59
60
|
};
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
status: reply.
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const send = async () => {
|
|
62
|
+
const reply = route.api === "file" ? await proxy.download(call) : await proxy.request(call);
|
|
63
|
+
return new Response(hasNullBody(reply.status) ? null : Buffer.from(reply.body), {
|
|
64
|
+
status: reply.status,
|
|
65
|
+
headers: reply.headers.map(([name, value]) => [name, value])
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
if (route.api !== "bot" || !/^\/(?:send|editMessage|deleteMessage)/u.test(route.path)) return send();
|
|
69
|
+
const type = request.headers.get("content-type") ?? "";
|
|
70
|
+
const body = type.startsWith("multipart/form-data") ? await new Response(Buffer.from(call.body), { headers: request.headers }).formData() : type.includes("application/json") ? JSON.parse(new TextDecoder().decode(call.body)) : {};
|
|
71
|
+
const chatId = body instanceof FormData ? body.get("chat_id") : body.chat_id;
|
|
72
|
+
return typeof chatId === "string" || typeof chatId === "number" ? inTelegramSendQueue(scope, String(chatId), send) : send();
|
|
65
73
|
};
|
|
66
74
|
}
|
|
67
75
|
export { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, TelegramProxyRouteError, telegramProxyFetch, telegramProxyRoute };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
2
|
+
/** Authenticate internal channel routes with the production deployment's runtime identity. */
|
|
3
|
+
export declare function runtimeRequestFailure(request: Request, options: {
|
|
4
|
+
readonly enabled: () => boolean;
|
|
5
|
+
readonly configured: () => boolean;
|
|
6
|
+
readonly verify: () => RuntimeTokenVerifier;
|
|
7
|
+
readonly log?: (message: string, detail: unknown) => void;
|
|
8
|
+
}): Promise<Response | undefined>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fileURLToPath as __eveFileURLToPath } from "node:url";
|
|
2
|
+
import { dirname as __eveDirname } from "node:path";
|
|
3
|
+
import { createRequire as __eveCreateRequire } from "node:module";
|
|
4
|
+
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
|
+
__eveDirname(__filename);
|
|
6
|
+
__eveCreateRequire(import.meta.url);
|
|
7
|
+
function bearer(header) {
|
|
8
|
+
return (header === null ? null : /^Bearer\s+(\S+)$/i.exec(header.trim()))?.[1];
|
|
9
|
+
}
|
|
10
|
+
function error(code, status) {
|
|
11
|
+
return Response.json({ error: code }, { status });
|
|
12
|
+
}
|
|
13
|
+
async function runtimeRequestFailure(request, options) {
|
|
14
|
+
const log = options.log ?? ((message, detail) => console.warn(message, detail));
|
|
15
|
+
if (!options.enabled()) return error("channels_disabled", 503);
|
|
16
|
+
if (!options.configured()) return error("not_found", 404);
|
|
17
|
+
const token = bearer(request.headers.get("authorization"));
|
|
18
|
+
if (token === void 0) return error("unauthorized", 401);
|
|
19
|
+
let verifier;
|
|
20
|
+
try {
|
|
21
|
+
verifier = options.verify();
|
|
22
|
+
} catch (cause) {
|
|
23
|
+
log("The agent cannot verify requests: the runtime identity is not configured.", cause);
|
|
24
|
+
return error("unavailable", 503);
|
|
25
|
+
}
|
|
26
|
+
const verification = await verifier(token);
|
|
27
|
+
if (verification.status === "unavailable") {
|
|
28
|
+
log("The agent cannot verify requests: the runtime key set is unavailable.", verification.reason);
|
|
29
|
+
return error("unavailable", 503);
|
|
30
|
+
}
|
|
31
|
+
if (verification.status !== "valid") {
|
|
32
|
+
log("The agent refused a request with an invalid runtime token.", verification.reason);
|
|
33
|
+
return error("unauthorized", 401);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export { runtimeRequestFailure };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Environment } from "../environment.js";
|
|
2
|
+
export declare function telegramSendScope(environment: Environment, connection: string): string;
|
|
3
|
+
/** One FIFO per bot/chat in this runtime, shared by all adapters, topics and attachment tools. */
|
|
4
|
+
export declare function inTelegramSendQueue<T>(scope: string, chatId: string, run: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { fileURLToPath as __eveFileURLToPath } from "node:url";
|
|
2
|
+
import { dirname as __eveDirname } from "node:path";
|
|
3
|
+
import { createRequire as __eveCreateRequire } from "node:module";
|
|
4
|
+
const __filename = __eveFileURLToPath(import.meta.url);
|
|
5
|
+
__eveDirname(__filename);
|
|
6
|
+
__eveCreateRequire(import.meta.url);
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
const held = new AsyncLocalStorage();
|
|
10
|
+
function telegramSendScope(environment, connection) {
|
|
11
|
+
return JSON.stringify([environment.SEGMENTSTREAM_WORKSPACE_ID ?? "", connection]);
|
|
12
|
+
}
|
|
13
|
+
function inTelegramSendQueue(scope, chatId, run) {
|
|
14
|
+
const key = JSON.stringify([scope, chatId]);
|
|
15
|
+
if (held.getStore()?.get(key)?.active) return run();
|
|
16
|
+
const task = (pending.get(key) ?? Promise.resolve()).catch(() => void 0).then(() => {
|
|
17
|
+
const lease = { active: true };
|
|
18
|
+
const context = new Map(held.getStore());
|
|
19
|
+
context.set(key, lease);
|
|
20
|
+
return held.run(context, async () => {
|
|
21
|
+
try {
|
|
22
|
+
return await run();
|
|
23
|
+
} finally {
|
|
24
|
+
lease.active = false;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
pending.set(key, task);
|
|
29
|
+
const cleanup = () => {
|
|
30
|
+
if (pending.get(key) === task) pending.delete(key);
|
|
31
|
+
};
|
|
32
|
+
task.then(cleanup, cleanup);
|
|
33
|
+
return task;
|
|
34
|
+
}
|
|
35
|
+
export { inTelegramSendQueue, telegramSendScope };
|
|
@@ -5,9 +5,9 @@ export declare function isSilentReply(text: string): boolean;
|
|
|
5
5
|
export declare function completionKind(message: string | null | undefined, finishReason?: string): "reply" | "tool_calls" | "empty" | "whitespace" | "silent_marker";
|
|
6
6
|
type MessageCompleted = NonNullable<TelegramChannelEvents["message.completed"]>;
|
|
7
7
|
/**
|
|
8
|
-
* The channel's
|
|
9
|
-
*
|
|
8
|
+
* The channel's final reply: formatted and split before Eve's plain-text splitter, with one
|
|
9
|
+
* queue slot for the whole reply. Tool-call steps and explicit silence markers have no delivery.
|
|
10
10
|
*/
|
|
11
|
-
export declare function replyDelivery(observed?: ObservedMessages): MessageCompleted;
|
|
11
|
+
export declare function replyDelivery(observed?: ObservedMessages, scope?: () => string): MessageCompleted;
|
|
12
12
|
export declare const deliverUnlessSilent: MessageCompleted;
|
|
13
13
|
export {};
|
|
@@ -6,7 +6,9 @@ __eveDirname(__filename);
|
|
|
6
6
|
__eveCreateRequire(import.meta.url);
|
|
7
7
|
import "./principal.mjs";
|
|
8
8
|
import { agentMessage, observedChatKey } from "./observed.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { splitTelegramText } from "./format.mjs";
|
|
10
|
+
import { inTelegramSendQueue } from "./send-queue.mjs";
|
|
11
|
+
import { TelegramDeliveryError, currentLocale, customerMessage, reportTelegramError } from "./customer-text.mjs";
|
|
10
12
|
import { confirmationLocale } from "./confirmation-text.mjs";
|
|
11
13
|
const SILENT_MARKERS = new Set(["[silent]", "no_reply"]);
|
|
12
14
|
function isSilentReply(text) {
|
|
@@ -18,11 +20,24 @@ function completionKind(message, finishReason) {
|
|
|
18
20
|
if (message.trim() === "") return "whitespace";
|
|
19
21
|
return isSilentReply(message) ? "silent_marker" : "reply";
|
|
20
22
|
}
|
|
21
|
-
function replyDelivery(observed) {
|
|
23
|
+
function replyDelivery(observed, scope = () => "telegram") {
|
|
22
24
|
return async (event, channel, context) => {
|
|
23
25
|
if (completionKind(event.message, event.finishReason) !== "reply") return;
|
|
24
|
-
const
|
|
25
|
-
await channel.telegram.
|
|
26
|
+
const message = customerMessage(event.message, confirmationLocale(context.session?.auth.current ?? null, currentLocale()));
|
|
27
|
+
await inTelegramSendQueue(scope(), channel.telegram.chatId, async () => {
|
|
28
|
+
const messageIds = [];
|
|
29
|
+
try {
|
|
30
|
+
for (const part of splitTelegramText(message)) {
|
|
31
|
+
const result = await channel.telegram.post(part);
|
|
32
|
+
if (!/^[1-9]\d*$/u.test(result.id)) throw new Error("Telegram did not acknowledge the reply with a message id.");
|
|
33
|
+
messageIds.push(Number(result.id));
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const reference = reportTelegramError("telegram_reply_delivery_failed", error, { messageIds });
|
|
37
|
+
throw new TelegramDeliveryError(reference, error instanceof TelegramDeliveryError ? error.delivery : "unknown", messageIds);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
const text = message.text;
|
|
26
41
|
if (observed === void 0 || channel.telegram.chatType === "private") return;
|
|
27
42
|
const principal = context.session.auth.current;
|
|
28
43
|
const sender = principal?.authenticator === "segmentstream-telegram" ? principal.subject : void 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Mount a SegmentStream workspace's agent on eve: the ontology as tools, acting as the member who writes, and Telegram through the workspace's connection.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"jose": "^6.2.12",
|
|
69
69
|
"jszip": "^3.10.2",
|
|
70
70
|
"mammoth": "^1.13.0",
|
|
71
|
+
"marked": "15.0.12",
|
|
71
72
|
"postal-mime": "^4.0.5",
|
|
72
73
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
73
74
|
},
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { type RouteDefinition } from "eve/channels";
|
|
2
|
-
import type { TelegramChannel, TelegramChannelState } from "eve/channels/telegram";
|
|
3
|
-
import type { SessionAuthContext } from "eve/context";
|
|
4
|
-
import type { SessionSettings } from "../../config.js";
|
|
5
|
-
import type { RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
6
|
-
import { type SessionPolicy } from "./sessions.js";
|
|
7
|
-
/**
|
|
8
|
-
* Requests from the ontology's `agent_chat.send_message` action: an external agent or a member asks
|
|
9
|
-
* the workspace agent to contact someone in a group or a private chat. The ontology service resolves
|
|
10
|
-
* the chat and posts here with its own Vercel OIDC token; the request is delivered into the chat's
|
|
11
|
-
* session through eve's proactive `receive`, without a seed message, so it continues the session
|
|
12
|
-
* the chat's own messages reach (`chatId::`, the chat-scope address) and the replies land there too.
|
|
13
|
-
* The session policy runs on that session first, as it does for an inbound message.
|
|
14
|
-
*/
|
|
15
|
-
export declare const MESSAGES_ROUTE = "/eve/v1/telegram/messages";
|
|
16
|
-
export interface AgentRequest {
|
|
17
|
-
readonly target: {
|
|
18
|
-
readonly kind: "group" | "private";
|
|
19
|
-
readonly provider: "telegram";
|
|
20
|
-
/** The group's chat id, or the Telegram user id of a private chat. */
|
|
21
|
-
readonly chatId: string;
|
|
22
|
-
};
|
|
23
|
-
readonly message: string;
|
|
24
|
-
/** Document record ids from newer ontologies; omitted by older deployments. */
|
|
25
|
-
readonly documentIds?: ReadonlyArray<string>;
|
|
26
|
-
/** Whom to contact, when the requester named someone. */
|
|
27
|
-
readonly person: {
|
|
28
|
-
readonly id: string;
|
|
29
|
-
readonly name: string;
|
|
30
|
-
} | null;
|
|
31
|
-
readonly requester: {
|
|
32
|
-
readonly id: string;
|
|
33
|
-
readonly name: string;
|
|
34
|
-
readonly kind: "member" | "service";
|
|
35
|
-
/** The roles the requester holds; empty from an ontology that does not send them. */
|
|
36
|
-
readonly roles: ReadonlyArray<string>;
|
|
37
|
-
/**
|
|
38
|
-
* Whether the turn can act as the requester: a member with a live paired identity. An ontology
|
|
39
|
-
* that does not send it means a member can.
|
|
40
|
-
*/
|
|
41
|
-
readonly actAs: boolean;
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
/** The request body, or `undefined` when it does not match the contract. */
|
|
45
|
-
export declare function agentRequest(value: unknown): AgentRequest | undefined;
|
|
46
|
-
/**
|
|
47
|
-
* Whom the request's turn runs as. A member requester the ontology says can be acted as is the
|
|
48
|
-
* turn's member, so the ontology connection acts as them and the ontology service checks, as for any
|
|
49
|
-
* turn, that they are active and paired. Their roles, as the action's principal held them, choose the
|
|
50
|
-
* model of a session the request starts; the service resolves them afresh on every call. A service
|
|
51
|
-
* user, or a member who has not paired, cannot be acted as, so the request runs without a member and
|
|
52
|
-
* the turn has no ontology; their roles still choose the model, and grant nothing, since the
|
|
53
|
-
* ontology connection acts only for a `memberId`.
|
|
54
|
-
*/
|
|
55
|
-
export declare function requestPrincipal(request: AgentRequest): SessionAuthContext;
|
|
56
|
-
/** What the turn is about, for the model, ahead of the request itself. */
|
|
57
|
-
export declare function requestContext(request: AgentRequest): string;
|
|
58
|
-
/** Authenticate internal channel routes with the production deployment's runtime identity. */
|
|
59
|
-
export declare function runtimeRequestFailure(request: Request, options: {
|
|
60
|
-
readonly enabled: () => boolean;
|
|
61
|
-
readonly configured: () => boolean;
|
|
62
|
-
readonly verify: () => RuntimeTokenVerifier;
|
|
63
|
-
readonly log?: (message: string, detail: unknown) => void;
|
|
64
|
-
}): Promise<Response | undefined>;
|
|
65
|
-
/**
|
|
66
|
-
* `POST /eve/v1/telegram/messages`. Gated like the webhook: outside production it answers 503 and a
|
|
67
|
-
* workspace without a Telegram channel 404. The caller must be a function of this deployment's
|
|
68
|
-
* project, verified from its Vercel OIDC token.
|
|
69
|
-
*/
|
|
70
|
-
export declare function messagesRoute(input: {
|
|
71
|
-
readonly channel: TelegramChannel;
|
|
72
|
-
readonly enabled: () => boolean;
|
|
73
|
-
readonly configured: () => boolean;
|
|
74
|
-
readonly verify: () => RuntimeTokenVerifier;
|
|
75
|
-
/** The adapter's session policy, which the webhook route applies too. */
|
|
76
|
-
readonly sessionPolicy: SessionPolicy;
|
|
77
|
-
/** The mounted `sessions.scope`. */
|
|
78
|
-
readonly scope: () => SessionSettings["scope"];
|
|
79
|
-
readonly log?: (message: string, detail: unknown) => void;
|
|
80
|
-
}): RouteDefinition<TelegramChannelState>;
|