@llblab/pi-telegram 0.41.1 → 0.42.0

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.
@@ -153,63 +153,153 @@ export function parseTopLevelTelegramComment(
153
153
  };
154
154
  }
155
155
 
156
- function parseCanonicalTelegramActionAttributes(
156
+ function parseTolerantTelegramAttributes(
157
157
  source: string,
158
+ names: readonly string[],
158
159
  ): Record<string, string> | undefined {
159
160
  const attributes: Record<string, string> = {};
160
- const pattern = /\s*([A-Za-z_][A-Za-z0-9_-]*)="([^"]*)"/y;
161
- let offset = 0;
162
- while (offset < source.length) {
163
- pattern.lastIndex = offset;
164
- const match = pattern.exec(source);
165
- if (!match) return undefined;
166
- const value = match[2].trim();
167
- if (value) attributes[match[1]] = value;
168
- offset = pattern.lastIndex;
161
+ const namePattern = names.join("|");
162
+ const pattern = new RegExp(
163
+ `\\b(${namePattern})\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s]+))`,
164
+ "gu",
165
+ );
166
+ for (const match of source.matchAll(pattern)) {
167
+ const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
168
+ if (value) attributes[match[1]!] = value;
169
169
  }
170
170
  return Object.keys(attributes).length > 0 ? attributes : undefined;
171
171
  }
172
172
 
173
- function getTelegramActionPayloadSource(
174
- comment: TelegramTopLevelHtmlComment,
175
- command: string,
176
- ): { source: string; hasBody: boolean } | undefined {
177
- const parsed = parseTopLevelTelegramComment(comment, command);
178
- if (!parsed || parsed.head.trimStart().startsWith(":")) return undefined;
179
- const source = [parsed.head, parsed.body]
180
- .filter((part): part is string => part !== undefined)
181
- .join("\n")
182
- .trim();
183
- return source ? { source, hasBody: parsed.body !== undefined } : undefined;
184
- }
185
-
186
173
  function isTelegramActionPayload(value: unknown): value is Record<string, unknown> {
187
174
  return value !== null && typeof value === "object" && !Array.isArray(value);
188
175
  }
189
176
 
