@llblab/pi-telegram 0.27.2 → 0.27.3

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/AGENTS.md CHANGED
@@ -94,7 +94,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
94
94
  - Thread bindings are bidirectional. From the Pi-instance side, an instance knows and preserves its target, slot, thread name, lifecycle state, and direct-delivery defaults. From the Telegram-client/bot side, the bridge observes thread creation/close/reopen/stale-send signals and reconciles them into instance binding state. Lifecycle transitions that affect operator understanding should be Telegram-visible when a live transport remains: every successful follower registration/re-registration gets a compact connected notice in the assigned thread, while heartbeat pruning stays silent because it is only liveness suspicion. These notices should use the instance thread name when known and fall back to the slot letter only while the thread is still unnamed. New unknown or command-created temporary threads receive the complete forward plus replace/restore control set and remain preserved until the user chooses one. Successful forward deletes the chooser and temporary thread; successful replace/restore always deletes the chooser, rebinds the temporary thread, and deletes only the replaced old thread. Any destructive cleanup must go through `thread-reconciler`. Unknown `forum_topic_created` service events are observations, not destructive cleanup proof.
95
95
  - The multi-instance bus uses private bot DMs with Telegram private-chat Threaded Mode enabled for the bot. No supergroup, group, or forum configuration is needed; the thread chat is always the private chat with the paired owner. Threaded capability checks must use bot/private-chat evidence such as `getMe.has_topics_enabled`, incoming `message_thread_id`, and topic operation success/failure; do not use group chat metadata as the control-plane truth for Telegram private-chat Threaded Mode.
96
96
  - Target-scoped behavior must preserve `{ chatId, threadId? }` through inbound messages/edits/callbacks/reactions, thread lifecycle service messages, media and split-text grouping, queue mutations, active-turn cleanup, previews, reply deduplication, menus, sections, generated buttons, voice, attachments, and direct delivery. Threaded Mode replies must keep Telegram's reply affordance to the triggering message just like classic DM mode. In Telegram private-chat Threaded Mode, mobile Telegram has been live-verified to render `sendMessage` replies that include top-level `message_thread_id` plus same-chat `reply_parameters.message_id`; Telegram Desktop may fail to show the visual reply header for the same correct payload, so do not treat Desktop-only absence as bridge failure without mobile/payload evidence.
97
- - Local bus IPC is a local-only trust boundary: private socket directories/files are necessary but not the whole authorization model; never let arbitrary local processes inject prompts, callbacks, API sends, or artifact routes.
97
+ - Local bus IPC is a local-only trust boundary: private socket directories/files are necessary but not the whole authorization model; never let arbitrary local processes inject prompts, callbacks, API sends, or artifact routes. Ordinary follower Bot API sends remain assigned-target scoped; runtime-marked `telegram_message` cross-target sends may address only a different thread in the same paired chat, and the leader must strip that internal authorization marker before Telegram transport.
98
98
  - Queue admission is explicit and validated: immediate commands, control lane, priority lane, and default lane must preserve allowed kind/lane pairings
99
99
  - Dispatch is gated by active turns, pending dispatch, unsettled control work, compaction, `ctx.isIdle()`, and Pi pending messages; dispatched prompts remain queued until `agent_start` consumes them
100
100
  - Telegram `/compact` owns a native `typing` keepalive for the compaction window so phone clients show activity between the started/completed notices; stop it on both completion and failure. Automatic/session compaction may start native typing only when an active Telegram turn exists, and must reuse that turn target; startup, reload, connect, restore, and idle/background compaction must not create visible typing by themselves.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.3: Follower Cross-Thread Delivery Hotfix
4
+
5
+ - `Follower Cross-Thread Delivery`: The bus-aware Telegram API runtime now marks sends whose target differs from the follower's assigned thread, and the leader authorizes that internal marker only for a different target inside the same paired chat before stripping it from the Bot API request. Impact: `telegram_message` can fulfill explicit inter-thread delivery from follower instances without opening arbitrary cross-chat or generic follower API access.
6
+ - `Authorization Coverage`: Focused regressions cover follower marker admission, assigned-target rejection, wrong-chat rejection, unmarked rejection, transport metadata stripping, and Rich-message marking while preserving the existing target-aware active-turn guard. Impact: the outbound policy and bus trust boundary now agree end to end instead of allowing a request locally and rejecting it at leader transport.
7
+
3
8
  ## 0.27.2: Target-Aware Direct Message Guard
