@llblab/pi-telegram 0.54.2 → 0.54.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/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.54.3: One routing tab on mobile
8
+
9
+ - `Mobile routing tab`: Sending a command or prompt from `All` in mobile Telegram no longer leaves an extra tab named after the input. The mobile client creates that native tab itself before delivering the input to `All`; the bridge now adopts it as the routing tab and renames it, instead of creating a second tab beside it. Desktop behavior is unchanged.
10
+
7
11
  ## 0.54.2: Diagnosable ownership checks and safe app cancellation
8
12
 
9
13
  - `Ownership checks`: An ownership check that cannot be verified no longer silently stops a live transport. Each such miss is recorded (`ownership-check-failed`) and polling stands down only after two consecutive tolerated misses; a confirmed loss is recorded as `ownership-lost` and stands down at once (#319).
@@ -910,6 +910,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
910
910
  export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
911
911
  /** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
912
912
  const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
913
+ /** A mobile client's native tab creation precedes the All input it is named after by about a second. */
914
+ const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
913
915
  export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
914
916
  return (message.message_thread_id === undefined &&
915
917
  typeof message.date === "number" &&
@@ -6854,6 +6856,30 @@ export function createTelegramInboundRouteRuntime(deps) {
6854
6856
  }
6855
6857
  return undefined;
6856
6858
  };
6859
+ /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
6860
+ const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
6861
+ const text = (message.text ?? message.caption ?? "").trim();
6862
+ let match;
6863
+ for (const [key, implicit] of implicitThreadCreations) {
6864
+ if (implicit.name &&
6865
+ text.startsWith(implicit.name) &&
6866
+ implicit.updateId < updateId &&
6867
+ implicit.createdAtSec > 0 &&
6868
+ message.date !== undefined &&
6869
+ message.date >= implicit.createdAtSec &&
6870
+ message.date - implicit.createdAtSec <=
6871
+ TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
6872
+ implicit.ctx === ctx &&
6873
+ implicit.operatorUserId === cap.operatorUserId &&
6874
+ implicit.journalBindingKey === cap.journalBindingKey &&
6875
+ isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
6876
+ implicit.generation === deps.getSessionGeneration?.() &&
6877
+ implicit.scope === deps.getAdmissionScope?.() &&
6878
+ (!match || implicit.updateId > match.implicit.updateId))
6879
+ match = { key, implicit };
6880
+ }
6881
+ return match;
6882
+ };
6857
6883
  /**
6858
6884
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
6859
6885
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
@@ -6893,6 +6919,34 @@ export function createTelegramInboundRouteRuntime(deps) {
6893
6919
  entry = found;
6894
6920
  return;
6895
6921
  }
6922
+ // Mobile clients sending from All first create a native tab named after the input, then deliver
6923
+ // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
6924
+ const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
6925
+ if (native) {
6926
+ const adopted = store.registerImplicitTemporaryThread({ journalBindingKey, updateIds: [updateId] }, native.implicit.target, randomBytes(16).toString("hex"), {
6927
+ ...authority,
6928
+ isCurrent: () => current() &&
6929
+ implicitThreadCreations.get(native.key) === native.implicit,
6930
+ });
6931
+ if (adopted && current()) {
6932
+ implicitThreadCreations.delete(native.key);
6933
+ entry = adopted;
6934
+ // Adopted native tabs share the routing name; a failed rename never blocks routing.
6935
+ try {
6936
+ await deps.callApi("editForumTopic", {
6937
+ chat_id: native.implicit.target.chatId,
6938
+ message_thread_id: native.implicit.target.threadId,
6939
+ name: TELEGRAM_TEMPORARY_THREAD_NAME,
6940
+ });
6941
+ }
6942
+ catch (error) {
6943
+ deps.recordRuntimeEvent?.("routing", error, {
6944
+ phase: "temporary-thread-name",
6945
+ });
6946
+ }
6947
+ return;
6948
+ }
6949
+ }
6896
6950
  const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
6897
6951
  entry = reservation?.entry;
6898
6952
  if (!reservation?.reserved || !current())
@@ -7253,6 +7307,12 @@ export function createTelegramInboundRouteRuntime(deps) {
7253
7307
  executor: structuredClone(cap.authority.executor),
7254
7308
  generation: deps.getSessionGeneration?.(),
7255
7309
  scope: deps.getAdmissionScope?.(),
7310
+ target: {
7311
+ chatId: lifecycle.target.chatId,
7312
+ threadId: lifecycle.target.threadId,
7313
+ },
7314
+ name: "name" in created && typeof created.name === "string" ? created.name : "",
7315
+ createdAtSec: message.date ?? 0,
7256
7316
  });
7257
7317
  }
7258
7318
  else
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.2",
3
+ "version": "0.54.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -476,7 +476,7 @@ With live bindings or a retained temporary entry, a known command or a fresh pri
476
476
 
477
477
  #### Temporary Thread Multi-Input Lifecycle (Approved Design)
478
478
 
479
- Every temporary routing tab carries one fixed routing name ([copy](./ui-style.md#route-chooser)), never its first input, because later prompts may join it: the bot creates its own tabs with that name and renames a native tab once, best-effort, when it adopts it. Commands and prompts share the same mode chooser. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability in that tab, without renaming it or creating another one. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. The next live input group in that exact target, of any content kind and either one message or an album, registers it through Threads `registerImplicitTemporaryThread` under profile admission, with that group as the entry's first input (its smallest update ID is the source), without calling or fabricating `createForumTopic` (adoption renames it once, best-effort); the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers Reroute, Restore and eligible Cancel; each mode opens its own submenu of live thread targets without dispatching the held input. A submenu Back returns to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Copy, emoji and button layout live in [UI Style](./ui-style.md#route-chooser). Cancel appears only at the root. After cancellation, the immediate cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry. A menu-picked command arrives threadless, so its original stays in All while the bot-created tab carries the chooser; once that input is consumed (Forward finalization, Restore dispatch, explicit or Restore-sibling Cancel, or chooser expiry, including after restart), the bot makes one best-effort `deleteMessage` of the All copy. Failure is recorded and never retried. Pending-cancellation recovery deletes nothing, and typed All inputs already live in their own tab and leave with it.
479
+ Every temporary routing tab carries one fixed routing name ([copy](./ui-style.md#route-chooser)), never its first input, because later prompts may join it: the bot creates its own tabs with that name and renames a native tab once, best-effort, when it adopts it. Commands and prompts share the same mode chooser. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability in that tab, without renaming it or creating another one. Mobile clients sending from `All` first create an implicitly named native tab and then deliver the input itself to `All`: an `All` input whose text starts with the name of such an observation from the same exact scope, created at most ten seconds earlier, registers that native tab as its temporary tab through `registerImplicitTemporaryThread` instead of creating a second one, and renames it once to the routing name. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. The next live input group in that exact target, of any content kind and either one message or an album, registers it through Threads `registerImplicitTemporaryThread` under profile admission, with that group as the entry's first input (its smallest update ID is the source), without calling or fabricating `createForumTopic` (adoption renames it once, best-effort); the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers Reroute, Restore and eligible Cancel; each mode opens its own submenu of live thread targets without dispatching the held input. A submenu Back returns to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Copy, emoji and button layout live in [UI Style](./ui-style.md#route-chooser). Cancel appears only at the root. After cancellation, the immediate cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry. A menu-picked command arrives threadless, so its original stays in All while the bot-created tab carries the chooser; once that input is consumed (Forward finalization, Restore dispatch, explicit or Restore-sibling Cancel, or chooser expiry, including after restart), the bot makes one best-effort `deleteMessage` of the All copy. Failure is recorded and never retried. Pending-cancellation recovery deletes nothing, and typed All inputs already live in their own tab and leave with it.
480
480
 
481
481
  This multi-input lifecycle is implemented in the local candidate, not authority for live deletion. It supersedes unconditional per-chooser tab removal. The reject-only guard prevents cleanup/retirement while another known same-tab chooser remains, including unresolved cleanup-only work. `temporaryThreads.inputs` now records bounded append-only journal source groups through the unbound producer before chooser publication; duplicates are read-only, partial/foreign/overlapping groups refuse, and faults retain the source without a new chooser. Cold reads preserve this protection without process-local chooser memory. A group has no readiness or disposition flag, and an absent legacy field does not prove complete coverage. `cancelledInputs` records exact whole known donor groups after either same-journal/operator retained-cancellation evidence or the separate body-free expiry tombstone. Expiry recording may clear a group's unknown Forward issuance or its expired Restore intent, but never mutates a canonical binding, recipient queue or immutable scoped settlement proof. If Restore already bound the tab, expiry releases the temporary frame without deletion. Otherwise fully resolved groups enter the existing one-second quiet-period cleanup. A partially acknowledged known cohort may finish donor disposition when every member is absent from that exact source journal and at least one member has same-operator expiry proof; this is not recipient completion or non-delivery proof. Fresh strict namespace/protection evidence still gates deletion; independent work and bound Threads remain protected. Store publication and returned/retained acknowledgement recheck that proof and operator/executor authority; a lost reply reconciles the exact fact read-only, while missing/foreign/corrupt proof stays protective. The producer composes this into explicit Cancel before temporary cleanup or chooser retirement. Cancelled groups cannot Restore; independent sources remain untouched. More than one known group still blocks cleanup and raw retirement, including all-cancelled membership: these facts grant no deletion. Each successful Cancel retires only its own chooser. If durable membership is fully resolved by exact cancellation/completion facts and no chooser remains, a process-local cleanup is queued with no grace delay (it still waits behind the resolving operation's Workspace admission, which Cancel releases right after its notice, answering the toast and deleting any All copy alongside the cleanup; new input in the tab before it runs cancels it). Its single attempt, under profile admission, rechecks authority, whole-group cancellation/expiry evidence, exact entry CAS, a strict per-Thread journal census (every namespace source must read strictly; any retained update naming the Thread in any state, any of its own groups, and any custody-bearing, failed or non-plain entry anywhere still protect; only an unrelated waiting plain message, edit, callback or reaction elsewhere is exempt, so a chooser held in another tab cannot pin every temporary tab, and so is a plain button tap in this tab, since it carries no input and the in-flight Cancel itself may outlast the quiet period), absence of choosers and the normal reconciler protections. `issueTemporaryThreadCleanup` publishes canonical `cleanupIssued: true` only for an exact fully resolved entry with strict unbound canonical evidence checked inside the publication transaction; the normal target/source guards precede issuance and run again at API boundaries. The reconciler requires that exact retained issued frame and limits its plan to this tab; unrelated expired-provision cleanup never borrows the grant. Close/delete calls disable retries and transport fallback and require a positive acknowledgement. Confirmed current deletion alone permits entry retirement; skip, negative/lost reply, authority loss or post-rename publication interruption retains the marker. Cold reads classify already-issued entries as unknown, new membership refuses, and executor adoption never renews the grant. Missing evidence only refuses; an unpublished grant issues no API call and may still receive its first grant. The quiet period remains process-local, but issued uncertainty survives restart. The census cannot see updates in transit before journal append. Restore from a multi-input tab is allowed only when each other known group is durably cancelled, Forward-completed or a live unselected chooser that supports private abandonment. A previously completed Forward never supplies a new selection or cancellation grant. After positive settlement of every selected source, such siblings are abandoned with cancellation facts and their controls edited to cancelled, then `retireTemporaryThread(expected, authority, completed)` releases the entry (one newly completed selected group, all others durably cancelled or previously Forward-completed). Missing authority/proof or an unproven Restore leaves siblings pending and protected. On startup, a retained journal input recorded in a created temporary tab whose target a Workspace binding now owns is held as historical, never rerouted; an exact saved chooser clock may expire its unclaimed donor attempt without affecting the binding. Live pending sources and due retry-wait sources (including startup retries) use the same fresh membership/binding predicate through `shouldHoldPendingInput` before execution, with source/context/binding/transport checks after classification awaits. The hold grants neither cancellation nor delivery and does not block unrelated inputs. Recipient custody never uses this direct-source classifier. A native production witness admits a recorded command after the worker has already processed an unrelated command, proving it cannot fall through to ordinary bound dispatch. A production retry witness keeps recorded failure metadata intact without command execution, cancellation or deletion. Claimed retries cannot schedule repeated due-time wakes; independent due and future retries remain executable. Real-IPC Forward/Restore sibling witnesses now include recipient journal drain through the real worker and follower router: only the selected message executes, source disposal is observed, and repeated controls do not replay the command. Existing message-ownership composition keeps a locally published chooser's callbacks at its leader publisher after follower rebinding; the native fixture now records publication ownership rather than relying only on target ownership. Four native delivery-reply-loss witnesses cover both routing actions (Forward/Restore), before recipient execution and after worker disposal: the leader source remains unresolved without ACK, independent sibling Cancel preserves the selected group/tab, and a late reply or repeated issued Restore click cannot settle/replay it. Explicit Forward retry after a lost ACK reproduced command duplication when the recipient worker had already disposed its journal record; stable delivery identity alone was not terminal deduplication. The follower's bounded process-local delivery window now acknowledges such a retry without re-execution while it retains that delivery. Ordinary Forward from a temporary tab first publishes a durable per-group `forwardedInputs` issuance fact in the canonical Threads owner under current authority, before either local command handling/prompt preparation/queue admission or follower RPC. Only positively unpublished issuance may receive its first grant; a published fact (even if its reply is ambiguous) is never retried, including after reload, restart or leader replacement. Local repeated selection refuses the retained grant before handler or queue re-entry. Captured All-tab provenance and acknowledged typed-input membership remain protective if the metadata reader disappears; unavailable evidence never falls back to unguarded generic dispatch. Restore retains its separate canonical routing grant. Native local fixtures interrupt source completion before/after commit, prompt preparation and issuance publication/authority; issued sources stay nonterminal, independent siblings remain cancellable and cold restart never manufactures completion or another attempt. A process-local `forward-unknown` chooser phase gates the live chooser. An issued group is refused by repeat Forward, explicit Cancel and Restore (the store also rejects them); before its fixed chooser deadline it keeps sibling accounting protective, while body-free donor expiry or positive `completedInputs` may resolve it; independent siblings remain operable and a restarted chooser cannot resend. Issuance is not proof of delivery, and recipient-side deduplication is limited to the bounded process-local delivery window; the chooser deadline supplies a loss-accepting exit for unclaimed unknown issued groups; generic journal forwarding and non-chooser retry policy are unchanged. Canonical Restore keeps its existing durable grants. Mixed native witnesses prove that a completed Forward allows Restore of another group without replay/recancellation, while an unknown Forward prevents sibling Restore before an intent is created. Prompt Restore leaves a command sibling pending at queue admission, then privately cancels it only after exact scoped worker receipt disposition. Independently Forwarded queued input is neither unassigned nor cancellation-capable: either receipt may dispose first, the other retains custody, and only both positive dispositions release temporary membership while keeping the bound tab/slot. An ordinary queued ACK emits the existing source-completion hint only after whole acknowledged removal and fresh context, owner, generation and originating journal-binding checks; it creates no immutable Restore marker. A published independent Forward group fact wakes pure inspection of already settled Restore only after its Workspace admission releases. The store refuses both Forward facts and cancellation for any source overlapping a retained Restore grant, regardless of target; malformed partial selected groups stay protective. Native ordinary prompt Forward plus sibling Cancel also delays deletion until receipt disposition and fresh all-resolved cleanup. Pi handoff is supplied by the fixture, not observed model/task completion. The retained All-command fixture registers 84 scenarios across creation, Forward, Cancel, cleanup/membership and Restore, including full slots, publication faults, sibling protection, native Unix IPC and recipient worker drain through the real non-reentrant Workspace gate. Full-slot follower cases hold a real Unix response before apply, after apply, and after read-only inspection until client timeout. Fresh same-session registration/context generations reconcile only through inspection; late replies never publish readiness, repeat apply or dispatch, cancel siblings, release independent queued recipient work or delete the tab. Successful continuation retains exact scoped source-disposition proof. A separate cold-successor witness loads fresh stores and a journal binding after an issued command lost acceptance authority, then starts a fresh context/generation/epoch under supplied same-session owner publication. Original command and sibling stay pending, spent routing/canonical facts remain unchanged, and stale Restore/Forward/Cancel controls cannot replay or dispose them. No chooser is reconstructed: the protective hold is proven, not a usable cold recovery surface. Pi effects and startup registration remain supplied adapters/preconditions; actual Pi startup and live behavior were accepted in the operator's 0.52.0 live smoke. Forward completes only its own input: no Forward removes the tab, the worker's completion ACK records a durable `completedInputs` group (process-local accumulation across the group's sources), and when every known group is cancelled or completed the delayed, fully rechecked cleanup removes the tab once. Cancelled and completed groups are disjoint, cannot Restore, and a completed Forward queued prompt holds the tab until its receipt completes. The tab chooser discloses removal only when no other input remains; a failed removal no longer edits the original chooser. Source-only prompt Cancel remains unchanged. The candidate's temporary-tab chooser is the default under the owned leader epoch, not a configurable feature flag. Upgrade all prospective canonical readers/writers before live activation; the metadata (including `forwardProtocol`, `forwardedInputs` and `cleanupIssued`) is not downgrade-safe through older snapshot writers, which could drop issuance facts.
482
482
 
package/lib/routing.ts CHANGED
@@ -1330,6 +1330,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
1330
1330
  export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
1331
1331
  /** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
1332
1332
  const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
1333
+ /** A mobile client's native tab creation precedes the All input it is named after by about a second. */
1334
+ const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
1333
1335
 
