wolli 0.0.3 → 0.0.5
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/README.md +23 -24
- package/built-in/plugins/discord/discord-chat.ts +47 -84
- package/built-in/plugins/discord/index.ts +139 -97
- package/built-in/plugins/discord/package.json +2 -2
- package/built-in/plugins/github/README.md +148 -0
- package/built-in/plugins/github/github-api.test.ts +260 -0
- package/built-in/plugins/github/github-api.ts +491 -0
- package/built-in/plugins/github/github-chat.ts +216 -0
- package/built-in/plugins/github/github-review.ts +102 -0
- package/built-in/plugins/github/github-workspace.ts +84 -0
- package/built-in/plugins/github/index.ts +795 -0
- package/built-in/plugins/github/package.json +14 -0
- package/built-in/plugins/scheduler/cron.ts +131 -0
- package/built-in/plugins/scheduler/index.ts +147 -151
- package/built-in/plugins/scheduler/package.json +3 -2
- package/built-in/plugins/scheduler/scheduler-due.ts +22 -0
- package/built-in/plugins/telegram/README.md +3 -3
- package/built-in/plugins/telegram/index.ts +177 -139
- package/built-in/plugins/telegram/package.json +2 -2
- package/built-in/plugins/telegram/telegram-chat.ts +79 -155
- package/dist/cli.js +6208 -6355
- package/docs/hooks.md +103 -0
- package/docs/index.md +10 -8
- package/docs/integrations.md +78 -663
- package/docs/introduction.md +95 -0
- package/docs/plugins.md +43 -36
- package/docs/providers.md +63 -0
- package/docs/sdk.md +42 -47
- package/docs/tools.md +81 -0
- package/docs/workflows.md +170 -0
- package/package.json +1 -1
- package/built-in/plugins/scheduler/scheduler-chat.ts +0 -164
- package/docs/extensions.md +0 -2331
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This integration faces the network, holds the bot token, and emits a `message`
|
|
5
5
|
* event per inbound Telegram message. It does not touch sessions or the agent; the
|
|
6
|
-
*
|
|
7
|
-
* events
|
|
8
|
-
*
|
|
6
|
+
* routing workflows (`telegram-chat.ts`, declared under `wolli.workflows`) map those
|
|
7
|
+
* events onto sessions and are resolved in place by the package manager. In-memory transport state that outlives a single call —
|
|
8
|
+
* the "typing…" keep-alive timers and the BotFather command menu — lives here, since
|
|
9
|
+
* workflow files hold no module state.
|
|
9
10
|
*
|
|
10
11
|
* Transport is grammY long polling (`@grammyjs/runner`'s `run`), so no public URL
|
|
11
12
|
* or TLS is needed. The package brings its OWN grammy + @grammyjs/runner deps.
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
* Onboarding asks for the BotFather token directly, verifies it with a live `getMe()`,
|
|
19
20
|
* and stores the raw token in `~/.wolli/agents/<name>/integrations.json`:
|
|
20
21
|
*
|
|
21
|
-
* { "telegram": { "
|
|
22
|
+
* { "telegram": { "botToken": "123456:ABC..." } }
|
|
22
23
|
*
|
|
23
24
|
* `botToken` is still resolved on read, so a `$ENV` / `!cmd` reference placed there by
|
|
24
25
|
* hand keeps working — onboarding just stores the literal token. `allowedChatIds` is an
|
|
@@ -36,15 +37,45 @@
|
|
|
36
37
|
*/
|
|
37
38
|
|
|
38
39
|
import { run } from "@grammyjs/runner";
|
|
39
|
-
// The integration
|
|
40
|
-
//
|
|
41
|
-
import
|
|
40
|
+
// The integration surface is host-provided via the loader's VIRTUAL_MODULES / aliases, so
|
|
41
|
+
// wolli is a peerDependency, not a dependency.
|
|
42
|
+
import { defineIntegration, type IntegrationOnboardContext } from "wolli";
|
|
42
43
|
import { Bot, GrammyError } from "grammy";
|
|
43
44
|
import { Type } from "typebox";
|
|
44
45
|
|
|
45
46
|
/** Telegram caps a single message at 4096 UTF-16 code units. */
|
|
46
47
|
const TELEGRAM_MAX_LENGTH = 4096;
|
|
47
48
|
|
|
49
|
+
/** Typing indicator refresh interval — Telegram clears the "typing…" state after a few seconds. */
|
|
50
|
+
const TYPING_INTERVAL_MS = 4000;
|
|
51
|
+
|
|
52
|
+
/** BotFather slash-command menu, registered at producer start. */
|
|
53
|
+
const COMMANDS = [
|
|
54
|
+
{ command: "new", description: "Start a fresh session" },
|
|
55
|
+
{ command: "status", description: "Show the current session and model" },
|
|
56
|
+
{ command: "help", description: "List available commands" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Live "typing…" keep-alive timers, keyed by chatId. Per-chat so parallel chats never
|
|
61
|
+
* stomp each other; cleared by `stopTyping` and by the producer's disposer on reload,
|
|
62
|
+
* so a reload can never orphan an interval.
|
|
63
|
+
*/
|
|
64
|
+
const typingTimers = new Map<number, ReturnType<typeof setInterval>>();
|
|
65
|
+
|
|
66
|
+
function stopTypingTimer(chatId: number): void {
|
|
67
|
+
const timer = typingTimers.get(chatId);
|
|
68
|
+
if (timer) {
|
|
69
|
+
clearInterval(timer);
|
|
70
|
+
typingTimers.delete(chatId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearAllTypingTimers(): void {
|
|
75
|
+
for (const timer of typingTimers.values()) clearInterval(timer);
|
|
76
|
+
typingTimers.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
/** BotFather walkthrough shown on the onboarding guide screen. */
|
|
49
80
|
const ONBOARD_GUIDE = [
|
|
50
81
|
"## Connect Telegram",
|
|
@@ -160,152 +191,159 @@ async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: stri
|
|
|
160
191
|
return { botToken: token };
|
|
161
192
|
}
|
|
162
193
|
|
|
163
|
-
export default
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
194
|
+
export default defineIntegration({
|
|
195
|
+
account: Type.Object({
|
|
196
|
+
/** BotFather token. Onboarding stores it raw; a `$ENV`/`!cmd` reference also works. */
|
|
197
|
+
botToken: Type.String(),
|
|
198
|
+
/** Allowlist of chat ids. Empty/absent = allow all (logged as a warning). */
|
|
199
|
+
allowedChatIds: Type.Optional(Type.Array(Type.Number())),
|
|
200
|
+
/** Outbound formatting; `"plain"` disables parse mode. */
|
|
201
|
+
parseMode: Type.Optional(
|
|
202
|
+
Type.Union([Type.Literal("MarkdownV2"), Type.Literal("HTML"), Type.Literal("plain")]),
|
|
203
|
+
),
|
|
204
|
+
}),
|
|
205
|
+
events: {
|
|
206
|
+
message: Type.Object({
|
|
207
|
+
chatId: Type.Number(),
|
|
208
|
+
messageId: Type.Number(),
|
|
209
|
+
text: Type.String(),
|
|
210
|
+
from: Type.Object({
|
|
211
|
+
id: Type.Number(),
|
|
212
|
+
username: Type.Optional(Type.String()),
|
|
213
|
+
firstName: Type.Optional(Type.String()),
|
|
214
|
+
}),
|
|
215
|
+
chatType: Type.String(),
|
|
216
|
+
date: Type.Number(),
|
|
175
217
|
}),
|
|
176
|
-
|
|
177
|
-
|
|
218
|
+
},
|
|
219
|
+
onboard,
|
|
220
|
+
actions: {
|
|
221
|
+
sendMessage: {
|
|
222
|
+
description: "Send a text message to a chat (chunked at 4096, with plain-text fallback).",
|
|
223
|
+
parameters: Type.Object({
|
|
178
224
|
chatId: Type.Number(),
|
|
179
|
-
messageId: Type.Number(),
|
|
180
225
|
text: Type.String(),
|
|
181
|
-
|
|
182
|
-
id: Type.Number(),
|
|
183
|
-
username: Type.Optional(Type.String()),
|
|
184
|
-
firstName: Type.Optional(Type.String()),
|
|
185
|
-
}),
|
|
186
|
-
chatType: Type.String(),
|
|
187
|
-
date: Type.Number(),
|
|
226
|
+
replyToMessageId: Type.Optional(Type.Number()),
|
|
188
227
|
}),
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}),
|
|
199
|
-
execute: async (params, ctx) => {
|
|
200
|
-
const { chatId, text, replyToMessageId } = params as {
|
|
201
|
-
chatId: number;
|
|
202
|
-
text: string;
|
|
203
|
-
replyToMessageId?: number;
|
|
204
|
-
};
|
|
205
|
-
const account = ctx.account as TelegramAccount;
|
|
206
|
-
const parseMode = account.parseMode ?? "MarkdownV2";
|
|
207
|
-
const bot = getBot(account.botToken);
|
|
228
|
+
execute: async (params, ctx) => {
|
|
229
|
+
const { chatId, text, replyToMessageId } = params as {
|
|
230
|
+
chatId: number;
|
|
231
|
+
text: string;
|
|
232
|
+
replyToMessageId?: number;
|
|
233
|
+
};
|
|
234
|
+
const account = ctx.account as TelegramAccount;
|
|
235
|
+
const parseMode = account.parseMode ?? "MarkdownV2";
|
|
236
|
+
const bot = getBot(account.botToken);
|
|
208
237
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
},
|
|
238
|
+
const messageIds: number[] = [];
|
|
239
|
+
// Reply-to applies only to the first chunk; later chunks just continue.
|
|
240
|
+
let replyTo = replyToMessageId;
|
|
241
|
+
for (const chunk of chunkText(text)) {
|
|
242
|
+
messageIds.push(await sendChunk(bot, chatId, chunk, parseMode, replyTo));
|
|
243
|
+
replyTo = undefined;
|
|
244
|
+
}
|
|
245
|
+
return { messageIds };
|
|
218
246
|
},
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
247
|
+
},
|
|
248
|
+
startTyping: {
|
|
249
|
+
description: "Show the 'typing…' indicator in a chat and keep it alive on a timer until stopTyping.",
|
|
250
|
+
parameters: Type.Object({
|
|
251
|
+
chatId: Type.Number(),
|
|
252
|
+
}),
|
|
253
|
+
execute: async (params, ctx) => {
|
|
254
|
+
const { chatId } = params as { chatId: number };
|
|
255
|
+
const account = ctx.account as TelegramAccount;
|
|
256
|
+
const bot = getBot(account.botToken);
|
|
257
|
+
const send = () => {
|
|
258
|
+
void bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
259
|
+
};
|
|
260
|
+
send(); // immediate first tick; Telegram would otherwise show nothing until the interval
|
|
261
|
+
stopTypingTimer(chatId); // replace any existing timer for this chat
|
|
262
|
+
typingTimers.set(chatId, setInterval(send, TYPING_INTERVAL_MS));
|
|
263
|
+
return { ok: true };
|
|
233
264
|
},
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
execute: async (params, ctx) => {
|
|
245
|
-
const { commands } = params as { commands: { command: string; description: string }[] };
|
|
246
|
-
const account = ctx.account as TelegramAccount;
|
|
247
|
-
const bot = getBot(account.botToken);
|
|
248
|
-
await bot.api.setMyCommands(commands);
|
|
249
|
-
return { ok: true };
|
|
250
|
-
},
|
|
265
|
+
},
|
|
266
|
+
stopTyping: {
|
|
267
|
+
description: "Stop the 'typing…' indicator in a chat.",
|
|
268
|
+
parameters: Type.Object({
|
|
269
|
+
chatId: Type.Number(),
|
|
270
|
+
}),
|
|
271
|
+
execute: async (params) => {
|
|
272
|
+
const { chatId } = params as { chatId: number };
|
|
273
|
+
stopTypingTimer(chatId);
|
|
274
|
+
return { ok: true };
|
|
251
275
|
},
|
|
252
276
|
},
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
277
|
+
},
|
|
278
|
+
run(ctx) {
|
|
279
|
+
const account = ctx.account as TelegramAccount;
|
|
280
|
+
const { botToken, allowedChatIds } = account;
|
|
281
|
+
const allowAll = !allowedChatIds || allowedChatIds.length === 0;
|
|
282
|
+
if (allowAll) {
|
|
283
|
+
console.warn("[telegram] no allowedChatIds configured — accepting messages from ANY chat.");
|
|
284
|
+
}
|
|
260
285
|
|
|
261
|
-
|
|
286
|
+
const bot = getBot(botToken);
|
|
262
287
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
288
|
+
// Register the BotFather slash-command menu — transport-level registration, re-run each
|
|
289
|
+
// reload. Fire-and-forget: a menu-registration failure must not block the poller.
|
|
290
|
+
void bot.api.setMyCommands(COMMANDS).catch((err) => {
|
|
291
|
+
console.error("[telegram] failed to register command menu:", err instanceof Error ? err.message : err);
|
|
292
|
+
});
|
|
268
293
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
id: c.from?.id ?? 0,
|
|
275
|
-
username: c.from?.username,
|
|
276
|
-
firstName: c.from?.first_name,
|
|
277
|
-
},
|
|
278
|
-
chatType: c.chat.type,
|
|
279
|
-
date: c.msg.date,
|
|
280
|
-
});
|
|
281
|
-
});
|
|
294
|
+
bot.on("message:text", (c) => {
|
|
295
|
+
// Ignore our own messages and anything outside the allowlist.
|
|
296
|
+
if (c.from?.id === c.me.id) return;
|
|
297
|
+
const chatId = c.chat.id;
|
|
298
|
+
if (!allowAll && !allowedChatIds?.includes(chatId)) return;
|
|
282
299
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
300
|
+
ctx.emit("message", {
|
|
301
|
+
chatId,
|
|
302
|
+
messageId: c.msg.message_id,
|
|
303
|
+
text: c.msg.text,
|
|
304
|
+
from: {
|
|
305
|
+
id: c.from?.id ?? 0,
|
|
306
|
+
username: c.from?.username,
|
|
307
|
+
firstName: c.from?.first_name,
|
|
308
|
+
},
|
|
309
|
+
chatType: c.chat.type,
|
|
310
|
+
date: c.msg.date,
|
|
286
311
|
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Update-handler errors (middleware throwing while processing an inbound update) — log, don't rethrow.
|
|
315
|
+
bot.catch((err) => {
|
|
316
|
+
console.error("[telegram] bot error:", err.message);
|
|
317
|
+
});
|
|
287
318
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
.catch
|
|
298
|
-
|
|
319
|
+
// Fire-and-forget startup: drop any webhook + backlog, then start long polling.
|
|
320
|
+
// The runner never resolves, so we must NOT await it.
|
|
321
|
+
let runner: ReturnType<typeof run> | undefined;
|
|
322
|
+
void bot.api
|
|
323
|
+
.deleteWebhook({ drop_pending_updates: true })
|
|
324
|
+
.then(() => {
|
|
325
|
+
if (ctx.signal.aborted) return;
|
|
326
|
+
runner = run(bot);
|
|
327
|
+
// A fatal getUpdates error (e.g. a 409 conflict from a poll overlap on reload) rejects the
|
|
328
|
+
// runner task. bot.catch does NOT cover the runner's fetch loop, so catch it here — otherwise
|
|
329
|
+
// the rejection is unhandled and crashes the whole daemon, not just the poller.
|
|
330
|
+
runner.task()?.catch((err) => {
|
|
331
|
+
console.error("[telegram] long polling stopped:", err instanceof Error ? err.message : err);
|
|
299
332
|
});
|
|
333
|
+
})
|
|
334
|
+
.catch((err) => {
|
|
335
|
+
console.error("[telegram] failed to start long polling:", err instanceof Error ? err.message : err);
|
|
336
|
+
});
|
|
300
337
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
338
|
+
// Belt and suspenders: stop the runner on abort and via the returned disposer.
|
|
339
|
+
// The stop-before-start swap on reload relies on this to avoid Telegram's 409
|
|
340
|
+
// ("two pollers on one token") conflict.
|
|
341
|
+
const dispose = () => {
|
|
342
|
+
// Clear typing timers here so a reload can never orphan an interval.
|
|
343
|
+
clearAllTypingTimers();
|
|
344
|
+
if (runner?.isRunning()) void runner.stop();
|
|
345
|
+
};
|
|
346
|
+
ctx.signal.addEventListener("abort", dispose);
|
|
347
|
+
return dispose;
|
|
348
|
+
},
|
|
349
|
+
});
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"wolli": {
|
|
7
7
|
"integrations": ["./index.ts"],
|
|
8
|
-
"
|
|
8
|
+
"workflows": ["./telegram-chat.ts"]
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"grammy": "1.44.0",
|
|
12
12
|
"@grammyjs/runner": "2.0.3"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"
|
|
15
|
+
"wolli": "*"
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -1,169 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram chat
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `ctx.session.getTags()` → final assistant text → `sendMessage`
|
|
12
|
-
* - typing: `agent_start`/`agent_end` toggle the Telegram "typing…" indicator
|
|
13
|
-
* - commands: `/new`, `/status`, `/help` are handled here, not sent to the model
|
|
14
|
-
*
|
|
15
|
-
* Session binding via tags: each chat gets its OWN Wolli session, bound by a `{ "telegram:chat":
|
|
16
|
-
* <id> }` tag. `findSessions` locates (and `createSession` lazily creates) the chat's session, so two
|
|
17
|
-
* chats run in parallel and a reply returns to the chat that started the turn — located by any
|
|
18
|
-
* extension with `wolli.findSessions({ "telegram:chat": <id> })`.
|
|
19
|
-
*
|
|
20
|
-
* This file is declared under the package's `wolli.extensions` and is resolved in place by the
|
|
21
|
-
* package manager when the integration is onboarded
|
|
22
|
-
* (`wolli <agent> plugins configure telegram`); it activates on the next launch.
|
|
2
|
+
* Telegram chat routing, as one workflows file:
|
|
3
|
+
* - `inbound` binds each chat to its own session by a `telegram:chat` tag and delivers
|
|
4
|
+
* inbound text as a followUp. Slash commands (`/new`, `/status`, `/help`) are handled
|
|
5
|
+
* locally, before the session lookup, and never reach the model.
|
|
6
|
+
* - `typing` starts the "typing…" indicator on `agent_start`; the integration owns the
|
|
7
|
+
* keep-alive timer.
|
|
8
|
+
* - `reply` ships the turn's final assistant text back on `agent_end`, riding the
|
|
9
|
+
* producing session's `telegram:chat` tag; typing stops first so a pure tool-call turn
|
|
10
|
+
* still clears the indicator.
|
|
23
11
|
*/
|
|
24
12
|
|
|
25
|
-
import type
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
const TYPING_INTERVAL_MS = 4000;
|
|
29
|
-
|
|
30
|
-
const COMMANDS = [
|
|
31
|
-
{ command: "new", description: "Start a fresh session" },
|
|
32
|
-
{ command: "status", description: "Show the current session and model" },
|
|
33
|
-
{ command: "help", description: "List available commands" },
|
|
34
|
-
];
|
|
13
|
+
import { type WorkflowContext, wolli } from "wolli";
|
|
14
|
+
import telegram from "./index.ts";
|
|
35
15
|
|
|
36
16
|
interface TelegramMessage {
|
|
37
17
|
chatId: number;
|
|
38
|
-
messageId: number;
|
|
39
18
|
text: string;
|
|
40
|
-
from: { id: number; username?: string; firstName?: string };
|
|
41
|
-
chatType: string;
|
|
42
|
-
date: number;
|
|
43
19
|
}
|
|
44
20
|
|
|
45
|
-
/**
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export default function (wolli: ExtensionAPI) {
|
|
60
|
-
const tg = wolli.getIntegration("telegram", "default");
|
|
61
|
-
|
|
62
|
-
// Model id from the most recent turn, surfaced by /status.
|
|
63
|
-
let lastModel: string | undefined;
|
|
64
|
-
let typingTimer: ReturnType<typeof setInterval> | undefined;
|
|
65
|
-
|
|
66
|
-
const reply = async (chatId: number, text: string): Promise<void> => {
|
|
67
|
-
await tg.call("sendMessage", { chatId, text });
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// Register the BotFather slash-command menu on startup.
|
|
71
|
-
void tg.call("setCommands", { commands: COMMANDS }).catch((err) => {
|
|
72
|
-
console.error("[telegram-chat] setCommands failed:", err instanceof Error ? err.message : err);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
// Slash commands are handled locally instead of being sent to the model.
|
|
76
|
-
async function handleCommand(chatId: number, text: string): Promise<void> {
|
|
77
|
-
// Strip a leading "/" and an optional "@botname" suffix (groups append it).
|
|
78
|
-
const command = text.slice(1).split(/\s+/)[0].split("@")[0].toLowerCase();
|
|
79
|
-
switch (command) {
|
|
80
|
-
case "new": {
|
|
81
|
-
// A fresh session tagged with this chat becomes the newest match, so future messages
|
|
82
|
-
// route to it (the prior session stays addressable but is no longer the newest).
|
|
83
|
-
await wolli.createSession({
|
|
84
|
-
setup: async (sessionManager) => {
|
|
85
|
-
await sessionManager.appendTags({ "telegram:chat": String(chatId) });
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
await reply(chatId, "Started a fresh session.");
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
case "status": {
|
|
92
|
-
const matches = await wolli.findSessions({ "telegram:chat": String(chatId) });
|
|
93
|
-
const name = (matches[0] && wolli.getSession(matches[0].id)?.getSessionName()) ?? "(unnamed)";
|
|
94
|
-
const model = lastModel ?? "(unknown until first reply)";
|
|
95
|
-
await reply(chatId, `Session: ${name}\nModel: ${model}`);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
case "help": {
|
|
99
|
-
const lines = COMMANDS.map((c) => `/${c.command} — ${c.description}`).join("\n");
|
|
100
|
-
await reply(chatId, `Commands:\n${lines}`);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
default:
|
|
104
|
-
await reply(chatId, `Unknown command: /${command}. Try /help.`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
tg.on("message", async (data) => {
|
|
109
|
-
const m = data as TelegramMessage;
|
|
110
|
-
|
|
111
|
-
if (m.text.startsWith("/")) {
|
|
112
|
-
await handleCommand(m.chatId, m.text);
|
|
21
|
+
/** Slash commands are handled locally instead of being sent to the model. */
|
|
22
|
+
async function handleCommand(msg: TelegramMessage, ctx: WorkflowContext): Promise<void> {
|
|
23
|
+
const tg = ctx.integration(telegram);
|
|
24
|
+
// Strip the leading "/" and an optional "@botname" suffix (groups append it).
|
|
25
|
+
const command = msg.text.slice(1).split(/\s+/)[0].split("@")[0].toLowerCase();
|
|
26
|
+
const chatTag = { "telegram:chat": String(msg.chatId) };
|
|
27
|
+
switch (command) {
|
|
28
|
+
case "new": {
|
|
29
|
+
// A fresh session tagged with this chat becomes the newest match, so future messages
|
|
30
|
+
// route to it (the prior session stays addressable but is no longer the newest).
|
|
31
|
+
await ctx.agent.createSession({ setup: (s) => s.appendTags(chatTag) });
|
|
32
|
+
await tg.sendMessage({ chatId: msg.chatId, text: "Started a fresh session." });
|
|
113
33
|
return;
|
|
114
34
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
setup: async (sessionManager) => {
|
|
123
|
-
await sessionManager.appendTags(chatTag);
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
// followUp so a message arriving mid-stream queues cleanly instead of interrupting.
|
|
128
|
-
void session.sendUserMessage(m.text, { deliverAs: "followUp" });
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
// Typing indicator: kept alive on a timer while a turn runs (Telegram clears the
|
|
132
|
-
// "typing…" state after a few seconds, so it must be re-sent).
|
|
133
|
-
const stopTyping = (): void => {
|
|
134
|
-
if (typingTimer) {
|
|
135
|
-
clearInterval(typingTimer);
|
|
136
|
-
typingTimer = undefined;
|
|
35
|
+
case "status": {
|
|
36
|
+
const [match] = await ctx.agent.findSessions(chatTag);
|
|
37
|
+
const session = match ? await ctx.agent.openSession(match.id) : undefined;
|
|
38
|
+
const name = session?.getSessionName() ?? "(none yet)";
|
|
39
|
+
const model = session?.model?.id ?? "(none yet)";
|
|
40
|
+
await tg.sendMessage({ chatId: msg.chatId, text: `Session: ${name}\nModel: ${model}` });
|
|
41
|
+
return;
|
|
137
42
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
147
|
-
sendTyping();
|
|
148
|
-
stopTyping();
|
|
149
|
-
typingTimer = setInterval(sendTyping, TYPING_INTERVAL_MS);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
wolli.on("agent_end", async ({ messages }, ctx) => {
|
|
153
|
-
stopTyping();
|
|
154
|
-
|
|
155
|
-
const assistantMsgs = messages as AgentMessage[];
|
|
156
|
-
for (const m of assistantMsgs) {
|
|
157
|
-
if (m.role === "assistant") lastModel = m.model;
|
|
43
|
+
case "help": {
|
|
44
|
+
const lines = [
|
|
45
|
+
"/new — Start a fresh session",
|
|
46
|
+
"/status — Show the current session and model",
|
|
47
|
+
"/help — List available commands",
|
|
48
|
+
].join("\n");
|
|
49
|
+
await tg.sendMessage({ chatId: msg.chatId, text: `Commands:\n${lines}` });
|
|
50
|
+
return;
|
|
158
51
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const chat = ctx.session.getTags()["telegram:chat"];
|
|
163
|
-
if (!chat) return; // not a telegram-bound session
|
|
164
|
-
|
|
165
|
-
const text = finalAssistantText(assistantMsgs);
|
|
166
|
-
if (!text) return; // pure tool-call turn — nothing to send
|
|
167
|
-
await reply(Number(chat), text);
|
|
168
|
-
});
|
|
52
|
+
default:
|
|
53
|
+
await tg.sendMessage({ chatId: msg.chatId, text: `Unknown command: /${command}. Try /help.` });
|
|
54
|
+
}
|
|
169
55
|
}
|
|
56
|
+
|
|
57
|
+
// msg is typed from the event schema
|
|
58
|
+
export const inbound = telegram.on("message", async (msg, ctx) => {
|
|
59
|
+
if (msg.text.startsWith("/")) return handleCommand(msg, ctx);
|
|
60
|
+
const chatTag = { "telegram:chat": String(msg.chatId) };
|
|
61
|
+
const [match] = await ctx.agent.findSessions(chatTag);
|
|
62
|
+
const session = match
|
|
63
|
+
? await ctx.agent.openSession(match.id)
|
|
64
|
+
: await ctx.agent.createSession({
|
|
65
|
+
setup: (s) => s.appendTags(chatTag),
|
|
66
|
+
});
|
|
67
|
+
// followUp queues behind a running turn instead of interrupting it.
|
|
68
|
+
await session.sendUserMessage(msg.text, { deliverAs: "followUp" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const typing = wolli.on("agent_start", async (_evt, ctx) => {
|
|
72
|
+
const chat = ctx.session.getTags()["telegram:chat"];
|
|
73
|
+
if (!chat) return; // not a telegram-bound session
|
|
74
|
+
await ctx.integration(telegram).startTyping({ chatId: Number(chat) });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const reply = wolli.on("agent_end", async (evt, ctx) => {
|
|
78
|
+
const chat = ctx.session.getTags()["telegram:chat"];
|
|
79
|
+
if (!chat) return; // not a telegram-bound session
|
|
80
|
+
const chatId = Number(chat);
|
|
81
|
+
// Stop the typing indicator before the empty-text early return, so a pure tool-call
|
|
82
|
+
// turn still clears it.
|
|
83
|
+
await ctx.integration(telegram).stopTyping({ chatId });
|
|
84
|
+
const text = evt.messages
|
|
85
|
+
.filter((m) => m.role === "assistant")
|
|
86
|
+
.at(-1)
|
|
87
|
+
?.content.filter((c) => c.type === "text")
|
|
88
|
+
.map((c) => c.text)
|
|
89
|
+
.join("")
|
|
90
|
+
.trim();
|
|
91
|
+
if (!text) return; // a pure tool-call turn sends nothing
|
|
92
|
+
await ctx.integration(telegram).sendMessage({ chatId, text });
|
|
93
|
+
});
|