pi-braid-telegram 0.1.4 → 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/README.md +17 -0
- package/build-info.json +9 -0
- package/package.json +8 -6
- package/src/index.ts +21 -2
- package/src/lib/format.ts +17 -4
- package/src/lib/inbound-poller.ts +130 -0
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
Bidirectional Telegram channel for pi-braid.
|
|
6
6
|
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-braid-telegram
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
One command, no clone, no build: `pi install` registers the package with pi and installs it under pi's npm directory.
|
|
12
|
+
|
|
7
13
|
- **Outbound**: `telegram_send` tool - the agent sends text messages to the
|
|
8
14
|
owner (or voice notes once a TTS source is configured). Messages are LONG by
|
|
9
15
|
default: plain text sends verbatim up to the cap (`braid.telegram.sendMaxChars`
|
|
@@ -96,6 +102,17 @@ Or register the workspace in `~/.pi/agent/settings.json`:
|
|
|
96
102
|
- A bot token from @BotFather; optionally `BRAID_TG_CHAT_ID` / `BRAID_TG_ALLOWED_USERS` for the
|
|
97
103
|
allow-list.
|
|
98
104
|
|
|
105
|
+
## Settings cadence
|
|
106
|
+
|
|
107
|
+
Settings live in `~/.pi/agent/settings.json`. `braid.telegram.pauseStateFile`
|
|
108
|
+
is captured as the callback write target when the extension loads. `getPauseState()`
|
|
109
|
+
resolves its read path per call, so restart after changing the setting to keep
|
|
110
|
+
reads and writes aligned. `braid.telegram.tts` is loaded into the voice hook at
|
|
111
|
+
extension load; restart after changing its source, rate, or voices.
|
|
112
|
+
The send and notification caps (`braid.telegram.sendMaxChars` and
|
|
113
|
+
`braid.telegram.notifyMaxChars`) are re-read per operation and change without
|
|
114
|
+
a restart.
|
|
115
|
+
|
|
99
116
|
## Environment variables
|
|
100
117
|
|
|
101
118
|
| Var | Purpose | Required |
|
package/build-info.json
ADDED
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-braid-telegram",
|
|
3
|
-
"version": "0.1.
|
|
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": [
|
|
7
7
|
"src/",
|
|
8
8
|
"docs/",
|
|
9
9
|
"README.md",
|
|
10
|
-
"LICENSE"
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"build-info.json"
|
|
11
12
|
],
|
|
12
13
|
"exports": {
|
|
13
14
|
".": "./src/index.ts"
|
|
@@ -34,16 +35,17 @@
|
|
|
34
35
|
"test": "node test/run-tests.mjs",
|
|
35
36
|
"check:doc-index": "node ../../packages/core/scripts/check-doc-index.mjs",
|
|
36
37
|
"publish:local": "node ../../packages/core/scripts/publish-local.mjs telegram",
|
|
37
|
-
"prepack": "npm run build"
|
|
38
|
+
"prepack": "npm run build && node ../../packages/core/scripts/pack-provenance.mjs write",
|
|
39
|
+
"postpack": "node ../../packages/core/scripts/pack-provenance.mjs remove"
|
|
38
40
|
},
|
|
39
41
|
"devDependencies": {
|
|
40
42
|
"typescript": "6.0.2",
|
|
41
43
|
"typebox": "1.1.38"
|
|
42
44
|
},
|
|
43
45
|
"peerDependencies": {
|
|
44
|
-
"@earendil-works/pi-ai": "
|
|
45
|
-
"@earendil-works/pi-coding-agent": "
|
|
46
|
-
"typebox": "
|
|
46
|
+
"@earendil-works/pi-ai": "^1.2.0",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^1.2.0",
|
|
48
|
+
"typebox": "^1.1.38"
|
|
47
49
|
},
|
|
48
50
|
"author": "kifirkin",
|
|
49
51
|
"license": "MIT",
|
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]`;
|
package/src/lib/format.ts
CHANGED
|
@@ -27,7 +27,11 @@ import type {
|
|
|
27
27
|
TelegramSendResult,
|
|
28
28
|
ParseMode,
|
|
29
29
|
} from "./telegram-client.js";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
TELEGRAM_HTML_TAGS,
|
|
32
|
+
checkTelegramHtml,
|
|
33
|
+
parseTelegramTagAt,
|
|
34
|
+
} from "./html-check.js";
|
|
31
35
|
|
|
32
36
|
/** Telegram hard cap for a single text message (https://core.telegram.org/bots/api#sendmessage). */
|
|
33
37
|
export const TELEGRAM_MAX_TEXT_LENGTH = 4096;
|
|
@@ -145,17 +149,26 @@ export function isBalancedMarkdown(text: string): boolean {
|
|
|
145
149
|
// attribute value is part of the tag, so `<a href="a>b">` is one token and is
|
|
146
150
|
// removed whole rather than truncated at the first `>`.
|
|
147
151
|
|
|
148
|
-
/** True when the text contains
|
|
152
|
+
/** True when the text contains a tag Telegram's HTML parser accepts.
|
|
153
|
+
*
|
|
154
|
+
* Tag-shaped is not enough: auto-selecting HTML for a body carrying `<n>` or
|
|
155
|
+
* `<b/>` makes Telegram reject the whole message ("Unsupported start tag"),
|
|
156
|
+
* while as a plain body the same characters reach the reader literally. Only
|
|
157
|
+
* a token whose name is in Telegram's tag set and whose syntax the parser
|
|
158
|
+
* accepts is a signal the body was authored as HTML.
|
|
159
|
+
*/
|
|
149
160
|
export function hasHtmlTags(text: string): boolean {
|
|
150
161
|
return findTagToken(text) !== null;
|
|
151
162
|
}
|
|
152
163
|
|
|
153
|
-
/** First tag-shaped token
|
|
164
|
+
/** First tag-shaped token Telegram would accept, or null. */
|
|
154
165
|
function findTagToken(text: string): { start: number; end: number } | null {
|
|
155
166
|
let i = text.indexOf("<");
|
|
156
167
|
while (i !== -1) {
|
|
157
168
|
const tag = parseTelegramTagAt(text, i);
|
|
158
|
-
if (tag
|
|
169
|
+
if (tag && tag.error === undefined && TELEGRAM_HTML_TAGS.has(tag.name)) {
|
|
170
|
+
return { start: i, end: tag.end };
|
|
171
|
+
}
|
|
159
172
|
i = text.indexOf("<", i + 1);
|
|
160
173
|
}
|
|
161
174
|
return null;
|
|
@@ -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 =
|