@llblab/pi-telegram 0.9.2 → 0.9.4

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
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4: Temp Dir And Command Template Hotfix
4
+
5
+ - `[Telegram Temp Dir]` Default Telegram API temp files now respect `PI_CODING_AGENT_DIR`, falling back to `~/.pi/agent` when the env var is unset. Impact: sandboxed or relocated agent dirs no longer force Telegram downloads through the default home-directory path.
6
+ - `[Command Templates]` Synced the local Command Template Standard with `pi-auto-tools@0.5.5`: command-template nodes now document `mode`, `label`, `delay`, `repeat`, parallel fanout semantics, zero-based repeat placeholders, padding, and limited arithmetic expressions such as `{_(index+1)}`. Impact: inbound/outbound Telegram handler docs and helpers share the current portable automation contract.
7
+ - `[Queue Menu]` Empty queue refresh clicks now rotate through compact alternate empty-state headings while preserving the default first-open `⌛ Queue is empty.` state, and the Refresh button now stays directly under Back for both empty and populated queue lists. Impact: manual queue polling feels alive and the primary refresh control stays in a stable location without changing queue semantics.
8
+ - `[Package]` Bumped package metadata to `0.9.4` and kept the lockfile in sync.
9
+
10
+ ## 0.9.3: External Handlers Rename
11
+
12
+ - `[External Handlers]` Renamed the external update handlers domain to `external-handlers` across source, tests, and docs. Impact: the interop domain now has a cleaner name aligned with inbound/outbound handler naming.
13
+ - `[Breaking]` Removed the old `external-update-handlers` module/doc path and old exported update/interceptor aliases. Impact: layered extensions should import from `@llblab/pi-telegram/lib/external-handlers.ts` and use the `TelegramExternalHandler*` names.
14
+ - `[Package]` Bumped package metadata to `0.9.3` and kept the lockfile in sync.
15
+
3
16
  ## 0.9.2: External Update Interceptors
4
17
 
5
18
  - `[External Update Interceptors]` Added a versioned `globalThis` registry that lets layered pi extensions observe and optionally consume Telegram updates before pi-telegram's default routing. Impact: approval gates and other same-process extensions can react synchronously to Telegram callbacks without owning a second bot poller.
package/README.md CHANGED
@@ -224,7 +224,7 @@ List the main risks first.
224
224
  <!-- telegram_button: OK -->
225
225
  ```
226
226
 
227
- Button prompts are routed back into the normal Telegram queue as prompt turns. Keep the opening comment unclosed until the body-ending `-->` for body-form buttons. Closed heads must use `prompt="..."` or the colon shorthand to create a button. Unknown inline-button callbacks that do not belong to pi-telegram are forwarded to π as `[callback] <data>` so other extensions can namespace and handle their own Telegram buttons without polling the bot themselves; see the [Callback Namespace Standard](./docs/callback-namespaces.md). Layered extensions that need to react to Telegram updates synchronously inside their own runtime (for example, to resolve a blocking-tool approval Promise the moment a callback arrives) can register a runtime interceptor on the shared update registry; see [External Update Handlers](./docs/external-update-handlers.md). Outbound handler details are documented in [`docs/outbound-handlers.md`](./docs/outbound-handlers.md).
227
+ Button prompts are routed back into the normal Telegram queue as prompt turns. Keep the opening comment unclosed until the body-ending `-->` for body-form buttons. Closed heads must use `prompt="..."` or the colon shorthand to create a button. Unknown inline-button callbacks that do not belong to pi-telegram are forwarded to π as `[callback] <data>` so other extensions can namespace and handle their own Telegram buttons without polling the bot themselves; see the [Callback Namespace Standard](./docs/callback-namespaces.md). Layered extensions that need to react to Telegram updates synchronously inside their own runtime (for example, to resolve a blocking-tool approval Promise the moment a callback arrives) can register a runtime interceptor on the shared update registry; see [External Handlers](./docs/external-handlers.md). Outbound handler details are documented in [`docs/outbound-handlers.md`](./docs/outbound-handlers.md).
228
228
 
229
229
  ## Streaming
230
230
 
package/docs/README.md CHANGED
@@ -10,4 +10,4 @@ Living index of project documentation in `/docs`.
10
10
  - [outbound-handlers.md](./outbound-handlers.md) — Local `pi-telegram` outbound-handler config, text/voice/button behavior, artifact outputs, and callback routing
11
11
  - [locks.md](./locks.md) — Shared `locks.json` standard for singleton extension ownership
12
12
  - [callback-namespaces.md](./callback-namespaces.md) — Shared Telegram `callback_data` namespace standard for layered extensions
13
- - [external-update-handlers.md](./external-update-handlers.md) — Runtime interceptor registry that lets layered extensions observe and consume Telegram updates without owning their own polling connection
13
+ - [external-handlers.md](./external-handlers.md) — Runtime interceptor registry that lets layered extensions observe and consume Telegram updates without owning their own polling connection
@@ -4,7 +4,7 @@ Command templates are the portable integration format for deterministic local au
4
4
 
5
5
  **Meta-contract:** transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).
6
6
 
7
- **Scope:** portable command execution format — shell-free exec, composition/pipes, timeout (30s default), retry, critical-step branching, output artifact selection, handler-level fallback. Single JSON standard; no platform lock-in.
7
+ **Scope:** portable synchronous command execution format — shell-free exec, composition/pipes, timeout (30s default), delay-before-start, retry, critical-step branching, output artifact selection, and handler-level fallback. Single JSON standard; no platform lock-in.
8
8
 
9
9
  ---
10
10
 
@@ -30,17 +30,18 @@ There is no portable `command` field. The command is derived from `template`: af
30
30
 
31
31
  Common object fields:
32
32
 
33
- | Field | Meaning |
34
- | ---------- | ------------------------------------------------------------------------------------------ |
35
- | `template` | Required command string or ordered composition array |
36
- | `args` | Optional placeholder-name declarations only; never stores defaults |
37
- | `defaults` | Placeholder default values by name |
38
- | `timeout` | Optional execution timeout in milliseconds; default `30000` (30s) |
39
- | `output` | Optional result selector; default `"stdout"`, or a "runtime value", e.g. `"ogg"` |
40
- | `retry` | Optional max attempts (including first); default `1`. Retries immediately on non-zero exit |
41
- | `critical` | Optional boolean; default `false`. When `true`, failure aborts the entire root composition |
33
+ - `label`: Optional human label for diagnostics and parallel branch reports.
34
+ - `mode`: Optional execution mode for array templates. Default is `"sequence"`; `"parallel"` runs children concurrently.
35
+ - `args`: Optional placeholder-name declarations only. Never stores defaults.
36
+ - `defaults`: Placeholder default values by name.
37
+ - `timeout`: Optional execution timeout in milliseconds. Default is `30000`. Long-running agent calls should set this explicitly.
38
+ - `delay`: Optional wait in milliseconds before starting this node. Default is no delay.
39
+ - `output`: Optional result selector. Default is `"stdout"`; runtime values such as `"ogg"` are valid.
40
+ - `retry`: Optional max attempts including the first. Default is `1`.
41
+ - `critical`: Optional boolean. When `true`, failure aborts the root composition.
42
+ - `template`: Required command string or ordered composition array.
42
43
 
43
- Storage paths, labels, selectors, descriptions, and registry-specific metadata belong to each extension's local schema.
44
+ For object form, write `template` last. Read the node flags first, then the executable content. Storage paths, labels, selectors, descriptions, and registry-specific metadata belong to each extension's local schema.
44
45
 
45
46
  ## Execution
46
47
 
@@ -105,7 +106,7 @@ template="echo 'literal words' {text}"
105
106
 
106
107
  ## Composition
107
108
 
108
- `template: [...]` means sequential composition; each leaf is a command template executed with one shared runtime value map:
109
+ `template: [...]` means sequential composition by default; each leaf is a command template executed with one shared runtime value map:
109
110
 
110
111
  ```json
