@llblab/pi-telegram 0.7.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +7 -0
- package/README.md +2 -1
- package/docs/architecture.md +2 -2
- package/docs/callback-namespaces.md +1 -1
- package/index.ts +10 -1
- package/lib/menu-queue.ts +1 -1
- package/lib/menu-status.ts +4 -4
- package/lib/menu.ts +7 -3
- package/lib/routing.ts +11 -1
- package/lib/text-groups.ts +203 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -132,6 +132,7 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
|
|
|
132
132
|
- Command help plus prompt-template commands and status/model/thinking/queue controls are driven through `/start`'s Telegram inline application menu and callback queries; the Queue button shows the queued-item count, model-menu scope/pagination controls stay at the top under Main menu, the model pagination indicator opens a compact page picker, and thinking-menu text stays a compact heading because the current level is marked by button state; `/status`, `/model`, `/thinking`, and `/queue` are hidden compatibility shortcuts
|
|
133
133
|
- Shared inline-keyboard structure belongs to `keyboard`; application-control button labels, callback data, and callback behavior stay in `menu`/`menu-model`/`menu-thinking`/`menu-status`/`menu-queue` while core queue mechanics stay in `queue`
|
|
134
134
|
- Inbound files may become π image inputs or configured attachment-handler text before queueing; outbound files must flow through `telegram_attach`
|
|
135
|
+
- Long Telegram text split recovery belongs to `text-groups`: keep it conservative, short-debounced, same chat/user/message-id contiguous, and gated by near-limit human text so normal rapid follow-ups and slash commands stay separate
|
|
135
136
|
- Inbound attachment handlers and command-backed outbound handlers use command templates as the standard integration contract; built-in outbound buttons use inline keyboards plus callback routing because no external command execution is needed
|
|
136
137
|
- Telegram prompt-template commands are discovered from π slash commands with `source: "prompt"`; π template names are mapped to Bot API-compatible aliases (`fix-tests` → `/fix_tests`), aliases that conflict with built-in bridge commands or hidden shortcuts are not displayed, prompt-template aliases stay out of the Telegram bot command menu, and the bridge expands template files before queueing because extension-originated `sendUserMessage()` bypasses π's interactive template expansion
|
|
137
138
|
- Unknown callback data not owned by pi-telegram prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) may be forwarded as `[callback] <data>` after built-in handlers decline it; external extensions should follow `docs/callback-namespaces.md` and must not poll the same bot independently
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2: Split Text Coalescing Hotfix
|
|
4
|
+
|
|
5
|
+
- `[Text Coalescing]` Telegram text messages that look like automatic splits of one near-limit human message are now short-debounced and forwarded to π as one prompt, using a conservative 3600-character near-limit threshold. Commands, bot messages, media groups, captions, non-contiguous messages, and normal short follow-ups bypass coalescing. Impact: long pasted logs/prompts are less likely to arrive as separate π turns when Telegram chunks them.
|
|
6
|
+
- `[Runtime Tests]` The media-group runtime regression now waits for the real debounce instead of mixing fake timers with the polling loop, and the reaction-priority runtime test flushes pending microtasks before ending the active turn. Impact: CI should stop failing on timing-only races around delayed dispatch and queued reaction mutations.
|
|
7
|
+
- `[Callback Namespaces]` Current status-screen navigation callbacks now use the canonical `menu:` namespace (`menu:model`, `menu:thinking`, `menu:queue`). `status:` remains reserved as an owned legacy prefix but is no longer emitted by current UI. Impact: new inline menu callbacks align with the unified app-menu model while old `status:` payloads still cannot leak to external fallback handlers.
|
|
8
|
+
- `[Package]` Bumped package metadata to `0.7.2` through npm and kept the lockfile in sync.
|
|
9
|
+
|
|
3
10
|
## 0.7.1: Layered Callback Interop
|
|
4
11
|
|
|
5
12
|
- `[Callback Interop]` Unknown Telegram inline-button callback data that does not belong to pi-telegram-owned prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) is now forwarded to π as `[callback] <data>` after assistant-button, queue-menu, and app-menu handlers decline it. `docs/callback-namespaces.md` defines the shared callback namespace standard for layered extensions. Impact: layered π extensions can namespace and handle their own Telegram inline buttons without polling the same bot or forking pi-telegram.
|
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ Once paired, simply chat with your bot in Telegram. All text, images, and files
|
|
|
77
77
|
|
|
78
78
|
Use these inside the Telegram DM with your bot:
|
|
79
79
|
|
|
80
|
-
- **`/start`**: Pair the first Telegram user when needed, register
|
|
80
|
+
- **`/start`**: Pair the first Telegram user when needed, register bridge bot commands, and open the inline application menu with command help, available π prompt templates, status rows, and controls.
|
|
81
81
|
- **`/compact`**: Start session compaction (only works when the session is idle).
|
|
82
82
|
- **`/next`**: Dispatch the next queued turn (aborts π first if busy).
|
|
83
83
|
- **`/continue`**: Enqueue a priority `continue` prompt. It waits like normal Telegram work when π is busy and can trigger prompt/skill handling that listens for `continue`.
|
|
@@ -102,6 +102,7 @@ Run these inside π, not Telegram:
|
|
|
102
102
|
### Queue, Reactions, and Media
|
|
103
103
|
|
|
104
104
|
- If you send more Telegram messages while π is busy, they enter the default prompt queue and are processed in order.
|
|
105
|
+
- Very long text messages that Telegram appears to split automatically are coalesced through a short conservative debounce and forwarded to π as one prompt when the first chunk is near Telegram's text limit, currently using a 3600-character threshold. Commands, bot messages, media groups, and normal short follow-ups are not coalesced.
|
|
105
106
|
- `👍`, `⚡️`, `❤️`, and `🕊` move a waiting prompt into the priority prompt queue, behind control actions but ahead of default prompts. Removing the last priority reaction sends it back to its normal queue position, and adding a priority reaction again gives it a fresh priority position.
|
|
106
107
|
- `👎`, `👻`, `💔`, and `💩` remove a waiting turn from the queue. Telegram Bot API does not expose ordinary DM message-deletion events through the polling path used here, so queue removal is bound to removal reactions.
|
|
107
108
|
- Reactions apply to any waiting Telegram turn, including text, voice, files, images, and media groups. For media groups, a reaction on any message in the group applies to the whole queued turn.
|
package/docs/architecture.md
CHANGED
|
@@ -30,7 +30,7 @@ Current runtime areas use these ownership boundaries:
|
|
|
30
30
|
| `config` / `setup` | Persisted bot/session pairing state, authorization, first-user pairing, token prompting, env fallback, validation, config persistence |
|
|
31
31
|
| `locks` / `polling` | Singleton `locks.json` ownership, takeover/restart semantics, long-poll controller state, update offset persistence, poll-loop runtime wiring |
|
|
32
32
|
| `updates` / `routing` | Update classification/execution planning, paired authorization, reactions, edits, callbacks, and inbound route composition |
|
|
33
|
-
| `media` / `turns` / `attachment-handlers` | Text/media extraction, media-group debounce, inbound downloads, turn building/editing, image reads, attachment-handler matching/execution/fallback output
|
|
33
|
+
| `media` / `text-groups` / `turns` / `attachment-handlers` | Text/media extraction, media-group debounce, long-text split coalescing, inbound downloads, turn building/editing, image reads, attachment-handler matching/execution/fallback output |
|
|
34
34
|
| `queue` | Queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, session and agent/tool lifecycle sequencing |
|
|
35
35
|
| `runtime` | Session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, agent-end reset binding |
|
|
36
36
|
| `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu` / `menu-queue` / `commands` | Model identity/thinking levels, scoped model resolution, in-flight switching, model-menu UI, thinking-menu UI, status-menu UI, inline application callback composition, queue-menu UI, slash commands, bot command registration |
|
|
@@ -156,7 +156,7 @@ Preferred order:
|
|
|
156
156
|
|
|
157
157
|
Draft streaming can remain as a plain-text fallback path, but rich Telegram previews are driven through editable messages and stable-block snapshot selection.
|
|
158
158
|
|
|
159
|
-
Telegram prompt responses use explicit delivery context to attach outbound text, rich previews, errors, attachment notices, and uploads as Telegram replies to the source prompt when possible. Reply metadata is opt-in per delivery path, uses `reply_parameters` with `allow_sending_without_reply: true`, and is applied only to the first chunk of split long responses; continuation chunks are sent as normal adjacent messages. Media-group turns reply to the turn's representative `replyToMessageId`, not to every source message in the group.
|
|
159
|
+
Telegram prompt responses use explicit delivery context to attach outbound text, rich previews, errors, attachment notices, and uploads as Telegram replies to the source prompt when possible. Reply metadata is opt-in per delivery path, uses `reply_parameters` with `allow_sending_without_reply: true`, and is applied only to the first chunk of split long responses; continuation chunks are sent as normal adjacent messages. Media-group turns reply to the turn's representative `replyToMessageId`, not to every source message in the group. Long text split coalescing is intentionally conservative: only human text messages at or above the 3600-character near-limit threshold open the short debounce window, immediate same-chat/user contiguous text tails join that prompt, and commands, bot messages, captions, media groups, and normal short follow-ups bypass the coalescer.
|
|
160
160
|
|
|
161
161
|
Outbound files are sent only after the active Telegram turn completes, must be staged through the `telegram_attach` tool, are staged atomically per tool call, are checked against a default 50 MiB limit configurable through `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES` or `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES`, and use file-backed multipart blobs so large sends do not require preloading whole files into memory.
|
|
162
162
|
|
|
@@ -20,7 +20,7 @@ myext:page:2
|
|
|
20
20
|
|
|
21
21
|
- Use a stable extension-owned namespace, preferably the package or extension name without scope punctuation.
|
|
22
22
|
- Keep the namespace lowercase ASCII: `a-z`, `0-9`, `_`, `-`.
|
|
23
|
-
- Do not use `pi-telegram` owned prefixes: `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`.
|
|
23
|
+
- Do not use `pi-telegram` owned prefixes: `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`. Current app navigation uses `menu:`; `status:` remains reserved for legacy/owned status callbacks but is not emitted by current UI.
|
|
24
24
|
- Keep the full `callback_data` within Telegram's 64-byte limit.
|
|
25
25
|
- Put only opaque ids or small enum values in payloads; do not store secrets, full prompts, or large state.
|
|
26
26
|
- Treat callbacks as untrusted input. Validate namespace, action, and payload before executing side effects.
|
package/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import * as Routing from "./lib/routing.ts";
|
|
|
29
29
|
import * as Setup from "./lib/setup.ts";
|
|
30
30
|
import * as OutboundHandlers from "./lib/outbound-handlers.ts";
|
|
31
31
|
import * as Status from "./lib/status.ts";
|
|
32
|
+
import * as TextGroups from "./lib/text-groups.ts";
|
|
32
33
|
|
|
33
34
|
type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
|
|
34
35
|
type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
|
|
@@ -67,6 +68,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
67
68
|
Api.TelegramMessage,
|
|
68
69
|
Pi.ExtensionContext
|
|
69
70
|
>();
|
|
71
|
+
const textGroupRuntime = TextGroups.createTelegramTextGroupController<
|
|
72
|
+
Api.TelegramMessage,
|
|
73
|
+
Pi.ExtensionContext
|
|
74
|
+
>();
|
|
70
75
|
const telegramQueueStore =
|
|
71
76
|
Queue.createTelegramQueueStore<Pi.ExtensionContext>();
|
|
72
77
|
const deferredQueueDispatchRuntime =
|
|
@@ -277,6 +282,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
277
282
|
bridgeRuntime,
|
|
278
283
|
activeTurnRuntime,
|
|
279
284
|
mediaGroupRuntime,
|
|
285
|
+
textGroupRuntime,
|
|
280
286
|
telegramQueueStore,
|
|
281
287
|
queueMutationRuntime,
|
|
282
288
|
modelMenuRuntime,
|
|
@@ -342,7 +348,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
342
348
|
prepareTempDir,
|
|
343
349
|
updateStatus,
|
|
344
350
|
unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
|
|
345
|
-
clearPendingMediaGroups:
|
|
351
|
+
clearPendingMediaGroups: TextGroups.createTelegramGroupedInputClearer({
|
|
352
|
+
clearMediaGroups: mediaGroupRuntime.clear,
|
|
353
|
+
clearTextGroups: textGroupRuntime.clear,
|
|
354
|
+
}),
|
|
346
355
|
clearModelMenuState: modelMenuRuntime.clear,
|
|
347
356
|
getActiveTurnChatId: activeTurnRuntime.getChatId,
|
|
348
357
|
clearPreview: previewRuntime.clear,
|
package/lib/menu-queue.ts
CHANGED
|
@@ -463,7 +463,7 @@ function createQueueMenuCallbackHandler<
|
|
|
463
463
|
const messageId = query.message?.message_id;
|
|
464
464
|
if (!data || typeof chatId !== "number" || typeof messageId !== "number")
|
|
465
465
|
return false;
|
|
466
|
-
if (data === "status:queue") {
|
|
466
|
+
if (data === "menu:queue" || data === "status:queue") {
|
|
467
467
|
const state = deps.getStoredModelMenuState(messageId);
|
|
468
468
|
if (!state) {
|
|
469
469
|
await deps.answerCallbackQuery(
|
package/lib/menu-status.ts
CHANGED
|
@@ -51,7 +51,7 @@ function isTelegramStatusMenuCallbackAction(
|
|
|
51
51
|
data: string | undefined,
|
|
52
52
|
action: "model" | "thinking",
|
|
53
53
|
): boolean {
|
|
54
|
-
return data === `status:${action}`;
|
|
54
|
+
return data === `menu:${action}` || data === `status:${action}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function applyTelegramMenuRenderPayload(
|
|
@@ -144,7 +144,7 @@ export function buildStatusReplyMarkup(
|
|
|
144
144
|
`${formatTelegramCommandEmojiPrefix("model")}Model`,
|
|
145
145
|
activeModel ? getCanonicalModelId(activeModel) : "unknown",
|
|
146
146
|
),
|
|
147
|
-
callback_data: "
|
|
147
|
+
callback_data: "menu:model",
|
|
148
148
|
},
|
|
149
149
|
]);
|
|
150
150
|
if (activeModel?.reasoning) {
|
|
@@ -154,14 +154,14 @@ export function buildStatusReplyMarkup(
|
|
|
154
154
|
`${formatTelegramCommandEmojiPrefix("thinking")}Thinking`,
|
|
155
155
|
currentThinkingLevel,
|
|
156
156
|
),
|
|
157
|
-
callback_data: "
|
|
157
|
+
callback_data: "menu:thinking",
|
|
158
158
|
},
|
|
159
159
|
]);
|
|
160
160
|
}
|
|
161
161
|
rows.push([
|
|
162
162
|
{
|
|
163
163
|
text: `🔢 Queue: ${queueItemCount}`,
|
|
164
|
-
callback_data: "
|
|
164
|
+
callback_data: "menu:queue",
|
|
165
165
|
},
|
|
166
166
|
]);
|
|
167
167
|
return { inline_keyboard: rows };
|
package/lib/menu.ts
CHANGED
|
@@ -276,11 +276,15 @@ export type TelegramMenuCallbackAction =
|
|
|
276
276
|
export function parseTelegramMenuCallbackAction(
|
|
277
277
|
data: string | undefined,
|
|
278
278
|
): TelegramMenuCallbackAction {
|
|
279
|
-
if (data === "
|
|
280
|
-
|
|
279
|
+
if (data === "menu:model" || data === "status:model") {
|
|
280
|
+
return { kind: "status", action: "model" };
|
|
281
|
+
}
|
|
282
|
+
if (data === "menu:thinking" || data === "status:thinking") {
|
|
281
283
|
return { kind: "status", action: "thinking" };
|
|
282
284
|
}
|
|
283
|
-
if (data === "
|
|
285
|
+
if (data === "menu:queue" || data === "status:queue") {
|
|
286
|
+
return { kind: "status", action: "queue" };
|
|
287
|
+
}
|
|
284
288
|
if (data?.startsWith("thinking:set:")) {
|
|
285
289
|
return {
|
|
286
290
|
kind: "thinking:set",
|
package/lib/routing.ts
CHANGED
|
@@ -14,6 +14,7 @@ import * as Model from "./model.ts";
|
|
|
14
14
|
import * as Queue from "./queue.ts";
|
|
15
15
|
import * as PromptTemplates from "./prompt-templates.ts";
|
|
16
16
|
import type { TelegramBridgeRuntime } from "./runtime.ts";
|
|
17
|
+
import * as TextGroups from "./text-groups.ts";
|
|
17
18
|
import * as Turns from "./turns.ts";
|
|
18
19
|
import * as Updates from "./updates.ts";
|
|
19
20
|
|
|
@@ -39,6 +40,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
39
40
|
bridgeRuntime: TelegramBridgeRuntime;
|
|
40
41
|
activeTurnRuntime: Queue.TelegramActiveTurnStore;
|
|
41
42
|
mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage, TContext>;
|
|
43
|
+
textGroupRuntime: TextGroups.TelegramTextGroupController<TMessage, TContext>;
|
|
42
44
|
telegramQueueStore: Queue.TelegramQueueStateStore<TContext>;
|
|
43
45
|
queueMutationRuntime: Queue.TelegramQueueMutationController<TContext>;
|
|
44
46
|
modelMenuRuntime: Menu.TelegramModelMenuRuntime<TModel>;
|
|
@@ -321,6 +323,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
321
323
|
mediaGroups: deps.mediaGroupRuntime,
|
|
322
324
|
dispatchMessages: commandOrPrompt.dispatchMessages,
|
|
323
325
|
});
|
|
326
|
+
const textDispatch = TextGroups.createTelegramTextGroupDispatchRuntime<
|
|
327
|
+
TMessage,
|
|
328
|
+
TContext
|
|
329
|
+
>({
|
|
330
|
+
textGroups: deps.textGroupRuntime,
|
|
331
|
+
dispatchMessages: commandOrPrompt.dispatchMessages,
|
|
332
|
+
dispatchSingleMessage: mediaDispatch.handleMessage,
|
|
333
|
+
});
|
|
324
334
|
const editRuntime = Turns.createTelegramQueuedPromptEditRuntime<
|
|
325
335
|
TMessage,
|
|
326
336
|
TContext
|
|
@@ -343,7 +353,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
343
353
|
answerCallbackQuery: deps.answerCallbackQuery,
|
|
344
354
|
handleAuthorizedTelegramCallbackQuery: callbackHandler,
|
|
345
355
|
sendTextReply: deps.sendTextReply,
|
|
346
|
-
handleAuthorizedTelegramMessage:
|
|
356
|
+
handleAuthorizedTelegramMessage: textDispatch.handleMessage,
|
|
347
357
|
handleAuthorizedTelegramEditedMessage: editRuntime.updateFromEditedMessage,
|
|
348
358
|
});
|
|
349
359
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram text-group coalescing helpers
|
|
3
|
+
* Zones: telegram inbound, queue admission, split-message recovery
|
|
4
|
+
* Owns conservative delayed grouping for Telegram text messages that look like automatic long-message splits
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const TELEGRAM_TEXT_GROUP_DEBOUNCE_MS = 700;
|
|
8
|
+
const TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH = 3600;
|
|
9
|
+
|
|
10
|
+
export interface TelegramTextGroupMessage {
|
|
11
|
+
message_id: number;
|
|
12
|
+
media_group_id?: string;
|
|
13
|
+
chat: { id: number };
|
|
14
|
+
from?: { id: number; is_bot?: boolean };
|
|
15
|
+
text?: string;
|
|
16
|
+
caption?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TelegramTextGroupState<TMessage, TContext = unknown> {
|
|
20
|
+
messages: TMessage[];
|
|
21
|
+
context?: TContext;
|
|
22
|
+
flushTimer?: ReturnType<typeof setTimeout>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TelegramTextGroupController<TMessage, TContext = unknown> {
|
|
26
|
+
queueMessage: (options: {
|
|
27
|
+
message: TMessage;
|
|
28
|
+
context: TContext;
|
|
29
|
+
dispatchMessages: (messages: TMessage[], ctx: TContext) => void;
|
|
30
|
+
}) => boolean;
|
|
31
|
+
clear: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TelegramTextGroupControllerOptions {
|
|
35
|
+
debounceMs?: number;
|
|
36
|
+
minSplitLength?: number;
|
|
37
|
+
setTimer?: (
|
|
38
|
+
callback: () => void,
|
|
39
|
+
ms: number,
|
|
40
|
+
) => ReturnType<typeof setTimeout>;
|
|
41
|
+
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TelegramTextGroupDispatchRuntime<
|
|
45
|
+
TMessage extends TelegramTextGroupMessage,
|
|
46
|
+
TContext,
|
|
47
|
+
> {
|
|
48
|
+
handleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TelegramGroupedInputClearerDeps {
|
|
52
|
+
clearMediaGroups: () => void;
|
|
53
|
+
clearTextGroups: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractTelegramTextGroupText(
|
|
57
|
+
message: TelegramTextGroupMessage,
|
|
58
|
+
): string {
|
|
59
|
+
return typeof message.text === "string" ? message.text : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isTelegramTextGroupCommand(text: string): boolean {
|
|
63
|
+
return text.trimStart().startsWith("/");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getTelegramTextGroupKey(
|
|
67
|
+
message: TelegramTextGroupMessage,
|
|
68
|
+
): string | undefined {
|
|
69
|
+
if (message.media_group_id) return undefined;
|
|
70
|
+
if (!message.from || message.from.is_bot) return undefined;
|
|
71
|
+
if (typeof message.text !== "string") return undefined;
|
|
72
|
+
return `${message.chat.id}:${message.from.id}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function canStartTelegramTextGroup(
|
|
76
|
+
message: TelegramTextGroupMessage,
|
|
77
|
+
minSplitLength: number,
|
|
78
|
+
): boolean {
|
|
79
|
+
const text = extractTelegramTextGroupText(message);
|
|
80
|
+
return text.length >= minSplitLength && !isTelegramTextGroupCommand(text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function canAppendTelegramTextGroupMessage<
|
|
84
|
+
TMessage extends TelegramTextGroupMessage,
|
|
85
|
+
>(
|
|
86
|
+
state: TelegramTextGroupState<TMessage, unknown>,
|
|
87
|
+
message: TMessage,
|
|
88
|
+
): boolean {
|
|
89
|
+
const text = extractTelegramTextGroupText(message);
|
|
90
|
+
const previous = state.messages.at(-1);
|
|
91
|
+
return (
|
|
92
|
+
!!previous &&
|
|
93
|
+
message.message_id > previous.message_id &&
|
|
94
|
+
message.message_id <= previous.message_id + 2 &&
|
|
95
|
+
text.length > 0 &&
|
|
96
|
+
!isTelegramTextGroupCommand(text)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function queueTelegramTextGroupMessage<
|
|
101
|
+
TMessage extends TelegramTextGroupMessage,
|
|
102
|
+
TContext = unknown,
|
|
103
|
+
>(options: {
|
|
104
|
+
message: TMessage;
|
|
105
|
+
context: TContext;
|
|
106
|
+
groups: Map<string, TelegramTextGroupState<TMessage, TContext>>;
|
|
107
|
+
debounceMs: number;
|
|
108
|
+
minSplitLength: number;
|
|
109
|
+
setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
110
|
+
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
|
|
111
|
+
dispatchMessages: (messages: TMessage[], ctx: TContext) => void;
|
|
112
|
+
}): boolean {
|
|
113
|
+
const key = getTelegramTextGroupKey(options.message);
|
|
114
|
+
if (!key) return false;
|
|
115
|
+
const existing = options.groups.get(key);
|
|
116
|
+
if (
|
|
117
|
+
!existing &&
|
|
118
|
+
!canStartTelegramTextGroup(options.message, options.minSplitLength)
|
|
119
|
+
)
|
|
120
|
+
return false;
|
|
121
|
+
if (existing && !canAppendTelegramTextGroupMessage(existing, options.message))
|
|
122
|
+
return false;
|
|
123
|
+
const state = existing ?? { messages: [] };
|
|
124
|
+
state.messages.push(options.message);
|
|
125
|
+
state.context = options.context;
|
|
126
|
+
if (state.flushTimer) options.clearTimer(state.flushTimer);
|
|
127
|
+
state.flushTimer = options.setTimer(() => {
|
|
128
|
+
const queued = options.groups.get(key);
|
|
129
|
+
options.groups.delete(key);
|
|
130
|
+
if (!queued || queued.context === undefined) return;
|
|
131
|
+
options.dispatchMessages(queued.messages, queued.context);
|
|
132
|
+
}, options.debounceMs);
|
|
133
|
+
options.groups.set(key, state);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createTelegramTextGroupController<
|
|
138
|
+
TMessage extends TelegramTextGroupMessage,
|
|
139
|
+
TContext = unknown,
|
|
140
|
+
>(
|
|
141
|
+
options: TelegramTextGroupControllerOptions = {},
|
|
142
|
+
): TelegramTextGroupController<TMessage, TContext> {
|
|
143
|
+
const groups = new Map<string, TelegramTextGroupState<TMessage, TContext>>();
|
|
144
|
+
const debounceMs = options.debounceMs ?? TELEGRAM_TEXT_GROUP_DEBOUNCE_MS;
|
|
145
|
+
const minSplitLength =
|
|
146
|
+
options.minSplitLength ?? TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH;
|
|
147
|
+
const setTimer =
|
|
148
|
+
options.setTimer ??
|
|
149
|
+
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
|
|
150
|
+
setTimeout(callback, ms));
|
|
151
|
+
const clearTimer = options.clearTimer ?? clearTimeout;
|
|
152
|
+
return {
|
|
153
|
+
queueMessage: ({ message, context, dispatchMessages }) =>
|
|
154
|
+
queueTelegramTextGroupMessage({
|
|
155
|
+
message,
|
|
156
|
+
context,
|
|
157
|
+
groups,
|
|
158
|
+
debounceMs,
|
|
159
|
+
minSplitLength,
|
|
160
|
+
setTimer,
|
|
161
|
+
clearTimer,
|
|
162
|
+
dispatchMessages,
|
|
163
|
+
}),
|
|
164
|
+
clear: () => {
|
|
165
|
+
for (const state of groups.values()) {
|
|
166
|
+
if (state.flushTimer) clearTimer(state.flushTimer);
|
|
167
|
+
}
|
|
168
|
+
groups.clear();
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createTelegramTextGroupDispatchRuntime<
|
|
174
|
+
TMessage extends TelegramTextGroupMessage,
|
|
175
|
+
TContext,
|
|
176
|
+
>(deps: {
|
|
177
|
+
textGroups: TelegramTextGroupController<TMessage, TContext>;
|
|
178
|
+
dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
|
|
179
|
+
dispatchSingleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
|
|
180
|
+
}): TelegramTextGroupDispatchRuntime<TMessage, TContext> {
|
|
181
|
+
return {
|
|
182
|
+
handleMessage: async (message, ctx) => {
|
|
183
|
+
const queuedTextGroup = deps.textGroups.queueMessage({
|
|
184
|
+
message,
|
|
185
|
+
context: ctx,
|
|
186
|
+
dispatchMessages: (messages, queuedCtx) => {
|
|
187
|
+
void deps.dispatchMessages(messages, queuedCtx);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
if (queuedTextGroup) return;
|
|
191
|
+
await deps.dispatchSingleMessage(message, ctx);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createTelegramGroupedInputClearer(
|
|
197
|
+
deps: TelegramGroupedInputClearerDeps,
|
|
198
|
+
): () => void {
|
|
199
|
+
return () => {
|
|
200
|
+
deps.clearMediaGroups();
|
|
201
|
+
deps.clearTextGroups();
|
|
202
|
+
};
|
|
203
|
+
}
|