pi-braid-telegram 0.1.5 → 0.1.6

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/build-info.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "pi-braid-telegram",
3
- "version": "0.1.5",
4
- "sha": "752d2e6e9b45037a8ebcc17f3d6f90c905b2c71c",
5
- "shaShort": "752d2e6",
6
- "branch": "detached",
3
+ "version": "0.1.6",
4
+ "sha": "dc847759a4c99339571e7583ae848d8ec99e694c",
5
+ "shaShort": "dc84775",
6
+ "branch": "main",
7
7
  "dirty": false,
8
- "packedAt": "2026-10-19T11:29:41.109Z"
8
+ "packedAt": "2026-10-23T21:27:14.009Z"
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-braid-telegram",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Bidirectional Telegram channel for pi-braid: telegram_send tool (outbound) + inbound long-poll injector (backward channel)",
5
5
  "type": "module",
6
6
  "files": [
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  import {
68
68
  createInboundPoller,
69
69
  type IncomingOwnerMessage,
70
+ type IncomingReplyContext,
70
71
  type InboundPoller,
71
72
  type PauseStateReader,
72
73
  } from "./lib/inbound-poller.js";
@@ -374,6 +375,24 @@ export function log(
374
375
 
375
376
  // Inbound formatting (agent-zero prompts/fw.telegram.user_message.md template)
376
377
 
378
+ /**
379
+ * The reply reference line, rendered into the envelope header so the owner's
380
+ * "yes, that one" arrives attached to what it answers. An absent reference
381
+ * renders NOTHING: a message that is not a reply keeps exactly the envelope it
382
+ * had, so a missing or malformed reply field can only ever cost the reference.
383
+ */
384
+ export function formatReplyLine(reply?: IncomingReplyContext): string {
385
+ if (!reply) return "";
386
+ const target =
387
+ reply.author === "agent"
388
+ ? "your message"
389
+ : reply.author === "owner"
390
+ ? "the owner's message"
391
+ : "an unidentified message";
392
+ const quoted = reply.excerpt ? ` — "${reply.excerpt}"` : "";
393
+ return `\nreplying to ${target} ${reply.messageId}${quoted}`;
394
+ }
395
+
377
396
  export function formatInboundMessage(message: IncomingOwnerMessage): string {
378
397
  const sender = message.username
379
398
  ? `@${message.username}`
@@ -387,7 +406,7 @@ export function formatInboundMessage(message: IncomingOwnerMessage): string {
387
406
  : "";
388
407
  // The opening tag line carries nothing after `]`: the provenance line
389
408
  // starts on its own line so the envelope header is readable at a glance.
390
- const header = `[braid-whisper - telegram${sentClock}]\nfrom ${sender}${name} in chat ${message.chatId}/${message.chatType}`;
409
+ const header = `[braid-whisper - telegram${sentClock}]\nfrom ${sender}${name} in chat ${message.chatId}/${message.chatType}${formatReplyLine(message.replyTo)}`;
391
410
  return `${header}\n\n${message.text.trim()}\n[braid-whisper-end]`;
392
411
  }
393
412
 
@@ -410,7 +429,7 @@ export function formatInboundBatch(messages: IncomingOwnerMessage[]): string {
410
429
  const sentClock = Number.isFinite(message.receivedAt)
411
430
  ? ` @ ${formatWhisperClock(message.receivedAt)}`
412
431
  : "";
413
- const partHeader = `[${index + 1}/${messages.length}${sentClock}] from ${sender}${name} in chat ${message.chatId}/${message.chatType}`;
432
+ const partHeader = `[${index + 1}/${messages.length}${sentClock}] from ${sender}${name} in chat ${message.chatId}/${message.chatType}${formatReplyLine(message.replyTo)}`;
414
433
  return `${partHeader}\n\n${message.text.trim()}`;
415
434
  });
416
435
  return `${header}\n\n${parts.join("\n\n")}\n[braid-whisper-end]`;
@@ -69,6 +69,40 @@ import {
69
69
  type SttConfig,
70
70
  } from "./transcribe.js";
71
71
 
72
+ /**
73
+ * One-line cap for the quoted excerpt. The excerpt is untrusted text copied
74
+ * into the prompt header, so it is hard-capped: a multi-KB replied message
75
+ * would ride into every reply for no gain.
76
+ */
77
+ export const REPLY_EXCERPT_MAX_CHARS = 120;
78
+
79
+ /**
80
+ * The message the owner replied to.
81
+ *
82
+ * Carried on the received message rather than resolved on demand: a lookup
83
+ * would put a getMessage round trip inside the receive path, where a failure
84
+ * costs the owner a delivered message, and the update already holds the
85
+ * reference.
86
+ */
87
+ export interface IncomingReplyContext {
88
+ /** `message_id` of the replied message — the name the owner can point at. */
89
+ messageId: number;
90
+ /**
91
+ * Who sent the replied message. "agent" and "owner" mean different things,
92
+ * and conflating them misleads worse than silence: "yes, that one" answering
93
+ * our own message is a confirmation, answering the owner's is a correction.
94
+ * "unknown" is a sender Telegram omitted, or a bot that is not provably ours
95
+ * (a message forwarded from another bot).
96
+ */
97
+ author: "agent" | "owner" | "unknown";
98
+ /**
99
+ * Sanitised one-line excerpt of the replied message's text (or its caption
100
+ * when it carried no text), capped at REPLY_EXCERPT_MAX_CHARS. Absent when
101
+ * the replied message carried neither.
102
+ */
103
+ excerpt?: string;
104
+ }
105
+
72
106
  export interface IncomingOwnerMessage {
73
107
  updateId: number;
74
108
  messageId: number;
@@ -78,6 +112,8 @@ export interface IncomingOwnerMessage {
78
112
  chatId: number;
79
113
  chatType: string;
80
114
  text: string;
115
+ /** Present when the owner replied to a specific message (absent otherwise). */
116
+ replyTo?: IncomingReplyContext;
81
117
  /** Present for photo messages (absent for plain-text). */
82
118
  photo?: {
83
119
  fileId: string;
@@ -144,6 +180,12 @@ export interface WatchdogTunnelStatusEvent {
144
180
  /** Media-extraction context threaded into extractIncomingMessage (photo/voice/document inbound). */
145
181
  export interface IncomingMediaContext {
146
182
  token?: string;
183
+ /**
184
+ * Our own bot id when the caller knows it (a cached getMe result). It only
185
+ * sharpens the reply-reference author verdict; absent, `is_bot` alone
186
+ * decides.
187
+ */
188
+ botId?: number;
147
189
  fetchFn?: TelegramFetch;
148
190
  baseUrl?: string;
149
191
  mediaDir?: string;
@@ -413,6 +455,89 @@ export function isAllowed(
413
455
  return false;
414
456
  }
415
457
 
458
+ /**
459
+ * Read the reply reference from the update the poller already holds.
460
+ *
461
+ * Never throws and never fetches: an absent, malformed, or partly readable
462
+ * `reply_to_message` degrades the REFERENCE (the id alone, or nothing) and
463
+ * leaves the delivery untouched — this channel's contract is that an owner
464
+ * message is never dropped, and a richer envelope must not weaken it.
465
+ */
466
+ export function extractReplyContext(
467
+ message: unknown,
468
+ botId?: number,
469
+ ): IncomingReplyContext | undefined {
470
+ if (message === null || typeof message !== "object") return undefined;
471
+ const raw = (message as { reply_to_message?: unknown }).reply_to_message;
472
+ if (raw === null || typeof raw !== "object") return undefined;
473
+ const reply = raw as {
474
+ message_id?: unknown;
475
+ from?: unknown;
476
+ text?: unknown;
477
+ caption?: unknown;
478
+ };
479
+ if (
480
+ typeof reply.message_id !== "number" ||
481
+ !Number.isFinite(reply.message_id)
482
+ ) {
483
+ return undefined;
484
+ }
485
+ const from = reply.from;
486
+ const sender =
487
+ from !== null && typeof from === "object"
488
+ ? (from as { id?: unknown; is_bot?: unknown })
489
+ : undefined;
490
+ const excerptText =
491
+ typeof reply.text === "string" && reply.text.trim()
492
+ ? reply.text
493
+ : typeof reply.caption === "string" && reply.caption.trim()
494
+ ? reply.caption
495
+ : undefined;
496
+ const excerpt = excerptText ? oneLineExcerpt(excerptText) : undefined;
497
+ return {
498
+ messageId: reply.message_id,
499
+ author: classifyReplyAuthor(sender, botId),
500
+ ...(excerpt ? { excerpt } : {}),
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Whose message was replied to. Telegram marks bot senders with `is_bot`; our
506
+ * own id, when the caller knows it, is the only positive proof that a bot
507
+ * message was OURS, so a bot that cannot be proven ours reads as "unknown"
508
+ * rather than claiming authorship we cannot verify.
509
+ */
510
+ function classifyReplyAuthor(
511
+ sender: { id?: unknown; is_bot?: unknown } | undefined,
512
+ botId: number | undefined,
513
+ ): IncomingReplyContext["author"] {
514
+ if (!sender || typeof sender.id !== "number") return "unknown";
515
+ if (typeof botId === "number") {
516
+ if (sender.id === botId) return "agent";
517
+ return sender.is_bot === true ? "unknown" : "owner";
518
+ }
519
+ return sender.is_bot === true ? "agent" : "owner";
520
+ }
521
+
522
+ /**
523
+ * Flatten the replied text to ONE line and defuse it before it reaches the
524
+ * envelope header: control and format characters (newlines, line separators,
525
+ * zero-width joiners) would let the quoted text forge extra header lines, and
526
+ * a bracketed `[braid-whisper ...]` marker would let it open or close an
527
+ * envelope inside the one it is quoted into. Then cut to the cap, with a
528
+ * visible ellipsis.
529
+ */
530
+ function oneLineExcerpt(raw: string): string | undefined {
531
+ const flat = raw
532
+ .replace(/[\p{Cc}\p{Cf}]+/gu, " ")
533
+ .replace(/\s+/g, " ")
534
+ .trim()
535
+ .replace(/\[\s*braid-whisper([^\]]*)\]/gi, "braid-whisper$1");
536
+ if (!flat) return undefined;
537
+ if (flat.length <= REPLY_EXCERPT_MAX_CHARS) return flat;
538
+ return flat.slice(0, REPLY_EXCERPT_MAX_CHARS - 1).trimEnd() + "…";
539
+ }
540
+
416
541
  /**
417
542
  * Extract an accepted message into the shape handed to consumers (shared by
418
543
  * the background poller and telegram-inbox). Plain-text messages map 1:1.
@@ -438,6 +563,10 @@ export async function extractIncomingMessage(
438
563
  media: IncomingMediaContext = {},
439
564
  ): Promise<IncomingOwnerMessage> {
440
565
  const message = update.message!;
566
+ // Read the reference ONCE and let every variant below inherit it through
567
+ // `base`: the plain-text, photo, voice, document, and text+document returns
568
+ // all spread `base`, so no delivery path can lose it by omission.
569
+ const replyTo = extractReplyContext(message, media.botId);
441
570
  const base = {
442
571
  updateId: update.update_id,
443
572
  messageId: message.message_id,
@@ -447,6 +576,7 @@ export async function extractIncomingMessage(
447
576
  chatId: message.chat.id,
448
577
  chatType: message.chat.type,
449
578
  receivedAt: message.date * 1000,
579
+ ...(replyTo ? { replyTo } : {}),
450
580
  };
451
581
  const text = typeof message.text === "string" ? message.text.trim() : "";
452
582
  const caption =