@fickydev/pigent 0.1.6 → 0.1.8
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 +6 -0
- package/PLAN.md +64 -1
- package/TODO.md +34 -1
- package/agents/assistant/agent.yaml +2 -0
- package/drizzle/migrations/0001_session_model_overrides.sql +2 -0
- package/drizzle/migrations/meta/0001_snapshot.json +519 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/pigent.yaml +9 -0
- package/profiles/assistant.yaml +1 -0
- package/src/agents/AgentRunner.ts +8 -2
- package/src/agents/BotCommandHandler.ts +210 -1
- package/src/channels/telegram/TelegramApi.ts +172 -14
- package/src/channels/telegram/TelegramPollingAdapter.ts +45 -2
- package/src/channels/telegram/types.ts +20 -0
- package/src/channels/types.ts +6 -0
- package/src/config/loadConfig.ts +1 -0
- package/src/config/schemas.ts +14 -1
- package/src/daemon/AgentDaemon.ts +18 -2
- package/src/db/repositories/SessionRepository.ts +33 -0
- package/src/db/schema.ts +2 -0
- package/src/pi/PiAgentRunner.ts +6 -0
- package/src/pi/PiModelResolver.ts +76 -0
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
import type { InboundMessage } from "../channels/types";
|
|
1
|
+
import type { InboundMessage, InlineKeyboardButton } from "../channels/types";
|
|
2
|
+
import type { ModelChoiceConfig } from "../config/schemas";
|
|
2
3
|
import type { Repositories } from "../db/repositories";
|
|
4
|
+
import { isValidModelRef } from "../pi/PiModelResolver";
|
|
3
5
|
import type { AgentRegistry } from "./AgentRegistry";
|
|
4
6
|
|
|
5
7
|
export type BotCommandResult =
|
|
6
8
|
| {
|
|
7
9
|
handled: true;
|
|
8
10
|
text: string;
|
|
11
|
+
inlineKeyboard?: InlineKeyboardButton[][];
|
|
9
12
|
}
|
|
10
13
|
| {
|
|
11
14
|
handled: false;
|
|
12
15
|
};
|
|
13
16
|
|
|
17
|
+
const THINKING_LEVELS = ["off", "low", "medium", "high"] as const;
|
|
18
|
+
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
19
|
+
|
|
14
20
|
export class BotCommandHandler {
|
|
15
21
|
constructor(
|
|
16
22
|
private readonly registry: AgentRegistry,
|
|
17
23
|
private readonly repositories: Repositories,
|
|
24
|
+
private readonly modelChoices: ModelChoiceConfig[] = [],
|
|
18
25
|
) {}
|
|
19
26
|
|
|
20
27
|
async handle(message: InboundMessage): Promise<BotCommandResult> {
|
|
@@ -26,6 +33,18 @@ export class BotCommandHandler {
|
|
|
26
33
|
return { handled: true, text: this.helpText() };
|
|
27
34
|
case "/agents":
|
|
28
35
|
return { handled: true, text: await this.agentsText(message) };
|
|
36
|
+
case "/model":
|
|
37
|
+
return await this.modelResult(message);
|
|
38
|
+
case "/thinking":
|
|
39
|
+
return await this.thinkingResult(message);
|
|
40
|
+
case "/model:set":
|
|
41
|
+
return { handled: true, text: await this.setModelFromChoice(message) };
|
|
42
|
+
case "/model:default":
|
|
43
|
+
return { handled: true, text: await this.clearModel(message) };
|
|
44
|
+
case "/thinking:set":
|
|
45
|
+
return { handled: true, text: await this.setThinkingFromCallback(message) };
|
|
46
|
+
case "/thinking:default":
|
|
47
|
+
return { handled: true, text: await this.clearThinking(message) };
|
|
29
48
|
default:
|
|
30
49
|
return { handled: false };
|
|
31
50
|
}
|
|
@@ -36,6 +55,12 @@ export class BotCommandHandler {
|
|
|
36
55
|
"Pigent commands:",
|
|
37
56
|
"/help - show this help",
|
|
38
57
|
"/agents - list agents for this chat",
|
|
58
|
+
"/model - show model picker for this chat session",
|
|
59
|
+
"/model <provider/modelId> - set model for this chat session",
|
|
60
|
+
"/model default - clear model override for this chat session",
|
|
61
|
+
"/thinking - show thinking picker for this chat session",
|
|
62
|
+
"/thinking <off|low|medium|high> - set thinking level for this chat session",
|
|
63
|
+
"/thinking default - clear thinking override for this chat session",
|
|
39
64
|
"/agent <agentId> <message> - send message to specific agent",
|
|
40
65
|
"@agentId <message> - send message to specific agent",
|
|
41
66
|
].join("\n");
|
|
@@ -62,4 +87,188 @@ export class BotCommandHandler {
|
|
|
62
87
|
`Allowed agents: ${allowedAgentIds.length > 0 ? allowedAgentIds.join(", ") : "none"}`,
|
|
63
88
|
].join("\n");
|
|
64
89
|
}
|
|
90
|
+
|
|
91
|
+
private async modelResult(message: InboundMessage): Promise<BotCommandResult> {
|
|
92
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
93
|
+
if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
|
|
94
|
+
|
|
95
|
+
const [, ...args] = message.text.trim().split(/\s+/);
|
|
96
|
+
const value = args.join(" ").trim();
|
|
97
|
+
|
|
98
|
+
if (!value) {
|
|
99
|
+
return {
|
|
100
|
+
handled: true,
|
|
101
|
+
text: [
|
|
102
|
+
`Agent: ${sessionResult.agentId}`,
|
|
103
|
+
`Session model: ${sessionResult.session.model ?? "default"}`,
|
|
104
|
+
this.modelChoices.length > 0
|
|
105
|
+
? "Choose a model:"
|
|
106
|
+
: "No model choices configured. Use /model <provider/modelId> to set manually.",
|
|
107
|
+
].join("\n"),
|
|
108
|
+
inlineKeyboard: this.modelChoices.length > 0 ? this.modelKeyboard() : undefined,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (value === "default") {
|
|
113
|
+
return { handled: true, text: await this.clearModel(message) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!isValidModelRef(value)) {
|
|
117
|
+
return { handled: true, text: "Invalid model. Use provider/modelId, for example: anthropic/claude-opus-4-5" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, value);
|
|
121
|
+
return { handled: true, text: `Session model for ${session.agentId} set to ${session.model}.` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async thinkingResult(message: InboundMessage): Promise<BotCommandResult> {
|
|
125
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
126
|
+
if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
|
|
127
|
+
|
|
128
|
+
const [, rawValue] = message.text.trim().split(/\s+/, 2);
|
|
129
|
+
const value = rawValue?.trim();
|
|
130
|
+
|
|
131
|
+
if (!value) {
|
|
132
|
+
return {
|
|
133
|
+
handled: true,
|
|
134
|
+
text: [
|
|
135
|
+
`Agent: ${sessionResult.agentId}`,
|
|
136
|
+
`Session thinking level: ${sessionResult.session.thinkingLevel ?? "default"}`,
|
|
137
|
+
"Choose thinking level:",
|
|
138
|
+
].join("\n"),
|
|
139
|
+
inlineKeyboard: this.thinkingKeyboard(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (value === "default") {
|
|
144
|
+
return { handled: true, text: await this.clearThinking(message) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!isThinkingLevel(value)) {
|
|
148
|
+
return { handled: true, text: "Invalid thinking level. Use one of: off, low, medium, high, default" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
|
|
152
|
+
return { handled: true, text: `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async setModelFromChoice(message: InboundMessage): Promise<string> {
|
|
156
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
157
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
158
|
+
|
|
159
|
+
const choiceIndex = Number(message.text.slice("/model:set:".length));
|
|
160
|
+
if (!Number.isSafeInteger(choiceIndex) || choiceIndex < 0 || choiceIndex >= this.modelChoices.length) {
|
|
161
|
+
return "Unknown model choice.";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const choice = this.modelChoices[choiceIndex];
|
|
165
|
+
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, choice.id);
|
|
166
|
+
return `Session model for ${session.agentId} set to ${choice.label} (${session.model}).`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async clearModel(message: InboundMessage): Promise<string> {
|
|
170
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
171
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
172
|
+
|
|
173
|
+
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, null);
|
|
174
|
+
return `Session model cleared for ${session.agentId}. Current: ${session.model ?? "default"}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async setThinkingFromCallback(message: InboundMessage): Promise<string> {
|
|
178
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
179
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
180
|
+
|
|
181
|
+
const value = message.text.slice("/thinking:set:".length);
|
|
182
|
+
if (!isThinkingLevel(value)) {
|
|
183
|
+
return "Invalid thinking level. Use one of: off, low, medium, high, default";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
|
|
187
|
+
return `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async clearThinking(message: InboundMessage): Promise<string> {
|
|
191
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
192
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
193
|
+
|
|
194
|
+
const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, null);
|
|
195
|
+
return `Session thinking level cleared for ${session.agentId}. Current: ${session.thinkingLevel ?? "default"}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private modelKeyboard(): InlineKeyboardButton[][] {
|
|
199
|
+
const rows = chunk(
|
|
200
|
+
this.modelChoices.map((choice, index) => ({
|
|
201
|
+
text: choice.label,
|
|
202
|
+
callbackData: `model:set:${index}`,
|
|
203
|
+
})),
|
|
204
|
+
2,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
rows.push([{ text: "Use default", callbackData: "model:default" }]);
|
|
208
|
+
return rows;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private thinkingKeyboard(): InlineKeyboardButton[][] {
|
|
212
|
+
return [
|
|
213
|
+
[
|
|
214
|
+
{ text: "Off", callbackData: "thinking:set:off" },
|
|
215
|
+
{ text: "Low", callbackData: "thinking:set:low" },
|
|
216
|
+
],
|
|
217
|
+
[
|
|
218
|
+
{ text: "Medium", callbackData: "thinking:set:medium" },
|
|
219
|
+
{ text: "High", callbackData: "thinking:set:high" },
|
|
220
|
+
],
|
|
221
|
+
[{ text: "Use default", callbackData: "thinking:default" }],
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async getDefaultSession(message: InboundMessage) {
|
|
226
|
+
if (message.channel !== "telegram") {
|
|
227
|
+
return { ok: false as const, message: "Unsupported channel." };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const chat = await this.repositories.telegram.findChat(message.chatId);
|
|
231
|
+
|
|
232
|
+
if (!chat?.enabled) {
|
|
233
|
+
return { ok: false as const, message: "No enabled chat configuration found." };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const agentId = chat.defaultAgentId;
|
|
237
|
+
|
|
238
|
+
if (!agentId) {
|
|
239
|
+
return { ok: false as const, message: "No default agent configured for this chat." };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!this.registry.hasAgent(agentId)) {
|
|
243
|
+
return { ok: false as const, message: `Unknown agent: ${agentId}` };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!(await this.repositories.telegram.isAgentAllowed(message.chatId, agentId))) {
|
|
247
|
+
return { ok: false as const, message: `Agent is not allowed in this chat: ${agentId}` };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const session = await this.repositories.sessions.getOrCreate({
|
|
251
|
+
agentId,
|
|
252
|
+
channel: message.channel,
|
|
253
|
+
chatId: message.chatId,
|
|
254
|
+
threadId: message.threadId,
|
|
255
|
+
userId: message.senderId,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return { ok: true as const, agentId, session };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isThinkingLevel(value: string): value is ThinkingLevel {
|
|
263
|
+
return THINKING_LEVELS.includes(value as ThinkingLevel);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
267
|
+
const rows: T[][] = [];
|
|
268
|
+
|
|
269
|
+
for (let index = 0; index < items.length; index += size) {
|
|
270
|
+
rows.push(items.slice(index, index + size));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return rows;
|
|
65
274
|
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
TelegramAnswerCallbackQueryResponse,
|
|
3
|
+
TelegramGetMeResponse,
|
|
4
|
+
TelegramGetUpdatesResponse,
|
|
5
|
+
TelegramSendMessageResponse,
|
|
6
|
+
TelegramSetMyCommandsResponse,
|
|
7
|
+
TelegramUpdate,
|
|
8
|
+
TelegramUser,
|
|
9
|
+
} from "./types";
|
|
10
|
+
import type { InlineKeyboardButton } from "../types";
|
|
2
11
|
|
|
3
12
|
export type TelegramApiOptions = {
|
|
4
13
|
token: string;
|
|
5
14
|
baseUrl?: string;
|
|
15
|
+
maxAttempts?: number;
|
|
16
|
+
baseRetryDelayMs?: number;
|
|
6
17
|
};
|
|
7
18
|
|
|
8
19
|
export type GetUpdatesOptions = {
|
|
@@ -11,11 +22,29 @@ export type GetUpdatesOptions = {
|
|
|
11
22
|
limit?: number;
|
|
12
23
|
};
|
|
13
24
|
|
|
25
|
+
export type TelegramBotCommand = {
|
|
26
|
+
command: string;
|
|
27
|
+
description: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TelegramApiResponse<T> = T & {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
description?: string;
|
|
33
|
+
error_code?: number;
|
|
34
|
+
parameters?: {
|
|
35
|
+
retry_after?: number;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
14
39
|
export class TelegramApi {
|
|
15
40
|
private readonly baseUrl: string;
|
|
41
|
+
private readonly maxAttempts: number;
|
|
42
|
+
private readonly baseRetryDelayMs: number;
|
|
16
43
|
|
|
17
44
|
constructor(options: TelegramApiOptions) {
|
|
18
45
|
this.baseUrl = options.baseUrl ?? `https://api.telegram.org/bot${options.token}`;
|
|
46
|
+
this.maxAttempts = options.maxAttempts ?? 3;
|
|
47
|
+
this.baseRetryDelayMs = options.baseRetryDelayMs ?? 500;
|
|
19
48
|
}
|
|
20
49
|
|
|
21
50
|
async getMe(): Promise<TelegramUser> {
|
|
@@ -28,40 +57,169 @@ export class TelegramApi {
|
|
|
28
57
|
offset: options.offset,
|
|
29
58
|
timeout: options.timeoutSeconds ?? 30,
|
|
30
59
|
limit: options.limit ?? 50,
|
|
31
|
-
allowed_updates: ["message"],
|
|
60
|
+
allowed_updates: ["message", "callback_query"],
|
|
32
61
|
});
|
|
33
62
|
|
|
34
63
|
return response.result;
|
|
35
64
|
}
|
|
36
65
|
|
|
37
|
-
async sendMessage(input: {
|
|
66
|
+
async sendMessage(input: {
|
|
67
|
+
chatId: string;
|
|
68
|
+
text: string;
|
|
69
|
+
threadId?: string | null;
|
|
70
|
+
inlineKeyboard?: InlineKeyboardButton[][];
|
|
71
|
+
}): Promise<void> {
|
|
38
72
|
await this.request<TelegramSendMessageResponse>("sendMessage", {
|
|
39
73
|
chat_id: input.chatId,
|
|
40
74
|
text: input.text,
|
|
41
75
|
message_thread_id: input.threadId ? Number(input.threadId) : undefined,
|
|
42
76
|
disable_web_page_preview: true,
|
|
77
|
+
reply_markup: input.inlineKeyboard ? toTelegramInlineKeyboard(input.inlineKeyboard) : undefined,
|
|
43
78
|
});
|
|
44
79
|
}
|
|
45
80
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"content-type": "application/json",
|
|
51
|
-
},
|
|
52
|
-
body: JSON.stringify(body),
|
|
81
|
+
async answerCallbackQuery(input: { callbackQueryId: string; text?: string }): Promise<void> {
|
|
82
|
+
await this.request<TelegramAnswerCallbackQueryResponse>("answerCallbackQuery", {
|
|
83
|
+
callback_query_id: input.callbackQueryId,
|
|
84
|
+
text: input.text,
|
|
53
85
|
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async setMyCommands(commands: TelegramBotCommand[]): Promise<void> {
|
|
89
|
+
await this.request<TelegramSetMyCommandsResponse>("setMyCommands", {
|
|
90
|
+
commands,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async request<T extends { ok: boolean; description?: string }>(method: string, body: unknown): Promise<T> {
|
|
95
|
+
let lastError: unknown;
|
|
96
|
+
|
|
97
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
|
|
98
|
+
try {
|
|
99
|
+
return await this.requestOnce<T>(method, body);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
lastError = error;
|
|
102
|
+
|
|
103
|
+
if (!isRetryableTelegramError(error) || attempt >= this.maxAttempts) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await sleep(retryDelayMs(error, attempt, this.baseRetryDelayMs));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw lastError instanceof Error ? lastError : new Error(`telegram ${method} failed`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async requestOnce<T extends { ok: boolean; description?: string }>(method: string, body: unknown): Promise<T> {
|
|
115
|
+
let response: Response;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
response = await fetch(`${this.baseUrl}/${method}`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: {
|
|
121
|
+
"content-type": "application/json",
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify(body),
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new TelegramApiError(method, `telegram ${method} failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
127
|
+
cause: error,
|
|
128
|
+
retryable: true,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const payload = await parseTelegramPayload<T>(response);
|
|
54
133
|
|
|
55
134
|
if (!response.ok) {
|
|
56
|
-
throw new
|
|
135
|
+
throw new TelegramApiError(method, `telegram ${method} failed with HTTP ${response.status}`, {
|
|
136
|
+
status: response.status,
|
|
137
|
+
description: payload?.description,
|
|
138
|
+
retryAfterSeconds: payload?.parameters?.retry_after,
|
|
139
|
+
retryable: isRetryableStatus(response.status),
|
|
140
|
+
});
|
|
57
141
|
}
|
|
58
142
|
|
|
59
|
-
|
|
143
|
+
if (!payload?.ok) {
|
|
144
|
+
const status = payload?.error_code;
|
|
60
145
|
|
|
61
|
-
|
|
62
|
-
|
|
146
|
+
throw new TelegramApiError(method, `telegram ${method} failed: ${payload?.description ?? "unknown error"}`, {
|
|
147
|
+
status,
|
|
148
|
+
description: payload?.description,
|
|
149
|
+
retryAfterSeconds: payload?.parameters?.retry_after,
|
|
150
|
+
retryable: status ? isRetryableStatus(status) : false,
|
|
151
|
+
});
|
|
63
152
|
}
|
|
64
153
|
|
|
65
154
|
return payload;
|
|
66
155
|
}
|
|
67
156
|
}
|
|
157
|
+
|
|
158
|
+
class TelegramApiError extends Error {
|
|
159
|
+
readonly method: string;
|
|
160
|
+
readonly status?: number;
|
|
161
|
+
readonly description?: string;
|
|
162
|
+
readonly retryAfterSeconds?: number;
|
|
163
|
+
readonly retryable: boolean;
|
|
164
|
+
|
|
165
|
+
constructor(
|
|
166
|
+
method: string,
|
|
167
|
+
message: string,
|
|
168
|
+
options: {
|
|
169
|
+
status?: number;
|
|
170
|
+
description?: string;
|
|
171
|
+
retryAfterSeconds?: number;
|
|
172
|
+
retryable: boolean;
|
|
173
|
+
cause?: unknown;
|
|
174
|
+
},
|
|
175
|
+
) {
|
|
176
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
177
|
+
this.name = "TelegramApiError";
|
|
178
|
+
this.method = method;
|
|
179
|
+
this.status = options.status;
|
|
180
|
+
this.description = options.description;
|
|
181
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
182
|
+
this.retryable = options.retryable;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function toTelegramInlineKeyboard(inlineKeyboard: InlineKeyboardButton[][]) {
|
|
187
|
+
return {
|
|
188
|
+
inline_keyboard: inlineKeyboard.map((row) =>
|
|
189
|
+
row.map((button) => ({
|
|
190
|
+
text: button.text,
|
|
191
|
+
callback_data: button.callbackData,
|
|
192
|
+
})),
|
|
193
|
+
),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function parseTelegramPayload<T extends { ok: boolean; description?: string }>(
|
|
198
|
+
response: Response,
|
|
199
|
+
): Promise<TelegramApiResponse<T> | null> {
|
|
200
|
+
try {
|
|
201
|
+
return (await response.json()) as TelegramApiResponse<T>;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isRetryableTelegramError(error: unknown): boolean {
|
|
208
|
+
return error instanceof TelegramApiError && error.retryable;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isRetryableStatus(status: number): boolean {
|
|
212
|
+
return status === 429 || status >= 500;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function retryDelayMs(error: unknown, attempt: number, baseDelayMs: number): number {
|
|
216
|
+
if (error instanceof TelegramApiError && error.retryAfterSeconds) {
|
|
217
|
+
return error.retryAfterSeconds * 1000;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return baseDelayMs * 2 ** (attempt - 1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sleep(ms: number): Promise<void> {
|
|
224
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
225
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { RuntimeKvRepository } from "../../db/repositories/RuntimeKvRepository";
|
|
2
2
|
import { logger } from "../../logging/logger";
|
|
3
3
|
import type { ChannelAdapter, InboundMessage, MessageHandler, OutboundMessage } from "../types";
|
|
4
|
-
import { TelegramApi } from "./TelegramApi";
|
|
5
|
-
import type { TelegramMessage, TelegramUpdate } from "./types";
|
|
4
|
+
import { TelegramApi, type TelegramBotCommand } from "./TelegramApi";
|
|
5
|
+
import type { TelegramCallbackQuery, TelegramMessage, TelegramUpdate } from "./types";
|
|
6
6
|
|
|
7
7
|
export type TelegramPollingAdapterOptions = {
|
|
8
8
|
api: TelegramApi;
|
|
9
9
|
runtimeKv: RuntimeKvRepository;
|
|
10
10
|
pollTimeoutSeconds?: number;
|
|
11
11
|
pollIntervalMs?: number;
|
|
12
|
+
commands?: TelegramBotCommand[];
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
const offsetKey = "telegram:update_offset";
|
|
@@ -28,6 +29,10 @@ export class TelegramPollingAdapter implements ChannelAdapter {
|
|
|
28
29
|
const botUser = await this.options.api.getMe();
|
|
29
30
|
this.botUserId = String(botUser.id);
|
|
30
31
|
|
|
32
|
+
if (this.options.commands?.length) {
|
|
33
|
+
await this.options.api.setMyCommands(this.options.commands);
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
this.running = true;
|
|
32
37
|
this.loopPromise = this.poll(handler);
|
|
33
38
|
logger.info("telegram polling started", {
|
|
@@ -54,6 +59,7 @@ export class TelegramPollingAdapter implements ChannelAdapter {
|
|
|
54
59
|
chatId: message.chatId,
|
|
55
60
|
threadId: message.threadId,
|
|
56
61
|
text: message.text,
|
|
62
|
+
inlineKeyboard: message.inlineKeyboard,
|
|
57
63
|
});
|
|
58
64
|
}
|
|
59
65
|
|
|
@@ -80,6 +86,11 @@ export class TelegramPollingAdapter implements ChannelAdapter {
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
private async handleUpdate(update: TelegramUpdate, handler: MessageHandler): Promise<void> {
|
|
89
|
+
if (update.callback_query) {
|
|
90
|
+
await this.handleCallbackQuery(update.update_id, update.callback_query, handler);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
const message = update.message;
|
|
84
95
|
if (!message?.text) return;
|
|
85
96
|
if (message.from?.is_bot) return;
|
|
@@ -89,6 +100,21 @@ export class TelegramPollingAdapter implements ChannelAdapter {
|
|
|
89
100
|
await handler(normalized);
|
|
90
101
|
}
|
|
91
102
|
|
|
103
|
+
private async handleCallbackQuery(
|
|
104
|
+
updateId: number,
|
|
105
|
+
callbackQuery: TelegramCallbackQuery,
|
|
106
|
+
handler: MessageHandler,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
await this.options.api.answerCallbackQuery({ callbackQueryId: callbackQuery.id });
|
|
109
|
+
|
|
110
|
+
if (!callbackQuery.data || !callbackQuery.message) return;
|
|
111
|
+
if (callbackQuery.from.is_bot) return;
|
|
112
|
+
if (this.botUserId && String(callbackQuery.from.id) === this.botUserId) return;
|
|
113
|
+
|
|
114
|
+
const normalized = normalizeTelegramCallbackQuery(updateId, callbackQuery);
|
|
115
|
+
await handler(normalized);
|
|
116
|
+
}
|
|
117
|
+
|
|
92
118
|
private async loadOffset(): Promise<number | undefined> {
|
|
93
119
|
const value = await this.options.runtimeKv.get(offsetKey);
|
|
94
120
|
if (!value) return undefined;
|
|
@@ -118,6 +144,23 @@ function normalizeTelegramMessage(updateId: number, message: TelegramMessage): I
|
|
|
118
144
|
};
|
|
119
145
|
}
|
|
120
146
|
|
|
147
|
+
function normalizeTelegramCallbackQuery(updateId: number, callbackQuery: TelegramCallbackQuery): InboundMessage {
|
|
148
|
+
const message = callbackQuery.message;
|
|
149
|
+
const senderName = [callbackQuery.from.first_name, callbackQuery.from.last_name].filter(Boolean).join(" ");
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
id: String(updateId),
|
|
153
|
+
channel: "telegram",
|
|
154
|
+
chatId: String(message?.chat.id),
|
|
155
|
+
threadId: message?.message_thread_id ? String(message.message_thread_id) : null,
|
|
156
|
+
senderId: String(callbackQuery.from.id),
|
|
157
|
+
senderName: senderName || callbackQuery.from.username || null,
|
|
158
|
+
text: `/${callbackQuery.data}`,
|
|
159
|
+
raw: callbackQuery,
|
|
160
|
+
receivedAt: new Date(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
121
164
|
function sleep(ms: number): Promise<void> {
|
|
122
165
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
123
166
|
}
|
|
@@ -25,10 +25,18 @@ export type TelegramMessage = {
|
|
|
25
25
|
text?: string;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
export type TelegramCallbackQuery = {
|
|
29
|
+
id: string;
|
|
30
|
+
from: TelegramUser;
|
|
31
|
+
message?: TelegramMessage;
|
|
32
|
+
data?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
28
35
|
export type TelegramUpdate = {
|
|
29
36
|
update_id: number;
|
|
30
37
|
message?: TelegramMessage;
|
|
31
38
|
edited_message?: TelegramMessage;
|
|
39
|
+
callback_query?: TelegramCallbackQuery;
|
|
32
40
|
};
|
|
33
41
|
|
|
34
42
|
export type TelegramGetUpdatesResponse = {
|
|
@@ -48,3 +56,15 @@ export type TelegramSendMessageResponse = {
|
|
|
48
56
|
result?: TelegramMessage;
|
|
49
57
|
description?: string;
|
|
50
58
|
};
|
|
59
|
+
|
|
60
|
+
export type TelegramAnswerCallbackQueryResponse = {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
result: boolean;
|
|
63
|
+
description?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type TelegramSetMyCommandsResponse = {
|
|
67
|
+
ok: boolean;
|
|
68
|
+
result: boolean;
|
|
69
|
+
description?: string;
|
|
70
|
+
};
|
package/src/channels/types.ts
CHANGED
|
@@ -12,11 +12,17 @@ export type InboundMessage = {
|
|
|
12
12
|
receivedAt: Date;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
export type InlineKeyboardButton = {
|
|
16
|
+
text: string;
|
|
17
|
+
callbackData: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
15
20
|
export type OutboundMessage = {
|
|
16
21
|
channel: ChannelId;
|
|
17
22
|
chatId: string;
|
|
18
23
|
threadId?: string | null;
|
|
19
24
|
text: string;
|
|
25
|
+
inlineKeyboard?: InlineKeyboardButton[][];
|
|
20
26
|
};
|
|
21
27
|
|
|
22
28
|
export type MessageHandler = (message: InboundMessage) => Promise<void>;
|
package/src/config/loadConfig.ts
CHANGED
package/src/config/schemas.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
const relativeOrAbsolutePathSchema = z.string().min(1);
|
|
4
|
+
const modelReferenceSchema = z.string().min(1).regex(/^[^/\s]+\/.+$/);
|
|
5
|
+
const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high"]);
|
|
4
6
|
|
|
5
7
|
export const PermissionConfigSchema = z.object({
|
|
6
8
|
canRunShell: z.boolean().default(false),
|
|
@@ -28,6 +30,8 @@ export const AgentConfigSchema = z.object({
|
|
|
28
30
|
name: z.string().min(1),
|
|
29
31
|
profile: z.string().min(1),
|
|
30
32
|
workspace: relativeOrAbsolutePathSchema,
|
|
33
|
+
model: modelReferenceSchema.nullable().default(null),
|
|
34
|
+
thinkingLevel: ThinkingLevelSchema.nullable().default(null),
|
|
31
35
|
systemPromptFile: relativeOrAbsolutePathSchema.optional(),
|
|
32
36
|
skills: z.array(z.string()).default([]),
|
|
33
37
|
extensions: z.array(z.string()).default([]),
|
|
@@ -48,7 +52,8 @@ export const AgentConfigSchema = z.object({
|
|
|
48
52
|
export const ProfileConfigSchema = z.object({
|
|
49
53
|
id: z.string().min(1).regex(/^[a-zA-Z0-9_-]+$/),
|
|
50
54
|
name: z.string().min(1),
|
|
51
|
-
model:
|
|
55
|
+
model: modelReferenceSchema.nullable().default(null),
|
|
56
|
+
thinkingLevel: ThinkingLevelSchema.nullable().default(null),
|
|
52
57
|
instructions: z.string().default(""),
|
|
53
58
|
defaultSkills: z.array(z.string()).default([]),
|
|
54
59
|
defaultExtensions: z.array(z.string()).default([]),
|
|
@@ -60,6 +65,11 @@ export const ProfileConfigSchema = z.object({
|
|
|
60
65
|
}),
|
|
61
66
|
});
|
|
62
67
|
|
|
68
|
+
export const ModelChoiceConfigSchema = z.object({
|
|
69
|
+
id: modelReferenceSchema,
|
|
70
|
+
label: z.string().min(1),
|
|
71
|
+
});
|
|
72
|
+
|
|
63
73
|
export const TelegramChatConfigSchema = z.object({
|
|
64
74
|
chatId: z.string().min(1),
|
|
65
75
|
title: z.string().optional(),
|
|
@@ -71,6 +81,7 @@ export const TelegramChatConfigSchema = z.object({
|
|
|
71
81
|
|
|
72
82
|
export const RootConfigSchema = z.object({
|
|
73
83
|
telegramChats: z.array(TelegramChatConfigSchema).default([]),
|
|
84
|
+
modelChoices: z.array(ModelChoiceConfigSchema).default([]),
|
|
74
85
|
});
|
|
75
86
|
|
|
76
87
|
export type PermissionConfig = z.infer<typeof PermissionConfigSchema>;
|
|
@@ -78,6 +89,7 @@ export type HeartbeatConfig = z.infer<typeof HeartbeatConfigSchema>;
|
|
|
78
89
|
export type AgentConfig = z.infer<typeof AgentConfigSchema>;
|
|
79
90
|
export type ProfileConfig = z.infer<typeof ProfileConfigSchema>;
|
|
80
91
|
export type TelegramChatConfig = z.infer<typeof TelegramChatConfigSchema>;
|
|
92
|
+
export type ModelChoiceConfig = z.infer<typeof ModelChoiceConfigSchema>;
|
|
81
93
|
export type RootConfig = z.infer<typeof RootConfigSchema>;
|
|
82
94
|
|
|
83
95
|
export type LoadedAgentConfig = AgentConfig & {
|
|
@@ -89,4 +101,5 @@ export type LoadedConfig = {
|
|
|
89
101
|
agents: LoadedAgentConfig[];
|
|
90
102
|
profiles: ProfileConfig[];
|
|
91
103
|
telegramChats: TelegramChatConfig[];
|
|
104
|
+
modelChoices: ModelChoiceConfig[];
|
|
92
105
|
};
|