@fickydev/pigent 0.1.7 → 0.1.9
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 +3 -0
- package/PLAN.md +21 -1
- package/TODO.md +13 -0
- package/package.json +1 -1
- package/pigent.yaml +10 -0
- package/src/agents/BotCommandHandler.ts +134 -27
- package/src/channels/telegram/TelegramApi.ts +47 -3
- 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 +8 -0
- package/src/daemon/AgentDaemon.ts +13 -1
- package/src/pi/PiAvailableModels.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
- Planned model selection support across profile, agent, and Telegram chat override levels.
|
|
44
44
|
- Added profile and agent config-based Pi model and thinking level selection.
|
|
45
45
|
- Added session-scoped model and thinking level overrides with `/model` and `/thinking` Telegram commands.
|
|
46
|
+
- Planned Telegram inline-button model and thinking level pickers backed by configured model choices.
|
|
47
|
+
- Added Telegram inline-button pickers for `/model` and `/thinking`, callback handling, and bot command menu registration.
|
|
48
|
+
- Changed `/model` picker to use Pi's currently available models automatically when explicit `modelChoices` are not configured.
|
|
46
49
|
- Kept daemon process alive after startup so CLI runs do not exit after `pigent ready`.
|
|
47
50
|
|
|
48
51
|
### Changed
|
package/PLAN.md
CHANGED
|
@@ -206,7 +206,27 @@ Telegram command support should persist session-scoped overrides after config su
|
|
|
206
206
|
- `/thinking <level>` sets thinking level for the default agent session in the current chat/thread
|
|
207
207
|
- `/thinking default` clears thinking override for the default agent session in the current chat/thread
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
Telegram should also provide inline-button pickers so non-technical users can choose from configured choices:
|
|
210
|
+
|
|
211
|
+
- `/model` can reply with inline keyboard buttons for configured model choices plus `Use default`
|
|
212
|
+
- `/thinking` can reply with inline keyboard buttons for `Off`, `Low`, `Medium`, `High`, and `Use default`
|
|
213
|
+
- callback payloads should be short and action-scoped, such as `model:set:<choiceId>`, `model:default`, `thinking:set:medium`, and `thinking:default`
|
|
214
|
+
- after selection, bot should answer the callback and update or send a confirmation message
|
|
215
|
+
- daemon startup should register Telegram bot commands through `setMyCommands` so users see Pigent commands in the Telegram command menu
|
|
216
|
+
|
|
217
|
+
Model choices default to Pi's currently available models from `ModelRegistry.getAvailable()`. Operators may optionally configure explicit choices to curate or rename the button list:
|
|
218
|
+
|
|
219
|
+
```yaml
|
|
220
|
+
modelChoices:
|
|
221
|
+
- id: anthropic/claude-sonnet-4-5
|
|
222
|
+
label: Claude Sonnet
|
|
223
|
+
- id: anthropic/claude-opus-4-5
|
|
224
|
+
label: Claude Opus
|
|
225
|
+
- id: openai/gpt-4.1
|
|
226
|
+
label: GPT-4.1
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The command and callback handlers should validate agent access and model availability through a Pi model resolver. They must not reveal API keys, auth values, raw environment variables, or provider secrets. The available-model fallback should use Pi auth presence checks only and never print secret values.
|
|
210
230
|
|
|
211
231
|
## Prompt Composition
|
|
212
232
|
|
package/TODO.md
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
## Config
|
|
41
41
|
|
|
42
|
+
- [x] Add root `modelChoices` config for Telegram model picker buttons
|
|
42
43
|
- [x] Add profile-level `thinkingLevel` config
|
|
43
44
|
- [x] Add agent-level `model` override config
|
|
44
45
|
- [x] Add agent-level `thinkingLevel` override config
|
|
@@ -84,11 +85,15 @@
|
|
|
84
85
|
- [x] Define `OutboundMessage`
|
|
85
86
|
- [x] Define `ChannelAdapter`
|
|
86
87
|
- [x] Define Telegram normalized types
|
|
88
|
+
- [x] Normalize Telegram callback query updates
|
|
87
89
|
- [ ] Implement `TelegramApi`
|
|
88
90
|
- [x] `getUpdates`
|
|
89
91
|
- [x] `sendMessage`
|
|
90
92
|
- [x] error handling
|
|
91
93
|
- [x] retry/backoff
|
|
94
|
+
- [x] `answerCallbackQuery`
|
|
95
|
+
- [x] inline keyboard `reply_markup` support
|
|
96
|
+
- [x] `setMyCommands` Telegram command menu registration
|
|
92
97
|
- [x] Implement `TelegramPollingAdapter`
|
|
93
98
|
- [x] polling loop
|
|
94
99
|
- [x] offset tracking
|
|
@@ -179,6 +184,12 @@
|
|
|
179
184
|
- [x] `/thinking` show thinking level for default chat agent session
|
|
180
185
|
- [x] `/thinking <level>` set thinking level for default chat agent session
|
|
181
186
|
- [x] `/thinking default` clear thinking level for default chat agent session
|
|
187
|
+
- [x] `/model` inline button picker for currently available Pi models
|
|
188
|
+
- [x] `/thinking` inline button picker
|
|
189
|
+
- [x] Handle model picker callback and persist selected session model
|
|
190
|
+
- [x] Fallback to Pi `ModelRegistry.getAvailable()` when `modelChoices` is empty
|
|
191
|
+
- [x] Handle thinking picker callback and persist selected session thinking level
|
|
192
|
+
- [x] Add `Use default` model/thinking buttons
|
|
182
193
|
- [ ] `/model <agentId> <provider/modelId>` set model for an explicit agent session
|
|
183
194
|
- [ ] `/thinking <agentId> <level>` set thinking level for an explicit agent session
|
|
184
195
|
- [x] `/help` show bot commands
|
|
@@ -192,6 +203,8 @@
|
|
|
192
203
|
- [ ] Unit test heartbeat `NOOP` behavior
|
|
193
204
|
- [ ] Unit test model selection priority
|
|
194
205
|
- [ ] Unit test Telegram `/model` command parsing
|
|
206
|
+
- [ ] Unit test Telegram model picker callback parsing
|
|
207
|
+
- [ ] Unit test Telegram thinking picker callback parsing
|
|
195
208
|
- [ ] Repository tests against temp SQLite DB
|
|
196
209
|
- [ ] Integration test fake Telegram update to fake Pi runner
|
|
197
210
|
|
package/package.json
CHANGED
package/pigent.yaml
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
telegramChats: []
|
|
2
|
+
modelChoices: []
|
|
2
3
|
|
|
4
|
+
# Optional. If empty, /model uses Pi's currently available models automatically.
|
|
5
|
+
# Add choices to curate or rename buttons for /model.
|
|
6
|
+
#
|
|
7
|
+
# modelChoices:
|
|
8
|
+
# - id: anthropic/claude-sonnet-4-5
|
|
9
|
+
# label: Claude Sonnet
|
|
10
|
+
# - id: anthropic/claude-opus-4-5
|
|
11
|
+
# label: Claude Opus
|
|
12
|
+
#
|
|
3
13
|
# Replace chatId with a real Telegram chat id to enable routing.
|
|
4
14
|
#
|
|
5
15
|
# telegramChats:
|
|
@@ -1,5 +1,7 @@
|
|
|
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 { getAvailableModelChoices } from "../pi/PiAvailableModels";
|
|
3
5
|
import { isValidModelRef } from "../pi/PiModelResolver";
|
|
4
6
|
import type { AgentRegistry } from "./AgentRegistry";
|
|
5
7
|
|
|
@@ -7,6 +9,7 @@ export type BotCommandResult =
|
|
|
7
9
|
| {
|
|
8
10
|
handled: true;
|
|
9
11
|
text: string;
|
|
12
|
+
inlineKeyboard?: InlineKeyboardButton[][];
|
|
10
13
|
}
|
|
11
14
|
| {
|
|
12
15
|
handled: false;
|
|
@@ -19,6 +22,7 @@ export class BotCommandHandler {
|
|
|
19
22
|
constructor(
|
|
20
23
|
private readonly registry: AgentRegistry,
|
|
21
24
|
private readonly repositories: Repositories,
|
|
25
|
+
private readonly modelChoices: ModelChoiceConfig[] = [],
|
|
22
26
|
) {}
|
|
23
27
|
|
|
24
28
|
async handle(message: InboundMessage): Promise<BotCommandResult> {
|
|
@@ -31,9 +35,17 @@ export class BotCommandHandler {
|
|
|
31
35
|
case "/agents":
|
|
32
36
|
return { handled: true, text: await this.agentsText(message) };
|
|
33
37
|
case "/model":
|
|
34
|
-
return
|
|
38
|
+
return await this.modelResult(message);
|
|
35
39
|
case "/thinking":
|
|
36
|
-
return
|
|
40
|
+
return await this.thinkingResult(message);
|
|
41
|
+
case "/model:set":
|
|
42
|
+
return { handled: true, text: await this.setModelFromChoice(message) };
|
|
43
|
+
case "/model:default":
|
|
44
|
+
return { handled: true, text: await this.clearModel(message) };
|
|
45
|
+
case "/thinking:set":
|
|
46
|
+
return { handled: true, text: await this.setThinkingFromCallback(message) };
|
|
47
|
+
case "/thinking:default":
|
|
48
|
+
return { handled: true, text: await this.clearThinking(message) };
|
|
37
49
|
default:
|
|
38
50
|
return { handled: false };
|
|
39
51
|
}
|
|
@@ -44,10 +56,10 @@ export class BotCommandHandler {
|
|
|
44
56
|
"Pigent commands:",
|
|
45
57
|
"/help - show this help",
|
|
46
58
|
"/agents - list agents for this chat",
|
|
47
|
-
"/model - show model for this chat session",
|
|
59
|
+
"/model - show model picker for this chat session",
|
|
48
60
|
"/model <provider/modelId> - set model for this chat session",
|
|
49
61
|
"/model default - clear model override for this chat session",
|
|
50
|
-
"/thinking - show thinking
|
|
62
|
+
"/thinking - show thinking picker for this chat session",
|
|
51
63
|
"/thinking <off|low|medium|high> - set thinking level for this chat session",
|
|
52
64
|
"/thinking default - clear thinking override for this chat session",
|
|
53
65
|
"/agent <agentId> <message> - send message to specific agent",
|
|
@@ -77,56 +89,100 @@ export class BotCommandHandler {
|
|
|
77
89
|
].join("\n");
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
private async
|
|
92
|
+
private async modelResult(message: InboundMessage): Promise<BotCommandResult> {
|
|
81
93
|
const sessionResult = await this.getDefaultSession(message);
|
|
82
|
-
if (!sessionResult.ok) return sessionResult.message;
|
|
94
|
+
if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
|
|
83
95
|
|
|
84
96
|
const [, ...args] = message.text.trim().split(/\s+/);
|
|
85
97
|
const value = args.join(" ").trim();
|
|
86
98
|
|
|
87
99
|
if (!value) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
const choices = await this.loadModelChoices();
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
handled: true,
|
|
104
|
+
text: [
|
|
105
|
+
`Agent: ${sessionResult.agentId}`,
|
|
106
|
+
`Session model: ${sessionResult.session.model ?? "default"}`,
|
|
107
|
+
choices.length > 0
|
|
108
|
+
? "Choose a model:"
|
|
109
|
+
: "No available Pi models found. Configure provider auth or use /model <provider/modelId> manually.",
|
|
110
|
+
].join("\n"),
|
|
111
|
+
inlineKeyboard: choices.length > 0 ? this.modelKeyboard(choices) : undefined,
|
|
112
|
+
};
|
|
94
113
|
}
|
|
95
114
|
|
|
96
115
|
if (value === "default") {
|
|
97
|
-
|
|
98
|
-
return `Session model cleared for ${session.agentId}. Current: ${session.model ?? "default"}`;
|
|
116
|
+
return { handled: true, text: await this.clearModel(message) };
|
|
99
117
|
}
|
|
100
118
|
|
|
101
119
|
if (!isValidModelRef(value)) {
|
|
102
|
-
return "Invalid model. Use provider/modelId, for example: anthropic/claude-opus-4-5";
|
|
120
|
+
return { handled: true, text: "Invalid model. Use provider/modelId, for example: anthropic/claude-opus-4-5" };
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, value);
|
|
106
|
-
return `Session model for ${session.agentId} set to ${session.model}
|
|
124
|
+
return { handled: true, text: `Session model for ${session.agentId} set to ${session.model}.` };
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
private async
|
|
127
|
+
private async thinkingResult(message: InboundMessage): Promise<BotCommandResult> {
|
|
110
128
|
const sessionResult = await this.getDefaultSession(message);
|
|
111
|
-
if (!sessionResult.ok) return sessionResult.message;
|
|
129
|
+
if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
|
|
112
130
|
|
|
113
131
|
const [, rawValue] = message.text.trim().split(/\s+/, 2);
|
|
114
132
|
const value = rawValue?.trim();
|
|
115
133
|
|
|
116
134
|
if (!value) {
|
|
117
|
-
return
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
return {
|
|
136
|
+
handled: true,
|
|
137
|
+
text: [
|
|
138
|
+
`Agent: ${sessionResult.agentId}`,
|
|
139
|
+
`Session thinking level: ${sessionResult.session.thinkingLevel ?? "default"}`,
|
|
140
|
+
"Choose thinking level:",
|
|
141
|
+
].join("\n"),
|
|
142
|
+
inlineKeyboard: this.thinkingKeyboard(),
|
|
143
|
+
};
|
|
123
144
|
}
|
|
124
145
|
|
|
125
146
|
if (value === "default") {
|
|
126
|
-
|
|
127
|
-
return `Session thinking level cleared for ${session.agentId}. Current: ${session.thinkingLevel ?? "default"}`;
|
|
147
|
+
return { handled: true, text: await this.clearThinking(message) };
|
|
128
148
|
}
|
|
129
149
|
|
|
150
|
+
if (!isThinkingLevel(value)) {
|
|
151
|
+
return { handled: true, text: "Invalid thinking level. Use one of: off, low, medium, high, default" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
|
|
155
|
+
return { handled: true, text: `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async setModelFromChoice(message: InboundMessage): Promise<string> {
|
|
159
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
160
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
161
|
+
|
|
162
|
+
const choices = await this.loadModelChoices();
|
|
163
|
+
const choiceIndex = Number(message.text.slice("/model:set:".length));
|
|
164
|
+
if (!Number.isSafeInteger(choiceIndex) || choiceIndex < 0 || choiceIndex >= choices.length) {
|
|
165
|
+
return "Unknown model choice.";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const choice = choices[choiceIndex];
|
|
169
|
+
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, choice.id);
|
|
170
|
+
return `Session model for ${session.agentId} set to ${choice.label} (${session.model}).`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async clearModel(message: InboundMessage): Promise<string> {
|
|
174
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
175
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
176
|
+
|
|
177
|
+
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, null);
|
|
178
|
+
return `Session model cleared for ${session.agentId}. Current: ${session.model ?? "default"}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async setThinkingFromCallback(message: InboundMessage): Promise<string> {
|
|
182
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
183
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
184
|
+
|
|
185
|
+
const value = message.text.slice("/thinking:set:".length);
|
|
130
186
|
if (!isThinkingLevel(value)) {
|
|
131
187
|
return "Invalid thinking level. Use one of: off, low, medium, high, default";
|
|
132
188
|
}
|
|
@@ -135,6 +191,47 @@ export class BotCommandHandler {
|
|
|
135
191
|
return `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.`;
|
|
136
192
|
}
|
|
137
193
|
|
|
194
|
+
private async clearThinking(message: InboundMessage): Promise<string> {
|
|
195
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
196
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
197
|
+
|
|
198
|
+
const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, null);
|
|
199
|
+
return `Session thinking level cleared for ${session.agentId}. Current: ${session.thinkingLevel ?? "default"}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async loadModelChoices(): Promise<ModelChoiceConfig[]> {
|
|
203
|
+
if (this.modelChoices.length > 0) return this.modelChoices;
|
|
204
|
+
|
|
205
|
+
return getAvailableModelChoices();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private modelKeyboard(choices: ModelChoiceConfig[]): InlineKeyboardButton[][] {
|
|
209
|
+
const rows = chunk(
|
|
210
|
+
choices.map((choice, index) => ({
|
|
211
|
+
text: choice.label,
|
|
212
|
+
callbackData: `model:set:${index}`,
|
|
213
|
+
})),
|
|
214
|
+
1,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
rows.push([{ text: "Use default", callbackData: "model:default" }]);
|
|
218
|
+
return rows;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private thinkingKeyboard(): InlineKeyboardButton[][] {
|
|
222
|
+
return [
|
|
223
|
+
[
|
|
224
|
+
{ text: "Off", callbackData: "thinking:set:off" },
|
|
225
|
+
{ text: "Low", callbackData: "thinking:set:low" },
|
|
226
|
+
],
|
|
227
|
+
[
|
|
228
|
+
{ text: "Medium", callbackData: "thinking:set:medium" },
|
|
229
|
+
{ text: "High", callbackData: "thinking:set:high" },
|
|
230
|
+
],
|
|
231
|
+
[{ text: "Use default", callbackData: "thinking:default" }],
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
|
|
138
235
|
private async getDefaultSession(message: InboundMessage) {
|
|
139
236
|
if (message.channel !== "telegram") {
|
|
140
237
|
return { ok: false as const, message: "Unsupported channel." };
|
|
@@ -175,3 +272,13 @@ export class BotCommandHandler {
|
|
|
175
272
|
function isThinkingLevel(value: string): value is ThinkingLevel {
|
|
176
273
|
return THINKING_LEVELS.includes(value as ThinkingLevel);
|
|
177
274
|
}
|
|
275
|
+
|
|
276
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
277
|
+
const rows: T[][] = [];
|
|
278
|
+
|
|
279
|
+
for (let index = 0; index < items.length; index += size) {
|
|
280
|
+
rows.push(items.slice(index, index + size));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return rows;
|
|
284
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
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;
|
|
@@ -13,6 +22,11 @@ export type GetUpdatesOptions = {
|
|
|
13
22
|
limit?: number;
|
|
14
23
|
};
|
|
15
24
|
|
|
25
|
+
export type TelegramBotCommand = {
|
|
26
|
+
command: string;
|
|
27
|
+
description: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
16
30
|
type TelegramApiResponse<T> = T & {
|
|
17
31
|
ok: boolean;
|
|
18
32
|
description?: string;
|
|
@@ -43,18 +57,37 @@ export class TelegramApi {
|
|
|
43
57
|
offset: options.offset,
|
|
44
58
|
timeout: options.timeoutSeconds ?? 30,
|
|
45
59
|
limit: options.limit ?? 50,
|
|
46
|
-
allowed_updates: ["message"],
|
|
60
|
+
allowed_updates: ["message", "callback_query"],
|
|
47
61
|
});
|
|
48
62
|
|
|
49
63
|
return response.result;
|
|
50
64
|
}
|
|
51
65
|
|
|
52
|
-
async sendMessage(input: {
|
|
66
|
+
async sendMessage(input: {
|
|
67
|
+
chatId: string;
|
|
68
|
+
text: string;
|
|
69
|
+
threadId?: string | null;
|
|
70
|
+
inlineKeyboard?: InlineKeyboardButton[][];
|
|
71
|
+
}): Promise<void> {
|
|
53
72
|
await this.request<TelegramSendMessageResponse>("sendMessage", {
|
|
54
73
|
chat_id: input.chatId,
|
|
55
74
|
text: input.text,
|
|
56
75
|
message_thread_id: input.threadId ? Number(input.threadId) : undefined,
|
|
57
76
|
disable_web_page_preview: true,
|
|
77
|
+
reply_markup: input.inlineKeyboard ? toTelegramInlineKeyboard(input.inlineKeyboard) : undefined,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
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,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async setMyCommands(commands: TelegramBotCommand[]): Promise<void> {
|
|
89
|
+
await this.request<TelegramSetMyCommandsResponse>("setMyCommands", {
|
|
90
|
+
commands,
|
|
58
91
|
});
|
|
59
92
|
}
|
|
60
93
|
|
|
@@ -150,6 +183,17 @@ class TelegramApiError extends Error {
|
|
|
150
183
|
}
|
|
151
184
|
}
|
|
152
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
|
+
|
|
153
197
|
async function parseTelegramPayload<T extends { ok: boolean; description?: string }>(
|
|
154
198
|
response: Response,
|
|
155
199
|
): Promise<TelegramApiResponse<T> | null> {
|
|
@@ -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
|
@@ -65,6 +65,11 @@ export const ProfileConfigSchema = z.object({
|
|
|
65
65
|
}),
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
export const ModelChoiceConfigSchema = z.object({
|
|
69
|
+
id: modelReferenceSchema,
|
|
70
|
+
label: z.string().min(1),
|
|
71
|
+
});
|
|
72
|
+
|
|
68
73
|
export const TelegramChatConfigSchema = z.object({
|
|
69
74
|
chatId: z.string().min(1),
|
|
70
75
|
title: z.string().optional(),
|
|
@@ -76,6 +81,7 @@ export const TelegramChatConfigSchema = z.object({
|
|
|
76
81
|
|
|
77
82
|
export const RootConfigSchema = z.object({
|
|
78
83
|
telegramChats: z.array(TelegramChatConfigSchema).default([]),
|
|
84
|
+
modelChoices: z.array(ModelChoiceConfigSchema).default([]),
|
|
79
85
|
});
|
|
80
86
|
|
|
81
87
|
export type PermissionConfig = z.infer<typeof PermissionConfigSchema>;
|
|
@@ -83,6 +89,7 @@ export type HeartbeatConfig = z.infer<typeof HeartbeatConfigSchema>;
|
|
|
83
89
|
export type AgentConfig = z.infer<typeof AgentConfigSchema>;
|
|
84
90
|
export type ProfileConfig = z.infer<typeof ProfileConfigSchema>;
|
|
85
91
|
export type TelegramChatConfig = z.infer<typeof TelegramChatConfigSchema>;
|
|
92
|
+
export type ModelChoiceConfig = z.infer<typeof ModelChoiceConfigSchema>;
|
|
86
93
|
export type RootConfig = z.infer<typeof RootConfigSchema>;
|
|
87
94
|
|
|
88
95
|
export type LoadedAgentConfig = AgentConfig & {
|
|
@@ -94,4 +101,5 @@ export type LoadedConfig = {
|
|
|
94
101
|
agents: LoadedAgentConfig[];
|
|
95
102
|
profiles: ProfileConfig[];
|
|
96
103
|
telegramChats: TelegramChatConfig[];
|
|
104
|
+
modelChoices: ModelChoiceConfig[];
|
|
97
105
|
};
|
|
@@ -24,7 +24,7 @@ export class AgentDaemon {
|
|
|
24
24
|
) {
|
|
25
25
|
this.adapters = createAdapters(repositories);
|
|
26
26
|
this.registry = new AgentRegistry(config, repositories);
|
|
27
|
-
this.commands = new BotCommandHandler(this.registry, repositories);
|
|
27
|
+
this.commands = new BotCommandHandler(this.registry, repositories, config.modelChoices);
|
|
28
28
|
this.router = new MessageRouter(repositories, this.registry);
|
|
29
29
|
this.runner = new AgentRunner(this.registry, repositories);
|
|
30
30
|
}
|
|
@@ -92,6 +92,7 @@ export class AgentDaemon {
|
|
|
92
92
|
chatId: message.chatId,
|
|
93
93
|
threadId: message.threadId,
|
|
94
94
|
text: command.text,
|
|
95
|
+
inlineKeyboard: command.inlineKeyboard,
|
|
95
96
|
});
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
@@ -158,9 +159,20 @@ function createAdapters(repositories: Repositories): ChannelAdapter[] {
|
|
|
158
159
|
runtimeKv: repositories.runtimeKv,
|
|
159
160
|
pollTimeoutSeconds: Number(process.env.TELEGRAM_POLL_TIMEOUT_SECONDS ?? 30),
|
|
160
161
|
pollIntervalMs: Number(process.env.TELEGRAM_POLL_INTERVAL_MS ?? 1000),
|
|
162
|
+
commands: telegramCommands(),
|
|
161
163
|
}),
|
|
162
164
|
);
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
return adapters;
|
|
166
168
|
}
|
|
169
|
+
|
|
170
|
+
function telegramCommands() {
|
|
171
|
+
return [
|
|
172
|
+
{ command: "help", description: "Show Pigent commands" },
|
|
173
|
+
{ command: "agents", description: "List agents available in this chat" },
|
|
174
|
+
{ command: "model", description: "Choose model for this chat session" },
|
|
175
|
+
{ command: "thinking", description: "Choose thinking level for this chat session" },
|
|
176
|
+
{ command: "agent", description: "Send message to a specific agent" },
|
|
177
|
+
];
|
|
178
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
export type AvailableModelChoice = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function getAvailableModelChoices(): Promise<AvailableModelChoice[]> {
|
|
10
|
+
const authStorage = AuthStorage.create();
|
|
11
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
12
|
+
const models = await modelRegistry.getAvailable();
|
|
13
|
+
|
|
14
|
+
return models.map(toChoice).sort((a, b) => a.label.localeCompare(b.label));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toChoice(model: Model<any>): AvailableModelChoice {
|
|
18
|
+
const modelWithName = model as Model<any> & { name?: string };
|
|
19
|
+
const id = `${model.provider}/${model.id}`;
|
|
20
|
+
const label = modelWithName.name ? `${modelWithName.name} (${id})` : id;
|
|
21
|
+
|
|
22
|
+
return { id, label };
|
|
23
|
+
}
|