@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 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.3, Eve 0.76.3 and ontology SDK 0.28.2.
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
- export declare function sendAttachments(options: {
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
- }): Promise<{
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 { customerCopy, customerText, reportTelegramError } from "./customer-text.mjs";
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
- async function sendAttachments(options) {
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) form.set("caption", caption);
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 ? { caption } : {}
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 : customerText(options.input.caption, locale);
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
- const response = await telegram(request);
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: id(record(result.result).message_id) ?? event.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 safeTelegramFetch(fetchImpl) {
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])) body[field] = customerText(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
- return fetchImpl(input, {
116
- ...init,
117
- body: JSON.stringify(body)
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 reply = route.api === "file" ? await proxy.download(call) : await proxy.request(call);
61
- return new Response(hasNullBody(reply.status) ? null : Buffer.from(reply.body), {
62
- status: reply.status,
63
- headers: reply.headers.map(([name, value]) => [name, value])
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 `message.completed` handler: eve's default delivery (plain text through
9
- * `sendMessage`, nothing for a tool-call step) unless the reply is a silence marker.
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 { currentLocale, customerText } from "./customer-text.mjs";
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 text = customerText(event.message, confirmationLocale(context.session?.auth.current ?? null, currentLocale()));
25
- await channel.telegram.post(text);
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",
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
  },