4
9
 
5
10
  - `Direct Message Guard`: `telegram_message` now rejects implicit delivery and an explicit target equal to the active Telegram turn, leaving the ordinary final-reply path as the sole current-target response. An explicit different chat/thread target remains allowed for user-requested cross-target delivery, while local/TUI direct sends and automatic Proactive Push remain unchanged. Impact: accidental duplicate replies are prevented without breaking legitimate inter-thread messaging.
@@ -408,7 +408,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
408
408
 
409
409
  - Messages, edits, callbacks, and reactions check user authorization, not only chat/thread membership.
410
410
  - Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary.
411
- - Follower Bot API proxying is allowlisted and target-scoped where applicable so a follower can reply in its assigned thread without gaining arbitrary bot control.
411
+ - Follower Bot API proxying is allowlisted and target-scoped where applicable. Ordinary sends remain confined to the follower's assigned thread; trusted runtime-marked `telegram_message` cross-target sends may address only a different thread inside the same paired chat, and the leader strips the internal marker before calling Telegram. This preserves requested inter-thread delivery without granting arbitrary bot or cross-chat control.
412
412
  - Button and section callbacks verify authorized `from.id` and owning target/instance.
413
413
  - Generated artifacts stay scoped to the owning thread after leader failover.
414
414
  - Diagnostics redact bot tokens, large prompts, attachment paths, and handler output.
package/lib/bus-api.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  * Wraps the direct Telegram Bot API runtime so follower instances can route outbound calls through the bus leader
5
5
  */
6
6
 
7
- import { stripTelegramBusApiMetadata } from "./bus.ts";
7
+ import {
8
+ markTelegramBusCrossTargetDelivery,
9
+ stripTelegramBusApiMetadata,
10
+ } from "./bus.ts";
8
11
  import { isTelegramMessageNotModifiedError } from "./telegram-api.ts";