177
+ function removeTelegramJsonTrailingCommas(source: string): string {
178
+ let normalized = "";
179
+ let inString = false;
180
+ let escaped = false;
181
+ for (let offset = 0; offset < source.length; offset += 1) {
182
+ const character = source[offset]!;
183
+ if (inString) {
184
+ normalized += character;
185
+ if (escaped) escaped = false;
186
+ else if (character === "\\") escaped = true;
187
+ else if (character === '"') inString = false;
188
+ continue;
189
+ }
190
+ if (character === '"') {
191
+ inString = true;
192
+ normalized += character;
193
+ continue;
194
+ }
195
+ if (character === ",") {
196
+ let next = offset + 1;
197
+ while (/\s/u.test(source[next] ?? "")) next += 1;
198
+ if (source[next] === "}" || source[next] === "]") continue;
199
+ }
200
+ normalized += character;
201
+ }
202
+ return normalized;
203
+ }
204
+
205
+ function parseTelegramJsonObjectCandidate(
206
+ source: string,
207
+ ): Record<string, unknown> | undefined {
208
+ const normalized = removeTelegramJsonTrailingCommas(source);
209
+ for (const candidate of normalized === source ? [source] : [source, normalized]) {
210
+ try {
211
+ const value: unknown = JSON.parse(candidate);
212
+ if (isTelegramActionPayload(value)) return value;
213
+ } catch {
214
+ // Try the bounded trailing-comma normalization before rejecting JSON.
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ function looksLikeTelegramNamedJsonObject(
221
+ source: string,
222
+ offset: number,
223
+ ): boolean {
224
+ return /^\{\s*"(?:[^"\\]|\\.)*"\s*:/u.test(source.slice(offset));
225
+ }
226
+
190
227
  export function parseTelegramActionPayload(
191
228
  comment: TelegramTopLevelHtmlComment,
192
229
  command: string,
193
230
  ): Record<string, unknown> | undefined {
194
- const payload = getTelegramActionPayloadSource(comment, command);
195
- if (!payload) return undefined;
196
- if (payload.source.startsWith("{")) {
197
- try {
198
- const value: unknown = JSON.parse(payload.source);
199
- return isTelegramActionPayload(value) ? value : undefined;
200
- } catch {
201
- return undefined;
231
+ let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
232
+ if (!content.startsWith(command)) return undefined;
233
+ content = content.slice(command.length);
234
+ let attributeEnvelope = content;
235
+ for (let offset = 0; offset < content.length; offset += 1) {
236
+ if (content[offset] !== "{" && content[offset] !== "[") continue;
237
+ if (
238
+ content[offset] === "[" &&
239
+ !isPlausibleTelegramMatrixStart(content, offset)
240
+ ) {
241
+ const noiseEnd = findTelegramStructuredPayloadEnd(content, offset);
242
+ if (noiseEnd !== undefined) {
243
+ attributeEnvelope = `${attributeEnvelope.slice(0, offset)}${" ".repeat(noiseEnd - offset)}${attributeEnvelope.slice(noiseEnd)}`;
244
+ offset = noiseEnd - 1;
245
+ }
246
+ continue;
247
+ }
248
+ if (content[offset] === "{") {
249
+ const parsed = parseTelegramAdaptiveActionPayloadRows(
250
+ content.slice(offset),
251
+ parseTelegramVoiceCompactActionPayload,
252
+ { allowTrailing: true },
253
+ );
254
+ if (parsed) return parsed.rows[0]![0];
202
255
  }
256
+ const end = findTelegramStructuredPayloadEnd(content, offset);
257
+ if (end === undefined) continue;
258
+ attributeEnvelope = `${attributeEnvelope.slice(0, offset)}${" ".repeat(end - offset)}${attributeEnvelope.slice(end)}`;
259
+ offset = end - 1;
203
260
  }
204
- if (payload.hasBody) return undefined;
205
- return parseCanonicalTelegramActionAttributes(payload.source);
261
+ return parseTolerantTelegramAttributes(attributeEnvelope, [
262
+ "text",
263
+ "value",
264
+ "lang",
265
+ "rate",
266
+ ]);
206
267
  }
207
268
 
208
269
  const TELEGRAM_COMPACT_ACTION_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u;
209
270
 
271
+ type TelegramCompactActionPayloadParser = (
272
+ atoms: readonly string[],
273
+ ) => Record<string, unknown> | undefined;
274
+
275
+ function parseTelegramButtonCompactActionPayload(
276
+ atoms: readonly string[],
277
+ ): Record<string, unknown> | undefined {
278
+ const [label, prompt, selectedStyle] = atoms;
279
+ if (atoms.length === 1) return { value: label };
280
+ if (atoms.length === 2) return { label, prompt };
281
+ if (
282
+ selectedStyle !== "primary" &&
283
+ selectedStyle !== "success" &&
284
+ selectedStyle !== "danger"
285
+ ) return undefined;
286
+ return { label, prompt, selected_style: selectedStyle };
287
+ }
288
+
289
+ function parseTelegramVoiceCompactActionPayload(
290
+ atoms: readonly string[],
291
+ ): Record<string, unknown> | undefined {
292
+ const [text, lang, rate] = atoms;
293
+ if (atoms.length === 1) return { text };
294
+ if (atoms.length === 2) return { text, lang };
295
+ return { text, lang, rate };
296
+ }
297
+
210
298
  function parseTelegramAdaptiveActionPayloadRows(
211
299
  source: string,
212
- ): Record<string, unknown>[][] | undefined {
300
+ parseCompactPayload: TelegramCompactActionPayloadParser,
301
+ options: { allowTrailing?: boolean } = {},
302
+ ): { rows: Record<string, unknown>[][]; end: number } | undefined {
213
303
  let offset = 0;
214
304
  const isStructuralWhitespace = (character: string | undefined): boolean =>
215
305
  character === " " ||
@@ -224,7 +314,7 @@ function parseTelegramAdaptiveActionPayloadRows(
224
314
  if (source[offset] !== ",") return true;
225
315
  offset += 1;
226
316
  skipWhitespace();
227
- return source[offset] !== "," && source[offset] !== "]";
317
+ return source[offset] !== ",";
228
318
  };
229
319
  const normalizeAtom = (value: string): string | undefined => {
230
320
  const normalized = value.trim();
@@ -259,15 +349,7 @@ function parseTelegramAdaptiveActionPayloadRows(
259
349
  normalizeAtom(atom.join("")),
260
350
  );
261
351
  if (atoms.some((atom) => atom === undefined)) return undefined;
262
- const [label, prompt, selectedStyle] = atoms as string[];
263
- if (atoms.length === 1) return { value: label };
264
- if (atoms.length === 2) return { label, prompt };
265
- if (
266
- selectedStyle !== "primary" &&
267
- selectedStyle !== "success" &&
268
- selectedStyle !== "danger"
269
- ) return undefined;
270
- return { label, prompt, selected_style: selectedStyle };
352
+ return parseCompactPayload(atoms as string[]);
271
353
  }
272
354
  atomSources.at(-1)!.push(character);
273
355
  offset += 1;
@@ -304,14 +386,10 @@ function parseTelegramAdaptiveActionPayloadRows(
304
386
  ) return undefined;
305
387
  if (stack.length > 0) continue;
306
388
  const candidate = source.slice(start, index + 1);
307
- try {
308
- const value: unknown = JSON.parse(candidate);
309
- if (!isTelegramActionPayload(value)) return undefined;
310
- offset = index + 1;
311
- return value;
312
- } catch {
313
- return undefined;
314
- }
389
+ const value = parseTelegramJsonObjectCandidate(candidate);
390
+ if (!value) return undefined;
391
+ offset = index + 1;
392
+ return value;
315
393
  }
316
394
  return undefined;
317
395
  };
@@ -320,6 +398,7 @@ function parseTelegramAdaptiveActionPayloadRows(
320
398
  const jsonCell = parseJsonObjectCell();
321
399
  if (jsonCell) return jsonCell;
322
400
  offset = start;
401
+ if (looksLikeTelegramNamedJsonObject(source, start)) return undefined;
323
402
  return parseCompactCell();
324
403
  };
325
404
  const parseRow = (): Record<string, unknown>[] | undefined => {
@@ -377,42 +456,92 @@ function parseTelegramAdaptiveActionPayloadRows(
377
456
  }
378
457
  if (!rows) return undefined;
379
458
  skipWhitespace();
380
- return offset === source.length ? rows : undefined;
459
+ if (!options.allowTrailing && offset !== source.length) return undefined;
460
+ return { rows, end: offset };
461
+ }
462
+
463
+ function findTelegramStructuredPayloadEnd(
464
+ source: string,
465
+ start: number,
466
+ ): number | undefined {
467
+ const stack: string[] = [source[start]!];
468
+ let inString = false;
469
+ let escaped = false;
470
+ for (let offset = start + 1; offset < source.length; offset += 1) {
471
+ const character = source[offset]!;
472
+ if (inString) {
473
+ if (escaped) escaped = false;
474
+ else if (character === "\\") escaped = true;
475
+ else if (character === '"') inString = false;
476
+ continue;
477
+ }
478
+ if (character === '"') {
479
+ inString = true;
480
+ continue;
481
+ }
482
+ if (character === "[" || character === "{") {
483
+ stack.push(character);
484
+ continue;
485
+ }
486
+ if (character !== "]" && character !== "}") continue;
487
+ const expected = character === "]" ? "[" : "{";
488
+ if (stack.at(-1) === expected) stack.pop();
489
+ if (stack.length === 0) return offset + 1;
490
+ }
491
+ return undefined;
492
+ }
493
+
494
+ function isPlausibleTelegramMatrixStart(
495
+ source: string,
496
+ start: number,
497
+ ): boolean {
498
+ let offset = start + 1;
499
+ while (/\s/u.test(source[offset] ?? "")) offset += 1;
500
+ return (
501
+ source[offset] === "{" ||
502
+ source[offset] === "[" ||
503
+ source[offset] === "]"
504
+ );
381
505
  }
382
506
 
383
507
  export function parseTelegramActionPayloadRows(
384
508
  comment: TelegramTopLevelHtmlComment,
385
509
  command: string,
386
510
  ): Record<string, unknown>[][] | undefined {
387
- const payload = getTelegramActionPayloadSource(comment, command);
388
- if (!payload) return undefined;
389
- if (payload.source.startsWith("[") || payload.source.startsWith("{")) {
390
- try {
391
- const value: unknown = JSON.parse(payload.source);
392
- if (isTelegramActionPayload(value)) return [[value]];
393
- if (!Array.isArray(value)) return undefined;
394
- const rows: Record<string, unknown>[][] = [];
395
- for (const entry of value) {
396
- if (isTelegramActionPayload(entry)) {
397
- rows.push([entry]);
398
- continue;
399
- }
400
- if (
401
- !Array.isArray(entry) ||
402
- entry.length === 0 ||
403
- !entry.every(isTelegramActionPayload)
404
- ) {
405
- return undefined;
406
- }
407
- rows.push(entry);
511
+ let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
512
+ if (!content.startsWith(command)) return undefined;
513
+ content = content.slice(command.length);
514
+ let attributeEnvelope = content;
515
+ for (let offset = 0; offset < content.length; offset += 1) {
516
+ if (content[offset] !== "[" && content[offset] !== "{") continue;
517
+ if (
518
+ content[offset] === "[" &&
519
+ !isPlausibleTelegramMatrixStart(content, offset)
520
+ ) {
521
+ const noiseEnd = findTelegramStructuredPayloadEnd(content, offset);
522
+ if (noiseEnd !== undefined) {
523
+ attributeEnvelope = `${attributeEnvelope.slice(0, offset)}${" ".repeat(noiseEnd - offset)}${attributeEnvelope.slice(noiseEnd)}`;
524
+ offset = noiseEnd - 1;
408
525
  }
409
- return rows;
410
- } catch {
411
- return parseTelegramAdaptiveActionPayloadRows(payload.source);
526
+ continue;
412
527
  }
528
+ const parsed = parseTelegramAdaptiveActionPayloadRows(
529
+ content.slice(offset),
530
+ parseTelegramButtonCompactActionPayload,
531
+ { allowTrailing: true },
532
+ );
533
+ if (parsed) return parsed.rows;
534
+ const end = findTelegramStructuredPayloadEnd(content, offset);
535
+ if (end === undefined) continue;
536
+ attributeEnvelope = `${attributeEnvelope.slice(0, offset)}${" ".repeat(end - offset)}${attributeEnvelope.slice(end)}`;
537
+ offset = end - 1;
413
538
  }
414
- if (payload.hasBody) return undefined;
415
- const attributes = parseCanonicalTelegramActionAttributes(payload.source);
539
+ const attributes = parseTolerantTelegramAttributes(attributeEnvelope, [
540
+ "label",
541
+ "prompt",
542
+ "value",
543
+ "selected_style",
544
+ ]);
416
545
  return attributes ? [[attributes]] : undefined;
417
546
  }
418
547
 
@@ -481,9 +610,10 @@ export function planTelegramVoiceReply(
481
610
  let lang: string | undefined;
482
611
  let rate: string | undefined;
483
612
  const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
484
- const command = parseTopLevelTelegramComment(comment, "telegram_voice");
485
- if (!command) return comment.raw;
486
- const payload = parseTelegramActionPayload(comment, "telegram_voice");
613
+ const command = "telegram_voice";
614
+ const normalizedContent = comment.content.replace(/^\s+/, "").replace(/^!/, "");
615
+ if (!normalizedContent.startsWith(command)) return comment.raw;
616
+ const payload = parseTelegramActionPayload(comment, command);
487
617
  if (!payload) return "";
488
618
  const text =
489
619
  getTelegramActionString(payload, "text") ??
package/lib/routing.ts CHANGED
@@ -759,6 +759,42 @@ export function createTelegramInboundRouteRuntime<
759
759
  );
760
760
  }
761
761
  };
762
+ const resolveTelegramThreadLabel = (message: {
763
+ chat: { id: number };
764
+ message_thread_id?: number;
765
+ }): string | undefined => {
766
+ const chatId = message.chat.id;
767
+ const threadId = message.message_thread_id;
768
+ if (!threadId) return undefined;
769
+ const localLabel = deps.getLocalThreadLabelForTarget?.({ chatId, threadId });
770
+ if (localLabel) return localLabel;
771
+ if (!deps.threadStore) return undefined;
772
+ const records = deps.threadStore.list();
773
+ const currentInstanceId = deps.getCurrentInstanceId?.();
774
+ for (const record of records) {
775
+ if (
776
+ record.target.chatId !== chatId ||
777
+ record.target.threadId !== threadId
778
+ ) {
779
+ continue;
780
+ }
781
+ if (
782
+ currentInstanceId &&
783
+ record.instanceId &&
784
+ record.instanceId !== currentInstanceId
785
+ ) {
786
+ continue;
787
+ }
788
+ return record.threadName &&
789
+ Threads.isTelegramTopicThreadNameValidForSlot(
790
+ record.threadName,
791
+ record.slot,
792
+ )
793
+ ? record.threadName
794
+ : getRestoredThreadName(record, record.slot ?? "");
795
+ }
796
+ return undefined;
797
+ };
762
798
  const createAdmissionReceipts = (
763
799
  queueKind: Queue.TelegramQueueItemKind,
764
800
  sources: readonly unknown[],
@@ -1628,6 +1664,13 @@ export function createTelegramInboundRouteRuntime<
1628
1664
  replyToMessageId: messageId,
1629
1665
  queueOrder,
1630
1666
  action,
1667
+ telegramPrefix: Turns.createTelegramTurnPrefix({
1668
+ thread: resolveTelegramThreadLabel({
1669
+ chat: { id: chatId },
1670
+ message_thread_id:
1671
+ buttonQuery.message?.message_thread_id,
1672
+ }),
1673
+ }),
1631
1674
  }),
1632
1675
  ...(admissionReceipts.length > 0 ? { admissionReceipts } : {}),
1633
1676
  };
@@ -1770,34 +1813,7 @@ export function createTelegramInboundRouteRuntime<
1770
1813
 
1771
1814
  // Voice policy resolves missing, invalid, and legacy manual config to hidden.
1772
1815
  getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
1773
- getTelegramThreadLabel(message) {
1774
- if (!deps.threadStore) return undefined;
1775
- const chatId = message.chat.id;
1776
- const threadId = message.message_thread_id;
1777
- if (!threadId) return undefined;
1778
- const localLabel = deps.getLocalThreadLabelForTarget?.({
1779
- chatId,
1780
- threadId,
1781
- });
1782
- if (localLabel) return localLabel;
1783
- const records = deps.threadStore.list();
1784
- const currentInstanceId = deps.getCurrentInstanceId?.();
1785
- for (const r of records) {
1786
- if (r.target.chatId !== chatId || r.target.threadId !== threadId)
1787
- continue;
1788
- if (
1789
- currentInstanceId &&
1790
- r.instanceId &&
1791
- r.instanceId !== currentInstanceId
1792
- )
1793
- continue;
1794
- return r.threadName &&
1795
- Threads.isTelegramTopicThreadNameValidForSlot(r.threadName, r.slot)
1796
- ? r.threadName
1797
- : getRestoredThreadName(r, r.slot ?? "");
1798
- }
1799
- return undefined;
1800
- },
1816
+ getTelegramThreadLabel: resolveTelegramThreadLabel,
1801
1817
  });
1802
1818
  const enqueueContinueTurn = async (
1803
1819
  message: TMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.41.1",
3
+ "version": "0.42.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"