111
112
  {
@@ -119,12 +120,17 @@ template="echo 'literal words' {text}"
119
120
 
120
121
  Composition rules:
121
122
 
122
- - Execute leaves in order; non-critical failures are recorded and execution continues, while `critical: true` failures abort the root composition
123
+ - Execute leaves in order when `mode` is omitted or set to `"sequence"`
124
+ - Execute child templates concurrently when `mode` is set to `"parallel"`
125
+ - Parallel composition uses soft-quorum semantics by default: failed non-critical children are reported but do not abort siblings or the next sequence step
126
+ - Non-critical failures are recorded and execution continues, while `critical: true` failures abort the root composition
123
127
  - Treat the whole composition as one handler for selector matching and fallback
124
128
  - Top-level `args` and `defaults` apply to every leaf unless the leaf defines private values
125
129
  - Leaf `args` replace inherited `args`; leaf `defaults` merge over inherited defaults; `timeout` and `output` are not inherited into leaves
126
130
  - Default `30000` (30s) timeout applies automatically; configure `timeout` only for exceptional long-running commands
127
- - Each leaf receives the previous leaf's stdout on stdin by default, while the final leaf stdout remains the default composition result
131
+ - Each sequence leaf receives the previous leaf's stdout on stdin by default, while the final leaf stdout remains the default composition result
132
+ - Each parallel child receives the same stdin, and child stdout values are joined in stable array order before flowing to the next sequence leaf
133
+ - Parallel branch joins include branch label and status, and tool details include branch metadata plus coverage summary
128
134
  - Each leaf still applies its own inline defaults
129
135
 
130
136
  ```json
@@ -132,8 +138,8 @@ Composition rules:
132
138
  "template": [
133
139
  "/path/to/tts --text {text} --lang {lang} --out {mp3}",
134
140
  {
135
- "template": "ffmpeg -y -i {mp3} -c:a {codec} {ogg}",
136
- "defaults": { "codec": "libopus" }
141
+ "defaults": { "codec": "libopus" },
142
+ "template": "ffmpeg -y -i {mp3} -c:a {codec} {ogg}"
137
143
  }
138
144
  ],
139
145
  "args": ["text", "lang", "mp3", "ogg"],
@@ -144,6 +150,81 @@ Composition rules:
144
150
 
145
151
  `output` selects the primary result channel. Omitted `output` means `"stdout"`, and explicitly writing `"output": "stdout"` is valid standard syntax. Artifact-producing handlers may instead name a runtime value or placeholder path, e.g. `"ogg"` or `"{ogg}"`.
146
152
 
153
+ ### Repeat
154
+
155
+ `repeat` expands one command-template node N times before execution. It works with both sequence and parallel nodes and is useful when many branches differ only by a number.
156
+
157
+ ```json
158
+ {
159
+ "mode": "parallel",
160
+ "repeat": 8,
161
+ "template": "render page{_(index+1)}.html --prev page{_(prev+1)}.html --next page{_(next+1)}.html --zero page{_index}.html"
162
+ }
163
+ ```
164
+
165
+ Reserved repeat placeholders are injected into each repeated node:
166
+
167
+ - `{index}`: current zero-based index, `0..repeat-1`
168
+ - `{prev}` / `{next}`: wrapped zero-based neighbors
169
+ - `{repeat}`: total repeat count
170
+
171
+ Human 1-based numbering is intentionally expressed as limited arithmetic: `{index+1}`, `{prev+1}`, `{next+1}`.
172
+
173
+ Leading underscores on repeat placeholders request zero padding. One underscore means width 2, two underscores mean width 3, and so on:
174
+
175
+ ```text
176
+ {_index} → 00, 01, ...
177
+ {_(index+1)} → 01, 02, ...
178
+ {__(index+1)} → 001, 002, ...
179
+ {_(prev+1)} → wrapped previous page number, padded to width 2
180
+ {_(next+1)} → wrapped next page number, padded to width 2
181
+ ```
182
+
183
+ Repeat expressions support only integers, `index`, `prev`, `next`, `repeat`, parentheses, and `+`, `-`, `*`, `/`, `%`. They are not JavaScript and cannot call functions or access properties.
184
+
185
+ Repeat placeholders are local generated values. Call-time args should not use these reserved names to override the repeat index.
186
+
187
+ Parallel nodes use the same object shape. Flags come first and `template` stays last:
188
+
189
+ ```json
190
+ {
191
+ "template": [
192
+ "prepare {out_dir}",
193
+ {
194
+ "mode": "parallel",
195
+ "template": [
196
+ {
197
+ "label": "gpt-5.5",
198
+ "timeout": 300000,
199
+ "template": "review-gpt {scope}"
200
+ },
201
+ {
202
+ "label": "deepseek-pro",
203
+ "timeout": 300000,
204
+ "template": "review-deepseek {scope}"
205
+ },
206
+ {
207
+ "label": "kimi",
208
+ "timeout": 300000,
209
+ "template": "review-kimi {scope}"
210
+ }
211
+ ]
212
+ },
213
+ "merge {out_dir}"
214
+ ]
215
+ }
216
+ ```
217
+
218
+ A degraded parallel join is still usable when at least one branch succeeds:
219
+
220
+ ```text
221
+ --- branch: gpt-5.5 status: done ---
222
+ review text
223
+ --- branch: deepseek-pro status: failed ---
224
+ exit: 1
225
+ stderr: provider balance exhausted
226
+ ```
227
+
147
228
  Legacy local schemas may accept `pipe` as an alias, but the portable standard is `template: [...]`.
148
229
 
149
230
  ## Fail-Open Default Policy
@@ -159,7 +240,7 @@ Set `critical: true` on any leaf to abort the entire root composition on failure
159
240
  "template": [
160
241
  { "template": "cargo build" },
161
242
  { "template": "cargo fmt --check" },
162
- { "template": "cargo test", "critical": true }
243
+ { "critical": true, "template": "cargo test" }
163
244
  ]
164
245
  }
165
246
  ```
@@ -175,14 +256,31 @@ Set `retry: N` on a leaf to attempt execution up to `N` times (including the fir
175
256
  ```json
176
257
  {
177
258
  "template": [
178
- { "template": "npm install", "retry": 3 },
179
- { "template": "npm test", "critical": true, "retry": 2 }
259
+ { "retry": 3, "template": "npm install" },
260
+ { "retry": 2, "critical": true, "template": "npm test" }
180
261
  ]
181
262
  }
182
263
  ```
183
264
 
184
265
  `npm install` is retried up to 3 times. `npm test` is retried up to 2 times; if all attempts fail, the critical step aborts the pipeline.
185
266
 
267
+ ## Delay
268
+
269
+ Set `delay` to wait before starting a node. The value is milliseconds. Delay is not inherited into child nodes, just like `timeout`.
270
+
271
+ ```json
272
+ {
273
+ "template": [
274
+ "prepare {scope}",
275
+ { "delay": 1000, "template": "review {scope}" }
276
+ ]
277
+ }
278
+ ```
279
+
280
+ On a sequence node, `delay` waits before the sequence begins. On a parallel node, `delay` waits before launching its children. On a branch, `delay` waits before that branch starts, without blocking sibling branches.
281
+
282
+ Use `delay` only for explicit backoff, rate pacing, or staged launch. Do not use it as a scheduler.
283
+
186
284
  ## Progressive Disclosure
187
285
 
188
286
  The standard uses a single `template` field that grows with the user's needs:
@@ -190,11 +288,14 @@ The standard uses a single `template` field that grows with the user's needs:
190
288
  ```text
191
289
  string → leaf command
192
290
  string[] → sequential composition
193
- { template } → leaf with defaults
194
- { template, retry, critical, output } → full leaf
291
+ { template } → leaf command object
292
+ { mode, template } → sequence or parallel subtree
293
+ { mode, args, defaults, delay, retry, critical, output, template } → full node
195
294
  ```
196
295
 
197
- Start with a string. Add composition when needed. Add retry when flaky. Add critical when safety matters. Same contract, growing capability, no dead weight.
296
+ Start with a string. Add composition when needed. Add `mode: "parallel"` when independent work can run concurrently. Add delay when launch pacing matters. Add retry when flaky. Add critical when safety matters. Same contract, growing capability, no dead weight.
297
+
298
+ `mode: "parallel"` is the synchronous fanout shape. Detached lifecycle, logs, cancellation, and durable state belong to host-specific async job or runtime-envelope standards, not to command templates.
198
299
 
199
300
  ## Tool Boundary
200
301
 
@@ -1,4 +1,4 @@
1
- # External Update Handlers
1
+ # External Handlers
2
2
 
3
3
  `pi-telegram` owns a single `getUpdates` long-poll connection per bot. Other
4
4
  pi extensions cannot open a competing poller against the same bot — the
@@ -12,7 +12,7 @@ fires.
12
12
 
13
13
  It is the runtime counterpart to
14
14
  [Callback Namespaces](./callback-namespaces.md): callback namespaces define
15
- how to share `callback_data` cleanly; external update handlers define how to
15
+ how to share `callback_data` cleanly; external handlers define how to
16
16
  observe and optionally short-circuit the dispatch of those updates.
17
17
 
18
18
  ## When to use it
@@ -63,9 +63,9 @@ Two equivalent paths.
63
63
  ### Typed import (recommended when you can depend on `@llblab/pi-telegram`)
64
64
 
65
65
  ```ts
66
- import { onTelegramUpdate } from "@llblab/pi-telegram/lib/external-update-handlers.ts";
66
+ import { onTelegramExternalUpdate } from "@llblab/pi-telegram/lib/external-handlers.ts";
67
67
 
68
- const off = onTelegramUpdate(async (update) => {
68
+ const off = onTelegramExternalUpdate(async (update) => {
69
69
  const cb = (update as { callback_query?: { id?: string; data?: string } })
70
70
  .callback_query;
71
71
  if (!cb?.data?.startsWith("myext:")) return "pass";
@@ -83,7 +83,7 @@ When the layered extension prefers no `import` from `@llblab/pi-telegram` (so
83
83
  load order between the two extensions does not matter, and either can be
84
84
  installed first), it must implement the **full v1 registry contract**, not
85
85
  just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on
86
- whatever object it finds at `globalThis.__piTelegramExternalUpdateRegistry__`,
86
+ whatever object it finds at `globalThis.__piTelegramExternalHandlerRegistry__`,
87
87
  so a partial object would silently break the first update.
88
88
 
89
89
  pi-telegram defensively re-creates the registry if the object on `globalThis`
@@ -100,19 +100,19 @@ type PiTelegramVerdict =
100
100
  | Promise<"consume" | "pass" | void>;
101
101
  type PiTelegramInterceptor = (update: unknown) => PiTelegramVerdict;
102
102
 
103
- interface PiTelegramExternalUpdateRegistry {
103
+ interface PiTelegramExternalHandlerRegistry {
104
104
  readonly version: 1;
105
105
  add: (handler: PiTelegramInterceptor) => () => void;
106
106
  // Required: pi-telegram's polling loop calls this on every update.
107
107
  dispatch: (update: unknown) => Promise<"consume" | "pass">;
108
108
  }
109
109
 
110
- const REGISTRY_KEY = "__piTelegramExternalUpdateRegistry__";
110
+ const REGISTRY_KEY = "__piTelegramExternalHandlerRegistry__";
111
111
 
112
- function getOrCreateRegistry(): PiTelegramExternalUpdateRegistry {
112
+ function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
113
113
  const g = globalThis as Record<string, unknown>;
114
114
  const existing = g[REGISTRY_KEY] as
115
- | PiTelegramExternalUpdateRegistry
115
+ | PiTelegramExternalHandlerRegistry
116
116
  | undefined;
117
117
  if (
118
118
  existing &&
@@ -123,7 +123,7 @@ function getOrCreateRegistry(): PiTelegramExternalUpdateRegistry {
123
123
  return existing;
124
124
  }
125
125
  const handlers = new Set<PiTelegramInterceptor>();
126
- const registry: PiTelegramExternalUpdateRegistry = {
126
+ const registry: PiTelegramExternalHandlerRegistry = {
127
127
  version: 1,
128
128
  add(handler) {
129
129
  handlers.add(handler);
@@ -151,7 +151,7 @@ const off = getOrCreateRegistry().add((update) => {
151
151
  });
152
152
  ```
153
153
 
154
- The registry object on `globalThis.__piTelegramExternalUpdateRegistry__` is
154
+ The registry object on `globalThis.__piTelegramExternalHandlerRegistry__` is
155
155
  versioned (`version: 1`) and stable across pi-telegram releases; future
156
156
  breaking changes will use a new schema version and a new key.
157
157
 
package/index.ts CHANGED
@@ -8,7 +8,7 @@ import * as Api from "./lib/api.ts";
8
8
  import * as CommandTemplates from "./lib/command-templates.ts";
9
9
  import * as Commands from "./lib/commands.ts";
10
10
  import * as Config from "./lib/config.ts";
11
- import { createTelegramInterceptedHandleUpdate } from "./lib/external-update-handlers.ts";
11
+ import { createTelegramExternalHandleUpdate } from "./lib/external-handlers.ts";
12
12
  import * as InboundHandlers from "./lib/inbound-handlers.ts";
13
13
  import * as Keyboard from "./lib/keyboard.ts";
14
14
  import * as Lifecycle from "./lib/lifecycle.ts";
@@ -359,7 +359,7 @@ export default function (pi: Pi.ExtensionAPI) {
359
359
  deleteWebhook,
360
360
  getUpdates,
361
361
  persistConfig: configStore.persist,
362
- handleUpdate: createTelegramInterceptedHandleUpdate({
362
+ handleUpdate: createTelegramExternalHandleUpdate({
363
363
  defaultHandle: inboundRouteRuntime.handleUpdate,
364
364
  }),
365
365
  stopTypingLoop: typing.stop,
package/lib/api.ts CHANGED
@@ -8,7 +8,7 @@ import { randomUUID } from "node:crypto";
8
8
  import { createWriteStream, openAsBlob } from "node:fs";
9
9
  import { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
10
10
  import { homedir } from "node:os";
11
- import { join } from "node:path";
11
+ import { join, resolve } from "node:path";
12
12
  import { Readable, Transform } from "node:stream";
13
13
  import { pipeline } from "node:stream/promises";
14
14
 
@@ -28,7 +28,12 @@ export function getTelegramInboundFileByteLimitFromEnv(
28
28
  return defaultValue;
29
29
  }
30
30
 
31
- const TEMP_DIR = join(homedir(), ".pi", "agent", "tmp", "telegram");
31
+ function getTelegramApiTempDir(): string {
32
+ const agentDir = process.env.PI_CODING_AGENT_DIR
33
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
34
+ : join(homedir(), ".pi", "agent");
35
+ return join(agentDir, "tmp", "telegram");
36
+ }
32
37
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
33
38
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
34
39
  process.env,
@@ -641,7 +646,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
641
646
  }): TelegramBridgeApiRuntime {
642
647
  return createTelegramBridgeApiRuntime({
643
648
  client: createTelegramApiClient(deps.getBotToken),
644
- tempDir: TEMP_DIR,
649
+ tempDir: getTelegramApiTempDir(),
645
650
  maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
646
651
  tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
647
652
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -10,17 +10,23 @@ import { isAbsolute, resolve } from "node:path";
10
10
 
11
11
  export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
12
12
 
13
+ export type CommandTemplateMode = "sequence" | "parallel";
14
+
13
15
  export interface CommandTemplateObjectConfig {
16
+ label?: string;
17
+ mode?: CommandTemplateMode;
14
18
  template?: CommandTemplateValue;
15
19
  args?: string[];
16
20
  defaults?: Record<string, unknown>;
17
21
  timeout?: number;
22
+ delay?: number;
18
23
  output?: string;
19
24
  retry?: number;
20
25
  critical?: boolean;
26
+ repeat?: number;
21
27
  }
22
28
 
23
- export type CommandTemplateValue = string | CommandTemplateConfig[];
29
+ export type CommandTemplateValue = string | CommandTemplateConfig[] | CommandTemplateObjectConfig;
24
30
 
25
31
  export type CommandTemplateConfig = string | CommandTemplateObjectConfig;
26
32
 
@@ -78,6 +84,61 @@ function normalizeCommandTemplateDefaults(
78
84
  return normalized;
79
85
  }
80
86
 
87
+ function normalizeRepeat(value: number | undefined): number | undefined {
88
+ if (value === undefined) return undefined;
89
+ if (!Number.isInteger(value) || value < 1)
90
+ throw new Error("Command template repeat must be a positive integer.");
91
+ return value;
92
+ }
93
+
94
+ function pad(value: number, width: number): string {
95
+ return String(value).padStart(width, "0");
96
+ }
97
+
98
+ export function isCommandTemplateRepeatPlaceholder(name: string): boolean {
99
+ return /^_{0,6}(?:index|prev|next|repeat)$/.test(name);
100
+ }
101
+
102
+ export function getCommandTemplateRepeatDefaults(
103
+ index: number,
104
+ repeat: number,
105
+ ): Record<string, string> {
106
+ const prev = (index - 1 + repeat) % repeat;
107
+ const next = (index + 1) % repeat;
108
+ const values: Record<string, string> = {
109
+ index: String(index),
110
+ next: String(next),
111
+ prev: String(prev),
112
+ repeat: String(repeat),
113
+ };
114
+ for (const name of ["index", "prev", "next", "repeat"]) {
115
+ const numeric = Number(values[name]);
116
+ for (let underscores = 1; underscores <= 6; underscores += 1) {
117
+ values[`${"_".repeat(underscores)}${name}`] = pad(numeric, underscores + 1);
118
+ }
119
+ }
120
+ return values;
121
+ }
122
+
123
+ function expandRepeatConfig(
124
+ config: CommandTemplateObjectConfig,
125
+ context: Pick<CommandTemplateObjectConfig, "args" | "defaults">,
126
+ ): CommandTemplateObjectConfig[] | undefined {
127
+ const repeat = normalizeRepeat(config.repeat);
128
+ if (repeat === undefined) return undefined;
129
+ return Array.from({ length: repeat }, (_unused, index0) => {
130
+ const { repeat: _repeat, ...rest } = config;
131
+ return {
132
+ ...rest,
133
+ defaults: {
134
+ ...(context.defaults ?? {}),
135
+ ...(rest.defaults ?? {}),
136
+ ...getCommandTemplateRepeatDefaults(index0, repeat),
137
+ },
138
+ };
139
+ });
140
+ }
141
+
81
142
  export function expandCommandTemplateConfigs(
82
143
  config: CommandTemplateConfig,
83
144
  inherited: Pick<CommandTemplateObjectConfig, "args" | "defaults"> = {},
@@ -99,6 +160,10 @@ export function expandCommandTemplateConfigs(
99
160
  ? { defaults: { ...(inheritedDefaults ?? {}), ...ownDefaults } }
100
161
  : {}),
101
162
  };
163
+ const repeated = expandRepeatConfig(normalizedConfig, context);
164
+ if (repeated) {
165
+ return repeated.flatMap((step) => expandCommandTemplateConfigs(step, context));
166
+ }
102
167
  if (Array.isArray(normalizedConfig.template)) {
103
168
  return normalizedConfig.template.flatMap((step) =>
104
169
  expandCommandTemplateConfigs(step, context),
@@ -189,17 +254,92 @@ export function expandCommandTemplateExecutable(
189
254
  return command;
190
255
  }
191
256
 
257
+ function evaluateCommandTemplateExpression(
258
+ expression: string,
259
+ values: Record<string, string>,
260
+ ): number {
261
+ let index = 0;
262
+ const source = expression.replace(/\s+/g, "");
263
+ function peek(): string | undefined {
264
+ return source[index];
265
+ }
266
+ function consume(char: string): boolean {
267
+ if (peek() !== char) return false;
268
+ index += 1;
269
+ return true;
270
+ }
271
+ function parsePrimary(): number {
272
+ if (consume("(")) {
273
+ const value = parseExpression();
274
+ if (!consume(")")) throw new Error(`Invalid command template expression: ${expression}`);
275
+ return value;
276
+ }
277
+ const numberMatch = source.slice(index).match(/^\d+/);
278
+ if (numberMatch) {
279
+ index += numberMatch[0].length;
280
+ return Number(numberMatch[0]);
281
+ }
282
+ const nameMatch = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_-]*/);
283
+ if (nameMatch) {
284
+ index += nameMatch[0].length;
285
+ const value = values[nameMatch[0]];
286
+ if (value === undefined || !/^-?\d+$/.test(value))
287
+ throw new Error(`Invalid command template expression variable: ${nameMatch[0]}`);
288
+ return Number(value);
289
+ }
290
+ throw new Error(`Invalid command template expression: ${expression}`);
291
+ }
292
+ function parseTerm(): number {
293
+ let value = parsePrimary();
294
+ while (true) {
295
+ if (consume("*")) value *= parsePrimary();
296
+ else if (consume("/")) value = Math.trunc(value / parsePrimary());
297
+ else if (consume("%")) value %= parsePrimary();
298
+ else return value;
299
+ }
300
+ }
301
+ function parseExpression(): number {
302
+ let value = parseTerm();
303
+ while (true) {
304
+ if (consume("+")) value += parseTerm();
305
+ else if (consume("-")) value -= parseTerm();
306
+ else return value;
307
+ }
308
+ }
309
+ const value = parseExpression();
310
+ if (index !== source.length) throw new Error(`Invalid command template expression: ${expression}`);
311
+ return value;
312
+ }
313
+
314
+ function substituteCommandTemplateExpression(
315
+ content: string,
316
+ values: Record<string, string>,
317
+ ): string | undefined {
318
+ const padded = content.match(/^(_{1,6})\((.+)\)$/);
319
+ if (padded) {
320
+ return pad(evaluateCommandTemplateExpression(padded[2], values), padded[1].length + 1);
321
+ }
322
+ if (!/[()+\-*\/%]/.test(content)) return undefined;
323
+ return String(evaluateCommandTemplateExpression(content, values));
324
+ }
325
+
192
326
  export function substituteCommandTemplateToken(
193
327
  token: string,
194
328
  values: Record<string, string>,
195
329
  missingLabel = "command template",
196
330
  ): string {
197
331
  return token.replace(
198
- /\{([A-Za-z_][A-Za-z0-9_-]*)(?:=([^}]*))?\}/g,
199
- (_match, name, inlineDefault: string | undefined) => {
200
- if (Object.hasOwn(values, name)) return values[name] ?? "";
201
- if (inlineDefault !== undefined) return inlineDefault;
202
- throw new Error(`Missing ${missingLabel} value: ${name}`);
332
+ /\{([^{}]+)\}/g,
333
+ (_match, content: string) => {
334
+ const simple = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?:=([^}]*))?$/);
335
+ if (simple) {
336
+ const [, name, inlineDefault] = simple;
337
+ if (Object.hasOwn(values, name)) return values[name] ?? "";
338
+ if (inlineDefault !== undefined) return inlineDefault;
339
+ }
340
+ const expression = substituteCommandTemplateExpression(content, values);
341
+ if (expression !== undefined) return expression;
342
+ throw new Error(`Missing ${missingLabel} value: ${content}`);
203
343
  },
204
344
  );
205
345
  }
@@ -300,6 +440,12 @@ export function buildCommandTemplateInvocation(
300
440
  }
301
441
  if (!normalizedConfig.template)
302
442
  throw new Error(options.emptyMessage ?? "Command template is required");
443
+ if (typeof normalizedConfig.template !== "string") {
444
+ throw new Error(
445
+ options.emptyMessage ??
446
+ "Command template object cannot be executed as one command",
447
+ );
448
+ }
303
449
  const parts = splitCommandTemplate(normalizedConfig.template);
304
450
  const commandPart = parts[0];
305
451
  if (!commandPart)
@@ -1,5 +1,5 @@
1
1
  /**
2
- * External Telegram update interceptor registry
2
+ * External Telegram handler registry
3
3
  * Zones: telegram transport, layered extension interop
4
4
  * Lets other pi extensions hook into the polling loop without owning their own getUpdates connection
5
5
  */
@@ -10,16 +10,16 @@
10
10
  * - `"consume"` — the interceptor handled this update; pi-telegram skips default routing.
11
11
  * - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
12
12
  */
13
- export type TelegramExternalUpdateVerdict = "consume" | "pass";
13
+ export type TelegramExternalHandlerVerdict = "consume" | "pass";
14
14
 
15
- export type TelegramExternalUpdateInterceptor = (
15
+ export type TelegramExternalHandler = (
16
16
  update: unknown,
17
17
  ) =>
18
- | TelegramExternalUpdateVerdict
18
+ | TelegramExternalHandlerVerdict
19
19
  | void
20
- | Promise<TelegramExternalUpdateVerdict | void>;
20
+ | Promise<TelegramExternalHandlerVerdict | void>;
21
21
 
22
- export interface TelegramExternalUpdateRegistry {
22
+ export interface TelegramExternalHandlerRegistry {
23
23
  /** Schema version of this registry shape. */
24
24
  readonly version: 1;
25
25
  /**
@@ -29,17 +29,17 @@ export interface TelegramExternalUpdateRegistry {
29
29
  * before pi-telegram's own routing. The first interceptor that returns
30
30
  * `"consume"` wins and stops the chain for that update.
31
31
  */
32
- add: (handler: TelegramExternalUpdateInterceptor) => () => void;
32
+ add: (handler: TelegramExternalHandler) => () => void;
33
33
  /**
34
34
  * Run all registered interceptors against an update.
35
35
  *
36
36
  * Used by pi-telegram's polling runtime; layered extensions should call
37
- * {@link onTelegramUpdate} or `add` instead of dispatching directly.
37
+ * {@link onTelegramExternalUpdate} or `add` instead of dispatching directly.
38
38
  */
39
- dispatch: (update: unknown) => Promise<TelegramExternalUpdateVerdict>;
39
+ dispatch: (update: unknown) => Promise<TelegramExternalHandlerVerdict>;
40
40
  }
41
41
 
42
- const REGISTRY_KEY = "__piTelegramExternalUpdateRegistry__";
42
+ const REGISTRY_KEY = "__piTelegramExternalHandlerRegistry__";
43
43
 
44
44
  /**
45
45
  * Validate that a value on `globalThis` matches the full v1 registry contract.
@@ -55,9 +55,9 @@ const REGISTRY_KEY = "__piTelegramExternalUpdateRegistry__";
55
55
  */
56
56
  function isValidV1Registry(
57
57
  candidate: unknown,
58
- ): candidate is TelegramExternalUpdateRegistry {
58
+ ): candidate is TelegramExternalHandlerRegistry {
59
59
  if (!candidate || typeof candidate !== "object") return false;
60
- const r = candidate as Partial<TelegramExternalUpdateRegistry>;
60
+ const r = candidate as Partial<TelegramExternalHandlerRegistry>;
61
61
  return (
62
62
  r.version === 1 &&
63
63
  typeof r.add === "function" &&
@@ -65,12 +65,12 @@ function isValidV1Registry(
65
65
  );
66
66
  }
67
67
 
68
- function getOrCreateRegistry(): TelegramExternalUpdateRegistry {
68
+ function getOrCreateRegistry(): TelegramExternalHandlerRegistry {
69
69
  const g = globalThis as Record<string, unknown>;
70
70
  const existing = g[REGISTRY_KEY];
71
71
  if (isValidV1Registry(existing)) return existing;
72
- const handlers = new Set<TelegramExternalUpdateInterceptor>();
73
- const registry: TelegramExternalUpdateRegistry = {
72
+ const handlers = new Set<TelegramExternalHandler>();
73
+ const registry: TelegramExternalHandlerRegistry = {
74
74
  version: 1,
75
75
  add(handler) {
76
76
  handlers.add(handler);
@@ -95,16 +95,18 @@ function getOrCreateRegistry(): TelegramExternalUpdateRegistry {
95
95
  /**
96
96
  * Called by pi-telegram's own runtime to obtain the registry it dispatches
97
97
  * through. Layered extensions should not call this; use
98
- * {@link onTelegramUpdate} instead.
98
+ * {@link onTelegramExternalUpdate} instead.
99
99
  */
100
- export function getTelegramExternalUpdateRegistry(): TelegramExternalUpdateRegistry {
100
+ export function getTelegramExternalHandlerRegistry(): TelegramExternalHandlerRegistry {
101
101
  return getOrCreateRegistry();
102
102
  }
103
103
 
104
- export interface TelegramExternalInterceptorWrapDeps<TUpdate, TContext> {
104
+ export interface TelegramExternalHandlerWrapDeps<TUpdate, TContext> {
105
105
  defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
106
- registry?: TelegramExternalUpdateRegistry;
106
+ registry?: TelegramExternalHandlerRegistry;
107
107
  }
108
+ export type TelegramExternalInterceptorWrapDeps<TUpdate, TContext> =
109
+ TelegramExternalHandlerWrapDeps<TUpdate, TContext>;
108
110
 
109
111
  /**
110
112
  * Wrap a default polling `handleUpdate` with the external interceptor registry.
@@ -115,8 +117,8 @@ export interface TelegramExternalInterceptorWrapDeps<TUpdate, TContext> {
115
117
  * Composition-root callers (pi-telegram's `index.ts`) should use this builder
116
118
  * instead of writing the lifting logic inline.
117
119
  */
118
- export function createTelegramInterceptedHandleUpdate<TUpdate, TContext>(
119
- deps: TelegramExternalInterceptorWrapDeps<TUpdate, TContext>,
120
+ export function createTelegramExternalHandleUpdate<TUpdate, TContext>(
121
+ deps: TelegramExternalHandlerWrapDeps<TUpdate, TContext>,
120
122
  ): (update: TUpdate, ctx: TContext) => Promise<void> {
121
123
  const registry = deps.registry ?? getOrCreateRegistry();
122
124
  const { defaultHandle } = deps;
@@ -140,9 +142,9 @@ export function createTelegramInterceptedHandleUpdate<TUpdate, TContext>(
140
142
  *
141
143
  * @example
142
144
  * ```ts
143
- * import { onTelegramUpdate } from "@llblab/pi-telegram/lib/external-update-handlers.ts";
145
+ * import { onTelegramExternalUpdate } from "@llblab/pi-telegram/lib/external-handlers.ts";
144
146
  *
145
- * const off = onTelegramUpdate(async (update) => {
147
+ * const off = onTelegramExternalUpdate(async (update) => {
146
148
  * const cb = (update as { callback_query?: { data?: string } }).callback_query;
147
149
  * if (!cb?.data?.startsWith("myext:")) return "pass";
148
150
  * await handleMyCallback(cb);
@@ -154,12 +156,12 @@ export function createTelegramInterceptedHandleUpdate<TUpdate, TContext>(
154
156
  * ```
155
157
  *
156
158
  * Extensions that prefer zero coupling can also reach the registry directly
157
- * via `globalThis.__piTelegramExternalUpdateRegistry__` (versioned object,
158
- * see {@link TelegramExternalUpdateRegistry}). This avoids importing
159
+ * via `globalThis.__piTelegramExternalHandlerRegistry__` (versioned object,
160
+ * see {@link TelegramExternalHandlerRegistry}). This avoids importing
159
161
  * `@llblab/pi-telegram` and tolerates either install order.
160
162
  */
161
- export function onTelegramUpdate(
162
- handler: TelegramExternalUpdateInterceptor,
163
+ export function onTelegramExternalUpdate(
164
+ handler: TelegramExternalHandler,
163
165
  ): () => void {
164
166
  return getOrCreateRegistry().add(handler);
165
167
  }
package/lib/menu-queue.ts CHANGED
@@ -13,6 +13,12 @@ import * as Queue from "./queue.ts";
13
13
 
14
14
  const QUEUE_ITEM_PROMPT_HTML_LIMIT = 3600;
15
15
  const QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX = "\n… [truncated]";
16
+ const EMPTY_QUEUE_REFRESH_TITLES = [
17
+ "<b>⌛ Queue is still empty.</b>",
18
+ "<b>🫙 Still nothing in queue.</b>",
19
+ "<b>🍃 Queue remains empty.</b>",
20
+ "<b>🕳 Nothing queued yet.</b>",
21
+ ] as const;
16
22
  type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup;
17
23
  interface TelegramQueueMenuItem {
18
24
  chatId: number;
@@ -55,9 +61,16 @@ function toTelegramQueueMenuItems<Context>(
55
61
  }
56
62
  function buildTelegramQueueMenuReplyMarkup(
57
63
  items: readonly TelegramQueueMenuItem[],
64
+ emptyRefreshIndex = 0,
58
65
  ): TelegramQueueMenuReplyMarkup {
59
66
  const backRow = [{ text: "⬆️ Main menu", callback_data: "menu:back" }];
60
- const refreshRow = [{ text: "🌀 Refresh", callback_data: "queue:refresh" }];
67
+ const nextEmptyRefreshIndex =
68
+ (emptyRefreshIndex + 1) % EMPTY_QUEUE_REFRESH_TITLES.length;
69
+ const refreshData =
70
+ items.length === 0
71
+ ? `queue:refresh:${nextEmptyRefreshIndex}`
72
+ : "queue:refresh";
73
+ const refreshRow = [{ text: "🌀 Refresh", callback_data: refreshData }];
61
74
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
62
75
  const rows = items.map(function buildTelegramQueueMenuRow(item, index) {
63
76
  const prefix = item.isPriority
@@ -73,7 +86,7 @@ function buildTelegramQueueMenuReplyMarkup(
73
86
  },
74
87
  ];
75
88
  });
76
- return { inline_keyboard: [backRow, ...rows, refreshRow] };
89
+ return { inline_keyboard: [backRow, refreshRow, ...rows] };
77
90
  }
78
91
  function findTelegramQueueItem<Context>(
79
92
  items: readonly Queue.TelegramQueueItem<Context>[],
@@ -215,7 +228,7 @@ async function handleTelegramQueueMenuCallback<Context>(
215
228
  await deps.answerCallbackQuery(callbackQueryId);
216
229
  return true;
217
230
  }
218
- if (data === "queue:list" || data === "queue:refresh") {
231
+ if (data === "queue:list") {
219
232
  await updateTelegramQueueMenuList(
220
233
  callbackQueryId,
221
234
  replyChatId,
@@ -224,6 +237,18 @@ async function handleTelegramQueueMenuCallback<Context>(
224
237
  );
225
238
  return true;
226
239
  }
240
+ const refreshMatch = data.match(/^queue:refresh(?::(\d+))?$/);
241
+ if (refreshMatch) {
242
+ await updateTelegramQueueMenuList(
243
+ callbackQueryId,
244
+ replyChatId,
245
+ replyMessageId,
246
+ deps,
247
+ undefined,
248
+ refreshMatch[1] === undefined ? 0 : Number(refreshMatch[1]),
249
+ );
250
+ return true;
251
+ }
227
252
  const pickMatch = data.match(/^queue:pick:(\d+):(\d+)$/);
228
253
  if (pickMatch) {
229
254
  await handleTelegramQueueMenuPick(
@@ -306,9 +331,13 @@ async function handleTelegramQueueMenuCallback<Context>(
306
331
  }
307
332
  function getTelegramQueueMenuListText(
308
333
  items: readonly TelegramQueueMenuItem[],
334
+ emptyRefreshIndex?: number,
309
335
  ): string {
310
- if (items.length === 0) return "<b>⌛ Queue is empty.</b>";
311
- return "<b>⏳ Queue:</b>";
336
+ if (items.length > 0) return "<b>⏳ Queue:</b>";
337
+ if (emptyRefreshIndex === undefined) return "<b>⌛ Queue is empty.</b>";
338
+ return EMPTY_QUEUE_REFRESH_TITLES[
339
+ emptyRefreshIndex % EMPTY_QUEUE_REFRESH_TITLES.length
340
+ ];
312
341
  }
313
342
  async function updateTelegramQueueMenuList<Context>(
314
343
  callbackQueryId: string,
@@ -316,13 +345,14 @@ async function updateTelegramQueueMenuList<Context>(
316
345
  replyMessageId: number,
317
346
  deps: TelegramQueueMenuCallbackDeps<Context>,
318
347
  notice?: string,
348
+ emptyRefreshIndex?: number,
319
349
  ): Promise<void> {
320
350
  const items = deps.getQueuedItems();
321
351
  await deps.updateQueueMessage(
322
352
  replyChatId,
323
353
  replyMessageId,
324
- getTelegramQueueMenuListText(items),
325
- buildTelegramQueueMenuReplyMarkup(items),
354
+ getTelegramQueueMenuListText(items, emptyRefreshIndex),
355
+ buildTelegramQueueMenuReplyMarkup(items, emptyRefreshIndex),
326
356
  );
327
357
  await deps.answerCallbackQuery(callbackQueryId, notice);
328
358
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for π",
6
6
  "type": "module",