@segmentstream/agent 0.6.3 → 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 +23 -1
- package/dist/extension/lib/telegram/adapter.mjs +3 -2
- 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/conversations.mjs +5 -3
- package/dist/extension/lib/telegram/customer-text.d.ts +13 -1
- package/dist/extension/lib/telegram/customer-text.mjs +107 -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/proxy-fetch.d.ts +1 -1
- package/dist/extension/lib/telegram/proxy-fetch.mjs +14 -6
- 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/README.md
CHANGED
|
@@ -314,6 +314,28 @@ There are no `document_ids` relay arguments, `AgentMessage` transport or
|
|
|
314
314
|
`<agent_request>` turns. Stored file tools keep their member authorization in the
|
|
315
315
|
current conversation; group purpose and document restrictions still apply.
|
|
316
316
|
|
|
317
|
+
## Telegram answer delivery
|
|
318
|
+
|
|
319
|
+
Agent replies render Markdown as visible text and Telegram entities. Headings, emphasis,
|
|
320
|
+
links, lists, quotations and code are supported; decorative separator lines are removed.
|
|
321
|
+
The customer-text guard checks the rendered prose and link destinations. If rendering fails,
|
|
322
|
+
delivery uses clean plain text. Only an explicit Telegram entity-formatting refusal permits
|
|
323
|
+
a plain-text retry; timeouts and other uncertain outcomes must never trigger an automatic resend.
|
|
324
|
+
Attachment captions use the same rendering and fallback rules.
|
|
325
|
+
|
|
326
|
+
Replies split at paragraph, line, sentence, word or UTF-16 boundaries within Telegram's 4096-unit
|
|
327
|
+
limit. Entities are rebased or clipped on each part, and surrogate pairs stay intact. The whole
|
|
328
|
+
reply or attachment batch occupies one shared FIFO slot per workspace/connection/chat, including
|
|
329
|
+
all forum topics. A failed slot releases the next one. Sends require a positive Telegram message
|
|
330
|
+
id; missing acknowledgements are errors and archive as unknown. Partial-delivery errors retain
|
|
331
|
+
the message ids already acknowledged.
|
|
332
|
+
|
|
333
|
+
This queue lives in one SDK runtime. It does not coordinate separate serverless instances or
|
|
334
|
+
the ontology service's exact `chat_post_message` provider. Ordering all of those together needs
|
|
335
|
+
a shared provider sequencer. Exact posting retains its approved literal text and durable receipt;
|
|
336
|
+
this formatter does not rewrite approved posts or their confirmation cards. The removed
|
|
337
|
+
`agent_chat.send_message` instruction relay must not be restored as a delivery fallback.
|
|
338
|
+
|
|
317
339
|
## Local development
|
|
318
340
|
|
|
319
341
|
`eve dev` in `agent/` runs the agent locally. The Telegram channel stays silent outside production.
|
|
@@ -395,7 +417,7 @@ Eve resolution, so member/topic routing survives a cold start. No second provide
|
|
|
395
417
|
|
|
396
418
|
## Release and migration
|
|
397
419
|
|
|
398
|
-
The tested release uses agent SDK 0.6.
|
|
420
|
+
The tested release uses agent SDK 0.6.4, Eve 0.76.3 and ontology SDK 0.28.3.
|
|
399
421
|
Deploy the agent first, then backend archive migration 0033/API, then the ontology
|
|
400
422
|
with its declared Telegram provider mapping and explicit `agent_chat:post_message`
|
|
401
423
|
grant. Older ontologies keep ordinary tools working without the extra envelope.
|
|
@@ -14,6 +14,7 @@ import { agentIdentities } from "../identities.mjs";
|
|
|
14
14
|
import { conversations } from "../conversations.mjs";
|
|
15
15
|
import { observedMessages } from "./observed.mjs";
|
|
16
16
|
import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
|
|
17
|
+
import { telegramSendScope } from "./send-queue.mjs";
|
|
17
18
|
import { failureText, reportTelegramError, safeTelegramFetch, withDeliveryLocale } from "./customer-text.mjs";
|
|
18
19
|
import { captureRouteDecider, captureTelegramFetch, conversationIdentities, telegramConversationCapture } from "./conversations.mjs";
|
|
19
20
|
import { runtimeTokenVerifier } from "../runtime-identity.mjs";
|
|
@@ -127,7 +128,7 @@ function segmentstreamTelegram(options) {
|
|
|
127
128
|
api: {
|
|
128
129
|
apiBaseUrl: TELEGRAM_PROXY_ORIGIN,
|
|
129
130
|
fileBaseUrl: TELEGRAM_PROXY_ORIGIN,
|
|
130
|
-
fetch: (input, init) => safeTelegramFetch(captureTelegramFetch(telegramProxyFetch(proxy()), archive, pairing))(input, init)
|
|
131
|
+
fetch: (input, init) => safeTelegramFetch(captureTelegramFetch(telegramProxyFetch(proxy(), telegramSendScope(environment, settings().connection)), archive, pairing), telegramSendScope(environment, settings().connection))(input, init)
|
|
131
132
|
},
|
|
132
133
|
credentials: {
|
|
133
134
|
botToken: TELEGRAM_PLACEHOLDER_TOKEN,
|
|
@@ -228,7 +229,7 @@ function segmentstreamTelegram(options) {
|
|
|
228
229
|
events: {
|
|
229
230
|
...telegramFailureEvents,
|
|
230
231
|
...telegramConfirmationEvents(confirmationRenderer({ model: () => options.models().decision }), archive),
|
|
231
|
-
"message.completed": replyDelivery(observed)
|
|
232
|
+
"message.completed": replyDelivery(observed, () => telegramSendScope(environment, settings().connection))
|
|
232
233
|
}
|
|
233
234
|
});
|
|
234
235
|
const adapter = channel.adapter;
|
|
@@ -24,7 +24,7 @@ export interface SentFile {
|
|
|
24
24
|
/** Bare ids are document records; explicit references select other entities and file properties. */
|
|
25
25
|
export declare function sendAttachmentsInput(value: Record<string, unknown>): SendAttachmentsInput;
|
|
26
26
|
/** All destinations and credentials come from the channel's turn and mounted configuration. */
|
|
27
|
-
|
|
27
|
+
interface SendOptions {
|
|
28
28
|
readonly input: SendAttachmentsInput;
|
|
29
29
|
readonly principal: SessionAuthContext | null;
|
|
30
30
|
readonly sessionId: string;
|
|
@@ -32,10 +32,14 @@ export declare function sendAttachments(options: {
|
|
|
32
32
|
readonly environment?: Environment;
|
|
33
33
|
readonly token?: RuntimeToken;
|
|
34
34
|
readonly fetch?: typeof fetch;
|
|
35
|
-
}
|
|
35
|
+
}
|
|
36
|
+
type SendResult = {
|
|
36
37
|
readonly files: ReadonlyArray<SentFile>;
|
|
37
38
|
} | {
|
|
38
39
|
readonly error: string;
|
|
39
40
|
readonly detail: string;
|
|
40
41
|
readonly reference: string;
|
|
41
|
-
}
|
|
42
|
+
};
|
|
43
|
+
/** Reserve the chat before downloading, and keep every album/batch in the same queue slot. */
|
|
44
|
+
export declare function sendAttachments(options: SendOptions): Promise<SendResult>;
|
|
45
|
+
export {};
|
|
@@ -11,7 +11,8 @@ import { redactDiagnostic } from "../diagnostics.mjs";
|
|
|
11
11
|
import { runtimeToken } from "../runtime-token.mjs";
|
|
12
12
|
import { ACT_AS_HEADER } from "../ontology-connection.mjs";
|
|
13
13
|
import { AttachmentError, CONVERSATION_HEADER, SESSION_HEADER } from "../attachments.mjs";
|
|
14
|
-
import {
|
|
14
|
+
import { inTelegramSendQueue, telegramSendScope } from "./send-queue.mjs";
|
|
15
|
+
import { customerCopy, customerMessage, formattingRefused, reportTelegramError } from "./customer-text.mjs";
|
|
15
16
|
import { confirmationLocale } from "./confirmation-text.mjs";
|
|
16
17
|
import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch } from "./proxy-fetch.mjs";
|
|
17
18
|
import { attachmentOwner } from "../attachment-tools.mjs";
|
|
@@ -74,7 +75,11 @@ async function boundedDownload(response, maximum) {
|
|
|
74
75
|
}
|
|
75
76
|
return new Uint8Array(Buffer.concat(chunks));
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
function sendAttachments(options) {
|
|
79
|
+
const chatId = options.principal?.attributes.chatId;
|
|
80
|
+
return typeof chatId === "string" && options.connection !== void 0 ? inTelegramSendQueue(telegramSendScope(options.environment ?? process.env, options.connection), chatId, () => sendAttachmentsInOrder(options)) : sendAttachmentsInOrder(options);
|
|
81
|
+
}
|
|
82
|
+
async function sendAttachmentsInOrder(options) {
|
|
78
83
|
const environment = options.environment ?? process.env;
|
|
79
84
|
const principal = options.principal;
|
|
80
85
|
const locale = confirmationLocale(principal);
|
|
@@ -117,7 +122,7 @@ async function sendAttachments(options) {
|
|
|
117
122
|
connection: options.connection,
|
|
118
123
|
token,
|
|
119
124
|
fetch: fetchImpl
|
|
120
|
-
}));
|
|
125
|
+
}), telegramSendScope(environment, options.connection));
|
|
121
126
|
const results = options.input.documentIds.map(({ id }) => ({
|
|
122
127
|
id,
|
|
123
128
|
filename: ""
|
|
@@ -196,12 +201,18 @@ async function sendAttachments(options) {
|
|
|
196
201
|
if (files.length === 1) {
|
|
197
202
|
const file = files[0];
|
|
198
203
|
form.set(file.type, file.blob, file.filename);
|
|
199
|
-
if (caption !== void 0)
|
|
204
|
+
if (caption !== void 0) {
|
|
205
|
+
form.set("caption", caption.text);
|
|
206
|
+
if (caption.entities.length > 0) form.set("caption_entities", JSON.stringify(caption.entities));
|
|
207
|
+
}
|
|
200
208
|
} else {
|
|
201
209
|
form.set("media", JSON.stringify(files.map((file, index) => ({
|
|
202
210
|
type: file.type,
|
|
203
211
|
media: `attach://file${index}`,
|
|
204
|
-
...index === 0 && caption !== void 0 ? {
|
|
212
|
+
...index === 0 && caption !== void 0 ? {
|
|
213
|
+
caption: caption.text,
|
|
214
|
+
...caption.entities.length > 0 ? { caption_entities: caption.entities } : {}
|
|
215
|
+
} : {}
|
|
205
216
|
}))));
|
|
206
217
|
files.forEach((file, index) => form.set(`file${index}`, file.blob, file.filename));
|
|
207
218
|
}
|
|
@@ -211,7 +222,7 @@ async function sendAttachments(options) {
|
|
|
211
222
|
body: form
|
|
212
223
|
});
|
|
213
224
|
}
|
|
214
|
-
let caption = options.input.caption === void 0 ? void 0 :
|
|
225
|
+
let caption = options.input.caption === void 0 ? void 0 : customerMessage(options.input.caption, locale);
|
|
215
226
|
let cursor = 0;
|
|
216
227
|
while (cursor < uploads.length) {
|
|
217
228
|
const batch = [uploads[cursor++]];
|
|
@@ -227,7 +238,14 @@ async function sendAttachments(options) {
|
|
|
227
238
|
}
|
|
228
239
|
try {
|
|
229
240
|
if (size > 2097152) throw refused("too_large", PROXY_LIMIT);
|
|
230
|
-
|
|
241
|
+
let response = await telegram(request);
|
|
242
|
+
if (caption !== void 0 && caption.entities.length > 0 && await formattingRefused(response)) {
|
|
243
|
+
caption = {
|
|
244
|
+
text: caption.text,
|
|
245
|
+
entities: []
|
|
246
|
+
};
|
|
247
|
+
response = await telegram(multipart(batch, caption));
|
|
248
|
+
}
|
|
231
249
|
const answer = await response.json();
|
|
232
250
|
if (!response.ok || !isRecord(answer) || answer.ok !== true) {
|
|
233
251
|
console.error("[segmentstream] Telegram attachment upload refused", {
|
|
@@ -272,12 +272,14 @@ function captureTelegramFetch(fetchImpl, archive, identities) {
|
|
|
272
272
|
}
|
|
273
273
|
throw error;
|
|
274
274
|
}
|
|
275
|
-
const result = record(await response.clone().json());
|
|
275
|
+
const result = record(await response.clone().json().catch(() => null));
|
|
276
|
+
const messageId = id(record(result.result).message_id);
|
|
277
|
+
const acknowledged = edit || messageId !== null && /^[1-9]\d*$/u.test(messageId);
|
|
276
278
|
await archive.append({
|
|
277
279
|
...event,
|
|
278
280
|
eventId: `${event.eventId}:result`,
|
|
279
|
-
status: response.ok && result.ok === true ? "completed" : "failed",
|
|
280
|
-
messageId:
|
|
281
|
+
status: response.ok && result.ok === true && acknowledged ? "completed" : result.ok === false ? "failed" : "unknown",
|
|
282
|
+
messageId: messageId ?? event.messageId
|
|
281
283
|
});
|
|
282
284
|
return response;
|
|
283
285
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type TelegramText } from "./format.js";
|
|
1
2
|
export declare function withDeliveryLocale<T>(locale: string, run: () => T, threadId?: string): T;
|
|
2
3
|
export declare function currentLocale(): string;
|
|
3
4
|
export declare function currentThreadId(): string | undefined;
|
|
@@ -16,5 +17,16 @@ export declare function isTechnicalText(text: string): boolean;
|
|
|
16
17
|
export declare function reportTelegramError(operation: string, error: unknown, metadata?: Record<string, unknown>, secrets?: ReadonlySet<string>): string;
|
|
17
18
|
export declare function failureText(errorId: string, kind?: "failed" | "recovery" | "attachment" | "authorization" | "response", locale?: string): string;
|
|
18
19
|
export declare function customerText(text: string, locale?: string): string;
|
|
20
|
+
/** Check what Telegram will display, including hidden link destinations, after removing markup. */
|
|
21
|
+
export declare function customerMessage(text: string, locale?: string): TelegramText;
|
|
22
|
+
/** Only a definite formatting refusal permits resending; network/unknown failures never do. */
|
|
23
|
+
export declare function formattingRefused(response: Response): Promise<boolean>;
|
|
24
|
+
export declare class TelegramDeliveryError extends Error {
|
|
25
|
+
readonly reference: string;
|
|
26
|
+
readonly delivery: "failed" | "unknown";
|
|
27
|
+
readonly messageIds: readonly number[];
|
|
28
|
+
readonly name = "TelegramDeliveryError";
|
|
29
|
+
constructor(reference: string, delivery: "failed" | "unknown", messageIds: readonly number[]);
|
|
30
|
+
}
|
|
19
31
|
/** Covers framework defaults, edits and callback notices as well as ordinary agent replies. */
|
|
20
|
-
export declare function safeTelegramFetch(fetchImpl: typeof fetch): typeof fetch;
|
|
32
|
+
export declare function safeTelegramFetch(fetchImpl: typeof fetch, scope?: string): typeof fetch;
|
|
@@ -7,6 +7,8 @@ __eveCreateRequire(import.meta.url);
|
|
|
7
7
|
import { isExactChatPostCard } from "./chat-post-card.mjs";
|
|
8
8
|
import { redactDiagnostic } from "../diagnostics.mjs";
|
|
9
9
|
import { currentInboundRequest } from "./inbound-request.mjs";
|
|
10
|
+
import { plainTelegramText, splitTelegramText, telegramMarkdown } from "./format.mjs";
|
|
11
|
+
import { inTelegramSendQueue } from "./send-queue.mjs";
|
|
10
12
|
import { randomBytes } from "node:crypto";
|
|
11
13
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
14
|
const deliveryLocale = new AsyncLocalStorage();
|
|
@@ -84,7 +86,47 @@ function customerText(text, locale = currentLocale()) {
|
|
|
84
86
|
if (!isTechnicalText(text)) return text;
|
|
85
87
|
return failureText(reportTelegramError("technical_text_blocked", { text }), "response", locale);
|
|
86
88
|
}
|
|
87
|
-
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") {
|
|
88
130
|
const methods = new Set([
|
|
89
131
|
"sendMessage",
|
|
90
132
|
"editMessageText",
|
|
@@ -98,7 +140,18 @@ function safeTelegramFetch(fetchImpl) {
|
|
|
98
140
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
99
141
|
if (!methods.has(url.split("/").at(-1) ?? "") || typeof init?.body !== "string") return fetchImpl(input, init);
|
|
100
142
|
const body = JSON.parse(init.body);
|
|
101
|
-
for (const field of ["text", "caption"]) if (typeof body[field] === "string" && !isExactChatPostCard(body[field]))
|
|
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
|
+
}
|
|
102
155
|
if (Array.isArray(body.media)) body.media = body.media.map((item) => ({
|
|
103
156
|
...item,
|
|
104
157
|
...typeof item.caption === "string" ? { caption: customerText(item.caption) } : {}
|
|
@@ -112,10 +165,57 @@ function safeTelegramFetch(fetchImpl) {
|
|
|
112
165
|
text: currentLocale().startsWith("ru") ? "Продолжить" : "Continue"
|
|
113
166
|
};
|
|
114
167
|
}));
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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();
|
|
119
219
|
};
|
|
120
220
|
}
|
|
121
|
-
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 };
|
|
@@ -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,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.6.
|
|
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
|
},
|