@llblab/pi-telegram 0.7.1 → 0.8.0
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 +6 -5
- package/CHANGELOG.md +20 -1
- package/README.md +36 -11
- package/docs/README.md +2 -2
- package/docs/architecture.md +7 -7
- package/docs/callback-namespaces.md +1 -1
- package/docs/inbound-handlers.md +93 -0
- package/docs/outbound-handlers.md +40 -4
- package/index.ts +52 -32
- package/lib/config.ts +9 -3
- package/lib/inbound-handlers.ts +588 -0
- package/lib/menu-queue.ts +1 -1
- package/lib/menu-status.ts +4 -4
- package/lib/menu.ts +7 -3
- package/lib/{attachments.ts → outbound-attachments.ts} +34 -34
- package/lib/outbound-handlers.ts +245 -0
- package/lib/prompts.ts +1 -1
- package/lib/routing.ts +14 -4
- package/lib/text-groups.ts +203 -0
- package/package.json +1 -1
- package/docs/attachment-handlers.md +0 -50
- package/lib/attachment-handlers.ts +0 -423
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
# Attachment Handlers
|
|
2
|
-
|
|
3
|
-
`pi-telegram` can run ordered inbound attachment handlers after downloading files and before the Telegram turn enters the π queue.
|
|
4
|
-
|
|
5
|
-
This document is the local adaptation of the portable [Command Template Standard](./command-templates.md).
|
|
6
|
-
|
|
7
|
-
## Config Shape
|
|
8
|
-
|
|
9
|
-
`telegram.json` may define `attachmentHandlers`:
|
|
10
|
-
|
|
11
|
-
```json
|
|
12
|
-
{
|
|
13
|
-
"attachmentHandlers": [
|
|
14
|
-
{
|
|
15
|
-
"type": "voice",
|
|
16
|
-
"template": "/path/to/stt1 --file {file} --lang {lang=ru}"
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
"mime": "audio/*",
|
|
20
|
-
"template": "/path/to/stt2 --file {file} --lang {lang=ru}"
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Handlers match by `type`, `mime`, or `match`. Wildcards such as `audio/*` are accepted. Each matching handler must provide `template`; a string is one command, and an array is ordered composition. Top-level `args` and `defaults` apply to composed steps unless a step defines private values. The command-template default timeout applies automatically. Legacy configs may still use `pipe` as a local alias.
|
|
27
|
-
|
|
28
|
-
## Template Placeholders
|
|
29
|
-
|
|
30
|
-
Attachment handlers support these built-in placeholders:
|
|
31
|
-
|
|
32
|
-
| Placeholder | Value |
|
|
33
|
-
| ----------- | ---------------------------------------------------------------- |
|
|
34
|
-
| `{file}` | Full local path to the downloaded file |
|
|
35
|
-
| `{mime}` | MIME type if known |
|
|
36
|
-
| `{type}` | Attachment kind such as `voice`, `audio`, `document`, or `photo` |
|
|
37
|
-
|
|
38
|
-
`defaults` may provide additional placeholder values such as `{lang}` or `{model}`. `args` is only a string-array declaration of supported placeholders; defaults belong in `defaults` or inline placeholders such as `{lang=ru}`. Examples prefer explicit flag-style CLIs for readability, but positional forms such as `/path/to/stt {file} {lang=ru} {model=voxtral-mini-latest}` are equally valid when the target script supports them.
|
|
39
|
-
|
|
40
|
-
If a top-level one-step handler template has no `{file}` placeholder, the downloaded file path is appended as the last command arg as a one-step handler convenience. Composition steps are plain command templates and do not receive implicit file-path args; include `{file}` explicitly where needed.
|
|
41
|
-
|
|
42
|
-
## Ordered Fallbacks
|
|
43
|
-
|
|
44
|
-
A handler list is ordered. For each attachment, matching handlers run in list order and stop after the first successful handler. A composed handler counts as one handler for fallback purposes: if any step fails, the next matching handler is tried.
|
|
45
|
-
|
|
46
|
-
If a matching handler fails with a non-zero exit code, the runtime records diagnostics and tries the next matching handler. If every matching handler fails, the attachment remains visible in the prompt as a normal local file reference.
|
|
47
|
-
|
|
48
|
-
## Prompt Output
|
|
49
|
-
|
|
50
|
-
Local attachments stay in the prompt under `[attachments] <directory>` with relative file entries. Successful handler stdout is added under `[outputs]`. For composed handlers, each step receives the previous step's stdout on stdin by default, and stdout from the last successful step is used as the handler output. Empty output and failed handler output are omitted from the prompt text.
|
|
@@ -1,423 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Telegram inbound attachment handler pipeline
|
|
3
|
-
* Zones: telegram inbound, command templates, prompt preparation
|
|
4
|
-
* Owns MIME/type matching, command-template execution, fallback handling, and prompt injection before prompt enqueueing
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { basename } from "node:path";
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
buildCommandTemplateInvocation,
|
|
11
|
-
expandCommandTemplateConfigs,
|
|
12
|
-
normalizeCommandTemplateConfig,
|
|
13
|
-
type CommandTemplateConfig,
|
|
14
|
-
type CommandTemplateObjectConfig,
|
|
15
|
-
} from "./command-templates.ts";
|
|
16
|
-
|
|
17
|
-
const DEFAULT_ATTACHMENT_HANDLER_TIMEOUT_MS = 120_000;
|
|
18
|
-
|
|
19
|
-
type TelegramAttachmentCommandTemplateConfig =
|
|
20
|
-
| string
|
|
21
|
-
| CommandTemplateObjectConfig;
|
|
22
|
-
|
|
23
|
-
export interface TelegramAttachmentHandlerConfig {
|
|
24
|
-
match?: string | string[];
|
|
25
|
-
mime?: string | string[];
|
|
26
|
-
type?: string | string[];
|
|
27
|
-
template?: string | TelegramAttachmentCommandTemplateConfig[];
|
|
28
|
-
pipe?: TelegramAttachmentCommandTemplateConfig[];
|
|
29
|
-
args?: string[];
|
|
30
|
-
defaults?: Record<string, unknown>;
|
|
31
|
-
timeout?: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface TelegramAttachmentHandlerFile {
|
|
35
|
-
path: string;
|
|
36
|
-
fileName?: string;
|
|
37
|
-
mimeType?: string;
|
|
38
|
-
kind?: string;
|
|
39
|
-
isImage?: boolean;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface TelegramAttachmentHandlerOutput {
|
|
43
|
-
file: TelegramAttachmentHandlerFile;
|
|
44
|
-
output: string;
|
|
45
|
-
handler: TelegramAttachmentHandlerConfig;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface TelegramAttachmentHandlerProcessResult<
|
|
49
|
-
TFile extends TelegramAttachmentHandlerFile = TelegramAttachmentHandlerFile,
|
|
50
|
-
> {
|
|
51
|
-
rawText: string;
|
|
52
|
-
promptFiles: TFile[];
|
|
53
|
-
handlerOutputs: string[];
|
|
54
|
-
handledFiles: TelegramAttachmentHandlerOutput[];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface TelegramAttachmentHandlerExecOptions {
|
|
58
|
-
cwd?: string;
|
|
59
|
-
timeout?: number;
|
|
60
|
-
signal?: AbortSignal;
|
|
61
|
-
stdin?: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface TelegramAttachmentHandlerExecResult {
|
|
65
|
-
stdout: string;
|
|
66
|
-
stderr: string;
|
|
67
|
-
code: number;
|
|
68
|
-
killed: boolean;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface TelegramAttachmentHandlerRuntimeContext {
|
|
72
|
-
cwd: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface TelegramAttachmentHandlerRuntimeDeps<TContext> {
|
|
76
|
-
getHandlers: () => TelegramAttachmentHandlerConfig[] | undefined;
|
|
77
|
-
execCommand: (
|
|
78
|
-
command: string,
|
|
79
|
-
args: string[],
|
|
80
|
-
options?: TelegramAttachmentHandlerExecOptions,
|
|
81
|
-
) => Promise<TelegramAttachmentHandlerExecResult>;
|
|
82
|
-
getCwd: (ctx: TContext) => string;
|
|
83
|
-
recordRuntimeEvent?: (
|
|
84
|
-
category: string,
|
|
85
|
-
error: unknown,
|
|
86
|
-
details?: Record<string, unknown>,
|
|
87
|
-
) => void;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export interface TelegramAttachmentHandlerRuntime<TContext> {
|
|
91
|
-
process: <TFile extends TelegramAttachmentHandlerFile>(
|
|
92
|
-
files: TFile[],
|
|
93
|
-
rawText: string,
|
|
94
|
-
ctx: TContext,
|
|
95
|
-
) => Promise<TelegramAttachmentHandlerProcessResult<TFile>>;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
interface AttachmentHandlerInvocation {
|
|
99
|
-
command: string;
|
|
100
|
-
args: string[];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function normalizeStringList(value: string | string[] | undefined): string[] {
|
|
104
|
-
if (Array.isArray(value)) {
|
|
105
|
-
return value
|
|
106
|
-
.map(String)
|
|
107
|
-
.map((item) => item.trim())
|
|
108
|
-
.filter(Boolean);
|
|
109
|
-
}
|
|
110
|
-
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
111
|
-
return [];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function matchesWildcard(pattern: string, value: string | undefined): boolean {
|
|
115
|
-
if (!value) return false;
|
|
116
|
-
const normalizedPattern = pattern.toLowerCase();
|
|
117
|
-
const normalizedValue = value.toLowerCase();
|
|
118
|
-
if (normalizedPattern === "*") return true;
|
|
119
|
-
const escaped = normalizedPattern
|
|
120
|
-
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
121
|
-
.replace(/\\\*/g, ".*");
|
|
122
|
-
return new RegExp(`^${escaped}$`).test(normalizedValue);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function handlerHasSelectors(
|
|
126
|
-
handler: TelegramAttachmentHandlerConfig,
|
|
127
|
-
): boolean {
|
|
128
|
-
return (
|
|
129
|
-
normalizeStringList(handler.match).length > 0 ||
|
|
130
|
-
normalizeStringList(handler.mime).length > 0 ||
|
|
131
|
-
normalizeStringList(handler.type).length > 0
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function matchesAnyPattern(
|
|
136
|
-
patterns: string[],
|
|
137
|
-
value: string | undefined,
|
|
138
|
-
): boolean {
|
|
139
|
-
return patterns.some((pattern) => matchesWildcard(pattern, value));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function telegramAttachmentHandlerMatchesFile(
|
|
143
|
-
handler: TelegramAttachmentHandlerConfig,
|
|
144
|
-
file: TelegramAttachmentHandlerFile,
|
|
145
|
-
): boolean {
|
|
146
|
-
if (!handlerHasSelectors(handler)) return true;
|
|
147
|
-
const matchPatterns = normalizeStringList(handler.match);
|
|
148
|
-
const mimePatterns = normalizeStringList(handler.mime);
|
|
149
|
-
const typePatterns = normalizeStringList(handler.type);
|
|
150
|
-
if (matchesAnyPattern(mimePatterns, file.mimeType)) return true;
|
|
151
|
-
if (matchesAnyPattern(typePatterns, file.kind)) return true;
|
|
152
|
-
if (matchesAnyPattern(matchPatterns, file.mimeType)) return true;
|
|
153
|
-
return matchesAnyPattern(matchPatterns, file.kind);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function findTelegramAttachmentHandlers(
|
|
157
|
-
handlers: TelegramAttachmentHandlerConfig[] | undefined,
|
|
158
|
-
file: TelegramAttachmentHandlerFile,
|
|
159
|
-
): TelegramAttachmentHandlerConfig[] {
|
|
160
|
-
if (!Array.isArray(handlers)) return [];
|
|
161
|
-
return handlers.filter(
|
|
162
|
-
(handler) =>
|
|
163
|
-
!!handler &&
|
|
164
|
-
typeof handler === "object" &&
|
|
165
|
-
telegramAttachmentHandlerMatchesFile(handler, file),
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function findTelegramAttachmentHandler(
|
|
170
|
-
handlers: TelegramAttachmentHandlerConfig[] | undefined,
|
|
171
|
-
file: TelegramAttachmentHandlerFile,
|
|
172
|
-
): TelegramAttachmentHandlerConfig | undefined {
|
|
173
|
-
return findTelegramAttachmentHandlers(handlers, file)[0];
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function hasAttachmentFilePlaceholder(value: string): boolean {
|
|
177
|
-
return /\{file\}/.test(value);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function getTelegramAttachmentHandlerTemplateValues(
|
|
181
|
-
file: TelegramAttachmentHandlerFile,
|
|
182
|
-
): Record<string, string> {
|
|
183
|
-
return {
|
|
184
|
-
file: file.path,
|
|
185
|
-
mime: file.mimeType ?? "",
|
|
186
|
-
type: file.kind ?? "",
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function buildTelegramAttachmentTemplateInvocation(
|
|
191
|
-
handler: CommandTemplateConfig,
|
|
192
|
-
file: TelegramAttachmentHandlerFile,
|
|
193
|
-
cwd: string,
|
|
194
|
-
appendFileIfMissing = true,
|
|
195
|
-
): AttachmentHandlerInvocation {
|
|
196
|
-
const values = getTelegramAttachmentHandlerTemplateValues(file);
|
|
197
|
-
const templateConfig = normalizeCommandTemplateConfig(handler);
|
|
198
|
-
const hadFilePlaceholder =
|
|
199
|
-
typeof templateConfig.template === "string"
|
|
200
|
-
? hasAttachmentFilePlaceholder(templateConfig.template)
|
|
201
|
-
: false;
|
|
202
|
-
const invocation = buildCommandTemplateInvocation(handler, values, cwd, {
|
|
203
|
-
emptyMessage: "Attachment handler template is empty",
|
|
204
|
-
missingLabel: "attachment handler template",
|
|
205
|
-
});
|
|
206
|
-
if (appendFileIfMissing && !hadFilePlaceholder)
|
|
207
|
-
invocation.args.push(file.path);
|
|
208
|
-
return invocation;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function buildTelegramAttachmentHandlerInvocation(
|
|
212
|
-
handler: CommandTemplateConfig,
|
|
213
|
-
file: TelegramAttachmentHandlerFile,
|
|
214
|
-
cwd: string,
|
|
215
|
-
appendFileIfMissing = true,
|
|
216
|
-
): AttachmentHandlerInvocation {
|
|
217
|
-
const { template } = normalizeCommandTemplateConfig(handler);
|
|
218
|
-
if (!template) throw new Error("Attachment handler template is required");
|
|
219
|
-
return buildTelegramAttachmentTemplateInvocation(
|
|
220
|
-
handler,
|
|
221
|
-
file,
|
|
222
|
-
cwd,
|
|
223
|
-
appendFileIfMissing,
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function getTelegramAttachmentHandlerConfiguredTimeout(
|
|
228
|
-
handler: TelegramAttachmentCommandTemplateConfig,
|
|
229
|
-
): number | undefined {
|
|
230
|
-
const timeout = typeof handler === "string" ? undefined : handler.timeout;
|
|
231
|
-
return typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
|
|
232
|
-
? timeout
|
|
233
|
-
: undefined;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function getTelegramAttachmentHandlerTimeout(
|
|
237
|
-
handler: TelegramAttachmentCommandTemplateConfig,
|
|
238
|
-
): number {
|
|
239
|
-
return (
|
|
240
|
-
getTelegramAttachmentHandlerConfiguredTimeout(handler) ??
|
|
241
|
-
DEFAULT_ATTACHMENT_HANDLER_TIMEOUT_MS
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function getRemainingTelegramAttachmentTimeout(
|
|
246
|
-
timeout: number,
|
|
247
|
-
startedAt: number,
|
|
248
|
-
): number {
|
|
249
|
-
return Math.max(1, timeout - (Date.now() - startedAt));
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function getTelegramAttachmentCompositionStepTimeout(
|
|
253
|
-
handler: TelegramAttachmentHandlerConfig,
|
|
254
|
-
step: TelegramAttachmentCommandTemplateConfig,
|
|
255
|
-
startedAt: number,
|
|
256
|
-
): number {
|
|
257
|
-
const remaining = getRemainingTelegramAttachmentTimeout(
|
|
258
|
-
getTelegramAttachmentHandlerTimeout(handler),
|
|
259
|
-
startedAt,
|
|
260
|
-
);
|
|
261
|
-
const stepTimeout = getTelegramAttachmentHandlerConfiguredTimeout(step);
|
|
262
|
-
return stepTimeout === undefined
|
|
263
|
-
? remaining
|
|
264
|
-
: Math.min(stepTimeout, remaining);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function getTelegramAttachmentHandlerKind(
|
|
268
|
-
handler: TelegramAttachmentHandlerConfig,
|
|
269
|
-
): string {
|
|
270
|
-
if (Array.isArray(handler.template) || handler.pipe?.length)
|
|
271
|
-
return "composition";
|
|
272
|
-
if (handler.template) return "template";
|
|
273
|
-
return "unknown";
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function formatTelegramAttachmentHandlerFailure(
|
|
277
|
-
result: TelegramAttachmentHandlerExecResult,
|
|
278
|
-
): string {
|
|
279
|
-
const parts = [
|
|
280
|
-
`Attachment handler exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
|
|
281
|
-
];
|
|
282
|
-
if (result.stderr.trim()) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
|
|
283
|
-
if (result.stdout.trim()) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
|
|
284
|
-
return parts.join("\n\n");
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async function executeTelegramAttachmentHandlerInvocation(
|
|
288
|
-
handler: TelegramAttachmentCommandTemplateConfig,
|
|
289
|
-
file: TelegramAttachmentHandlerFile,
|
|
290
|
-
cwd: string,
|
|
291
|
-
deps: Pick<TelegramAttachmentHandlerRuntimeDeps<unknown>, "execCommand">,
|
|
292
|
-
appendFileIfMissing = true,
|
|
293
|
-
timeout = getTelegramAttachmentHandlerTimeout(handler),
|
|
294
|
-
stdin?: string,
|
|
295
|
-
): Promise<string> {
|
|
296
|
-
const invocation = buildTelegramAttachmentHandlerInvocation(
|
|
297
|
-
handler,
|
|
298
|
-
file,
|
|
299
|
-
cwd,
|
|
300
|
-
appendFileIfMissing,
|
|
301
|
-
);
|
|
302
|
-
const result = await deps.execCommand(invocation.command, invocation.args, {
|
|
303
|
-
cwd,
|
|
304
|
-
timeout,
|
|
305
|
-
...(typeof handler === "object" && handler.retry !== undefined
|
|
306
|
-
? { retry: handler.retry }
|
|
307
|
-
: {}),
|
|
308
|
-
...(stdin !== undefined ? { stdin } : {}),
|
|
309
|
-
});
|
|
310
|
-
if (result.code !== 0)
|
|
311
|
-
throw new Error(formatTelegramAttachmentHandlerFailure(result));
|
|
312
|
-
return result.stdout;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function getTelegramAttachmentHandlerCompositionSteps(
|
|
316
|
-
handler: TelegramAttachmentHandlerConfig,
|
|
317
|
-
): TelegramAttachmentCommandTemplateConfig[] {
|
|
318
|
-
if (Array.isArray(handler.template)) {
|
|
319
|
-
return expandCommandTemplateConfigs(
|
|
320
|
-
handler,
|
|
321
|
-
) as TelegramAttachmentCommandTemplateConfig[];
|
|
322
|
-
}
|
|
323
|
-
if (handler.pipe?.length) {
|
|
324
|
-
return expandCommandTemplateConfigs({
|
|
325
|
-
...handler,
|
|
326
|
-
template: handler.pipe,
|
|
327
|
-
}) as TelegramAttachmentCommandTemplateConfig[];
|
|
328
|
-
}
|
|
329
|
-
return [];
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
async function executeTelegramAttachmentHandler(
|
|
333
|
-
handler: TelegramAttachmentHandlerConfig,
|
|
334
|
-
file: TelegramAttachmentHandlerFile,
|
|
335
|
-
cwd: string,
|
|
336
|
-
deps: Pick<TelegramAttachmentHandlerRuntimeDeps<unknown>, "execCommand">,
|
|
337
|
-
): Promise<string> {
|
|
338
|
-
const steps = getTelegramAttachmentHandlerCompositionSteps(handler);
|
|
339
|
-
if (steps.length === 0) {
|
|
340
|
-
const output = await executeTelegramAttachmentHandlerInvocation(
|
|
341
|
-
handler,
|
|
342
|
-
file,
|
|
343
|
-
cwd,
|
|
344
|
-
deps,
|
|
345
|
-
);
|
|
346
|
-
return output.trim();
|
|
347
|
-
}
|
|
348
|
-
const startedAt = Date.now();
|
|
349
|
-
let output = "";
|
|
350
|
-
for (const [index, step] of steps.entries()) {
|
|
351
|
-
try {
|
|
352
|
-
output = await executeTelegramAttachmentHandlerInvocation(
|
|
353
|
-
step,
|
|
354
|
-
file,
|
|
355
|
-
cwd,
|
|
356
|
-
deps,
|
|
357
|
-
false,
|
|
358
|
-
getTelegramAttachmentCompositionStepTimeout(handler, step, startedAt),
|
|
359
|
-
index === 0 ? undefined : output,
|
|
360
|
-
);
|
|
361
|
-
} catch (error) {
|
|
362
|
-
if (typeof step === "object" && step.critical) throw error;
|
|
363
|
-
output = "";
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
return output.trim();
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
export async function processTelegramAttachmentHandlers<
|
|
370
|
-
TFile extends TelegramAttachmentHandlerFile,
|
|
371
|
-
>(options: {
|
|
372
|
-
files: TFile[];
|
|
373
|
-
rawText: string;
|
|
374
|
-
handlers?: TelegramAttachmentHandlerConfig[];
|
|
375
|
-
cwd: string;
|
|
376
|
-
execCommand: TelegramAttachmentHandlerRuntimeDeps<unknown>["execCommand"];
|
|
377
|
-
recordRuntimeEvent?: TelegramAttachmentHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
|
|
378
|
-
}): Promise<TelegramAttachmentHandlerProcessResult<TFile>> {
|
|
379
|
-
const promptFiles: TFile[] = [...options.files];
|
|
380
|
-
const outputs: TelegramAttachmentHandlerOutput[] = [];
|
|
381
|
-
for (const file of options.files) {
|
|
382
|
-
const handlers = findTelegramAttachmentHandlers(options.handlers, file);
|
|
383
|
-
for (const handler of handlers) {
|
|
384
|
-
try {
|
|
385
|
-
const output = await executeTelegramAttachmentHandler(
|
|
386
|
-
handler,
|
|
387
|
-
file,
|
|
388
|
-
options.cwd,
|
|
389
|
-
options,
|
|
390
|
-
);
|
|
391
|
-
if (output) outputs.push({ file, output, handler });
|
|
392
|
-
break;
|
|
393
|
-
} catch (error) {
|
|
394
|
-
options.recordRuntimeEvent?.("attachment-handler", error, {
|
|
395
|
-
fileName: file.fileName || basename(file.path),
|
|
396
|
-
handler: getTelegramAttachmentHandlerKind(handler),
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return {
|
|
402
|
-
rawText: options.rawText,
|
|
403
|
-
promptFiles,
|
|
404
|
-
handlerOutputs: outputs.map((output) => output.output),
|
|
405
|
-
handledFiles: outputs,
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export function createTelegramAttachmentHandlerRuntime<TContext>(
|
|
410
|
-
deps: TelegramAttachmentHandlerRuntimeDeps<TContext>,
|
|
411
|
-
): TelegramAttachmentHandlerRuntime<TContext> {
|
|
412
|
-
return {
|
|
413
|
-
process: (files, rawText, ctx) =>
|
|
414
|
-
processTelegramAttachmentHandlers({
|
|
415
|
-
files,
|
|
416
|
-
rawText,
|
|
417
|
-
handlers: deps.getHandlers(),
|
|
418
|
-
cwd: deps.getCwd(ctx),
|
|
419
|
-
execCommand: deps.execCommand,
|
|
420
|
-
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
421
|
-
}),
|
|
422
|
-
};
|
|
423
|
-
}
|