@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 +13 -0
- package/README.md +1 -1
- package/docs/README.md +1 -1
- package/docs/command-templates.md +123 -22
- package/docs/{external-update-handlers.md → external-handlers.md} +11 -11
- package/index.ts +2 -2
- package/lib/api.ts +8 -3
- package/lib/command-templates.ts +152 -6
- package/lib/{external-update-handlers.ts → external-handlers.ts} +29 -27
- package/lib/menu-queue.ts +37 -7
- package/package.json +1 -1
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
|
|
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-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
"
|
|
136
|
-
"
|
|
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"
|
|
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"
|
|
179
|
-
{ "
|
|
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
|
|
194
|
-
{
|
|
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
|
|
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
|
|
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 {
|
|
66
|
+
import { onTelegramExternalUpdate } from "@llblab/pi-telegram/lib/external-handlers.ts";
|
|
67
67
|
|
|
68
|
-
const off =
|
|
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.
|
|
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
|
|
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 = "
|
|
110
|
+
const REGISTRY_KEY = "__piTelegramExternalHandlerRegistry__";
|
|
111
111
|
|
|
112
|
-
function getOrCreateRegistry():
|
|
112
|
+
function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
|
|
113
113
|
const g = globalThis as Record<string, unknown>;
|
|
114
114
|
const existing = g[REGISTRY_KEY] as
|
|
115
|
-
|
|
|
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:
|
|
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.
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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,
|
package/lib/command-templates.ts
CHANGED
|
@@ -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
|
-
/\{([
|
|
199
|
-
(_match,
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
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
|
|
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
|
|
13
|
+
export type TelegramExternalHandlerVerdict = "consume" | "pass";
|
|
14
14
|
|
|
15
|
-
export type
|
|
15
|
+
export type TelegramExternalHandler = (
|
|
16
16
|
update: unknown,
|
|
17
17
|
) =>
|
|
18
|
-
|
|
|
18
|
+
| TelegramExternalHandlerVerdict
|
|
19
19
|
| void
|
|
20
|
-
| Promise<
|
|
20
|
+
| Promise<TelegramExternalHandlerVerdict | void>;
|
|
21
21
|
|
|
22
|
-
export interface
|
|
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:
|
|
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
|
|
37
|
+
* {@link onTelegramExternalUpdate} or `add` instead of dispatching directly.
|
|
38
38
|
*/
|
|
39
|
-
dispatch: (update: unknown) => Promise<
|
|
39
|
+
dispatch: (update: unknown) => Promise<TelegramExternalHandlerVerdict>;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const REGISTRY_KEY = "
|
|
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
|
|
58
|
+
): candidate is TelegramExternalHandlerRegistry {
|
|
59
59
|
if (!candidate || typeof candidate !== "object") return false;
|
|
60
|
-
const r = candidate as Partial<
|
|
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():
|
|
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<
|
|
73
|
-
const registry:
|
|
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
|
|
98
|
+
* {@link onTelegramExternalUpdate} instead.
|
|
99
99
|
*/
|
|
100
|
-
export function
|
|
100
|
+
export function getTelegramExternalHandlerRegistry(): TelegramExternalHandlerRegistry {
|
|
101
101
|
return getOrCreateRegistry();
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export interface
|
|
104
|
+
export interface TelegramExternalHandlerWrapDeps<TUpdate, TContext> {
|
|
105
105
|
defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
|
|
106
|
-
registry?:
|
|
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
|
|
119
|
-
deps:
|
|
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 {
|
|
145
|
+
* import { onTelegramExternalUpdate } from "@llblab/pi-telegram/lib/external-handlers.ts";
|
|
144
146
|
*
|
|
145
|
-
* const off =
|
|
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.
|
|
158
|
-
* see {@link
|
|
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
|
|
162
|
-
handler:
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
311
|
-
return "<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
|
}
|