9
12
  import type {
10
13
  TelegramAnswerGuestQueryOptions,
@@ -56,6 +59,19 @@ function withDefaultThreadTarget(
56
59
  : body;
57
60
  }
58
61
 
62
+ function markFollowerCrossTargetDelivery<T extends Record<string, unknown>>(
63
+ body: T,
64
+ defaultTarget: { chatId: number; threadId?: number } | undefined,
65
+ ): T {
66
+ if (!defaultTarget || body.chat_id !== defaultTarget.chatId) return body;
67
+ const threadId = body.message_thread_id;
68
+ const isDifferentTarget =
69
+ threadId === undefined
70
+ ? defaultTarget.threadId !== undefined
71
+ : threadId !== defaultTarget.threadId;
72
+ return isDifferentTarget ? markTelegramBusCrossTargetDelivery(body) : body;
73
+ }
74
+
59
75
  function rejectTelegramDirectOwnership(method: string): Promise<never> {
60
76
  return Promise.reject(
61
77
  new Error(`Telegram ${method} requires direct transport ownership.`),
@@ -238,16 +254,28 @@ export function createTelegramBusAwareApiRuntime(
238
254
  return deps.ownsDirect()
239
255
  ? deps.directRuntime.sendMessage(stripTelegramBusApiMetadata(body))
240
256
  : deps
241
- .callFollowerApi("call", ["sendMessage", body])
257
+ .callFollowerApi("call", [
258
+ "sendMessage",
259
+ markFollowerCrossTargetDelivery(
260
+ body,
261
+ deps.getDefaultTarget?.(),
262
+ ),
263
+ ])
242
264
  .then(asSentMessage);
243
265
  },
244
266
  sendRichMessage(
245
267
  body: TelegramSendRichMessageBody,
246
268
  ): Promise<TelegramSentMessage> {
247
269
  return deps.ownsDirect()
248
- ? deps.directRuntime.sendRichMessage(body)
270
+ ? deps.directRuntime.sendRichMessage(stripTelegramBusApiMetadata(body))
249
271
  : deps
250
- .callFollowerApi("call", ["sendRichMessage", body])
272
+ .callFollowerApi("call", [
273
+ "sendRichMessage",
274
+ markFollowerCrossTargetDelivery(
275
+ body,
276
+ deps.getDefaultTarget?.(),
277
+ ),
278
+ ])
251
279
  .then(asSentMessage);
252
280
  },
253
281
  sendRichMessageDraft(
package/lib/bus.ts CHANGED
@@ -229,6 +229,8 @@ export function getTelegramFollowerTargetOwnership(input: {
229
229
  }
230
230
 
231
231
  const TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD = "__piTelegramAggregateDelivery";
232
+ const TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD =
233
+ "__piTelegramCrossTargetDelivery";
232
234
 
233
235
  export function markTelegramBusAggregateDelivery<
234
236
  T extends Record<string, unknown>,
@@ -249,12 +251,38 @@ export function isTelegramBusAggregateDelivery(body: unknown): boolean {
249
251
  );
250
252
  }
251
253
 
254
+ export function markTelegramBusCrossTargetDelivery<
255
+ T extends Record<string, unknown>,
256
+ >(body: T): T {
257
+ return {
258
+ ...body,
259
+ [TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD]: true,
260
+ };
261
+ }
262
+
263
+ export function isTelegramBusCrossTargetDelivery(body: unknown): boolean {
264
+ return Boolean(
265
+ body &&
266
+ typeof body === "object" &&
267
+ !Array.isArray(body) &&
268
+ (body as Record<string, unknown>)[
269
+ TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD
270
+ ] === true,
271
+ );
272
+ }
273
+
252
274
  export function stripTelegramBusApiMetadata<T extends Record<string, unknown>>(
253
275
  body: T,
254
276
  ): T {
255
- if (!(TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD in body)) return body;
277
+ if (
278
+ !(TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD in body) &&
279
+ !(TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD in body)
280
+ ) {
281
+ return body;
282
+ }
256
283
  const clean = { ...body };
257
284
  delete clean[TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD];
285
+ delete clean[TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD];
258
286
  return clean;
259
287
  }
260
288
 
@@ -304,6 +332,18 @@ export function isTelegramFollowerApiCallAllowed(input: {
304
332
  const record = body as Record<string, unknown>;
305
333
  return matchesId(record.chat_id, target.chatId);
306
334
  };
335
+ const isDifferentTargetScoped = (body: unknown): boolean => {
336
+ if (!target || !isTargetChatScoped(body)) return false;
337
+ const threadId = (body as Record<string, unknown>).message_thread_id;
338
+ if (threadId === undefined) return target.threadId !== undefined;
339
+ const parsedThreadId =
340
+ typeof threadId === "number" ? threadId : Number(threadId);
341
+ return (
342
+ Number.isInteger(parsedThreadId) &&
343
+ (target.threadId === undefined ||
344
+ !matchesId(threadId, target.threadId))
345
+ );
346
+ };
307
347
  const isTargetMessageScoped = (body: unknown): boolean => {
308
348
  if (!isTargetChatScoped(body)) return false;
309
349
  const messageId = (body as Record<string, unknown>).message_id;
@@ -350,6 +390,12 @@ export function isTelegramFollowerApiCallAllowed(input: {
350
390
  const body = input.args[1] as Record<string, unknown>;
351
391
  return body.message_thread_id === undefined && isTargetChatScoped(body);
352
392
  }
393
+ if (
394
+ (apiMethod === "sendMessage" || apiMethod === "sendRichMessage") &&
395
+ isTelegramBusCrossTargetDelivery(input.args[1])
396
+ ) {
397
+ return isDifferentTargetScoped(input.args[1]);
398
+ }
353
399
  if (
354
400
  apiMethod === "deleteMessage" ||
355
401
  apiMethod === "editMessageReplyMarkup" ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.27.2",
3
+ "version": "0.27.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"