1334
1336
  export function isTelegramAllTabCommandExpired(
1335
1337
  message: { date?: number; message_thread_id?: number },
@@ -1945,6 +1947,9 @@ export function createTelegramInboundRouteRuntime<
1945
1947
  executor: Threads.TelegramWorkspaceRestoreExecutor;
1946
1948
  generation: number | undefined;
1947
1949
  scope: string | undefined;
1950
+ target: { chatId: number; threadId: number };
1951
+ name: string;
1952
+ createdAtSec: number;
1948
1953
  }
1949
1954
  >();
1950
1955
  const guidedUnboundTopicKeys = new Set<string>();
@@ -10075,6 +10080,44 @@ export function createTelegramInboundRouteRuntime<
10075
10080
  }
10076
10081
  return undefined;
10077
10082
  };
10083
+ /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
10084
+ const findImplicitCreationForAllInput = (
10085
+ message: TMessage,
10086
+ updateId: number,
10087
+ cap: NonNullable<ReturnType<typeof captureTemporaryThreadAuthority>>,
10088
+ ctx: TContext,
10089
+ ) => {
10090
+ const text = (message.text ?? message.caption ?? "").trim();
10091
+ let match:
10092
+ | {
10093
+ key: string;
10094
+ implicit: typeof implicitThreadCreations extends Map<string, infer V>
10095
+ ? V
10096
+ : never;
10097
+ }
10098
+ | undefined;
10099
+ for (const [key, implicit] of implicitThreadCreations) {
10100
+ if (
10101
+ implicit.name &&
10102
+ text.startsWith(implicit.name) &&
10103
+ implicit.updateId < updateId &&
10104
+ implicit.createdAtSec > 0 &&
10105
+ message.date !== undefined &&
10106
+ message.date >= implicit.createdAtSec &&
10107
+ message.date - implicit.createdAtSec <=
10108
+ TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
10109
+ implicit.ctx === ctx &&
10110
+ implicit.operatorUserId === cap.operatorUserId &&
10111
+ implicit.journalBindingKey === cap.journalBindingKey &&
10112
+ isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
10113
+ implicit.generation === deps.getSessionGeneration?.() &&
10114
+ implicit.scope === deps.getAdmissionScope?.() &&
10115
+ (!match || implicit.updateId > match.implicit.updateId)
10116
+ )
10117
+ match = { key, implicit };
10118
+ }
10119
+ return match;
10120
+ };
10078
10121
  /**
10079
10122
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
10080
10123
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
@@ -10130,6 +10173,39 @@ export function createTelegramInboundRouteRuntime<
10130
10173
  entry = found;
10131
10174
  return;
10132
10175
  }
10176
+ // Mobile clients sending from All first create a native tab named after the input, then deliver
10177
+ // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
10178
+ const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
10179
+ if (native) {
10180
+ const adopted = store.registerImplicitTemporaryThread(
10181
+ { journalBindingKey, updateIds: [updateId] },
10182
+ native.implicit.target,
10183
+ randomBytes(16).toString("hex"),
10184
+ {
10185
+ ...authority,
10186
+ isCurrent: () =>
10187
+ current() &&
10188
+ implicitThreadCreations.get(native.key) === native.implicit,
10189
+ },
10190
+ );
10191
+ if (adopted && current()) {
10192
+ implicitThreadCreations.delete(native.key);
10193
+ entry = adopted;
10194
+ // Adopted native tabs share the routing name; a failed rename never blocks routing.
10195
+ try {
10196
+ await deps.callApi!("editForumTopic", {
10197
+ chat_id: native.implicit.target.chatId,
10198
+ message_thread_id: native.implicit.target.threadId,
10199
+ name: TELEGRAM_TEMPORARY_THREAD_NAME,
10200
+ });
10201
+ } catch (error) {
10202
+ deps.recordRuntimeEvent?.("routing", error, {
10203
+ phase: "temporary-thread-name",
10204
+ });
10205
+ }
10206
+ return;
10207
+ }
10208
+ }
10133
10209
  const reservation = store.reserveTemporaryThread(
10134
10210
  source,
10135
10211
  randomBytes(16).toString("hex"),
@@ -10625,6 +10701,12 @@ export function createTelegramInboundRouteRuntime<
10625
10701
  executor: structuredClone(cap.authority.executor),
10626
10702
  generation: deps.getSessionGeneration?.(),
10627
10703
  scope: deps.getAdmissionScope?.(),
10704
+ target: {
10705
+ chatId: lifecycle.target.chatId,
10706
+ threadId: lifecycle.target.threadId!,
10707
+ },
10708
+ name: "name" in created && typeof created.name === "string" ? created.name : "",
10709
+ createdAtSec: message.date ?? 0,
10628
10710
  });
10629
10711
  } else implicitThreadCreations.delete(key);
10630
10712
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.2",
3
+ "version": "0.54.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"