wolli 0.0.2 → 0.0.4
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 +31 -21
- package/built-in/plugins/discord/README.md +156 -0
- package/built-in/plugins/discord/discord-chat.ts +50 -0
- package/built-in/plugins/discord/index.ts +234 -0
- package/built-in/plugins/discord/package.json +16 -0
- package/built-in/plugins/scheduler/cron.ts +131 -0
- package/built-in/plugins/scheduler/index.ts +240 -0
- package/{plugins → 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 +153 -0
- package/built-in/plugins/telegram/index.ts +349 -0
- package/{plugins → built-in/plugins}/telegram/package.json +2 -2
- package/built-in/plugins/telegram/telegram-chat.ts +93 -0
- package/built-in/skills/.gitkeep +0 -0
- package/dist/cli.js +5739 -5848
- 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/skills.md +14 -0
- package/docs/tools.md +81 -0
- package/docs/workflows.md +170 -0
- package/package.json +2 -2
- package/docs/extensions.md +0 -2331
- package/plugins/scheduler/index.ts +0 -244
- package/plugins/scheduler/scheduler-chat.ts +0 -164
- package/plugins/telegram/index.ts +0 -311
- package/plugins/telegram/telegram-chat.ts +0 -169
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram chat integration — the transport half (self-contained package).
|
|
3
|
+
*
|
|
4
|
+
* This integration faces the network, holds the bot token, and emits a `message`
|
|
5
|
+
* event per inbound Telegram message. It does not touch sessions or the agent; the
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* Transport is grammY long polling (`@grammyjs/runner`'s `run`), so no public URL
|
|
12
|
+
* or TLS is needed. The package brings its OWN grammy + @grammyjs/runner deps.
|
|
13
|
+
*
|
|
14
|
+
* ## Install + configure
|
|
15
|
+
*
|
|
16
|
+
* wolli <agent> plugins install ./built-in/plugins/telegram
|
|
17
|
+
* # then paste the BotFather token into the guided prompt — that's it.
|
|
18
|
+
*
|
|
19
|
+
* Onboarding asks for the BotFather token directly, verifies it with a live `getMe()`,
|
|
20
|
+
* and stores the raw token in `~/.wolli/agents/<name>/integrations.json`:
|
|
21
|
+
*
|
|
22
|
+
* { "telegram": { "botToken": "123456:ABC..." } }
|
|
23
|
+
*
|
|
24
|
+
* `botToken` is still resolved on read, so a `$ENV` / `!cmd` reference placed there by
|
|
25
|
+
* hand keeps working — onboarding just stores the literal token. `allowedChatIds` is an
|
|
26
|
+
* allowlist — empty/absent means "allow any chat" (logged as a warning). `parseMode`
|
|
27
|
+
* is how outbound text is formatted (default `"MarkdownV2"`; `"plain"` disables it).
|
|
28
|
+
* `allowedChatIds`/`parseMode` are not asked during onboarding — edit integrations.json
|
|
29
|
+
* to set them.
|
|
30
|
+
*
|
|
31
|
+
* ## Known v1 limitations
|
|
32
|
+
* - No durable cursor: `run()` calls `deleteWebhook({ drop_pending_updates: true })`
|
|
33
|
+
* on start, so a restart never replays a backlog — but it also drops messages
|
|
34
|
+
* sent while the bot was offline. Acceptable for v1.
|
|
35
|
+
* - No inbound media/images, no webhook mode, no callback queries / inline keyboards.
|
|
36
|
+
* - No outbound throttling (add `@grammyjs/transformer-throttler` if rate limits bite).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { run } from "@grammyjs/runner";
|
|
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";
|
|
43
|
+
import { Bot, GrammyError } from "grammy";
|
|
44
|
+
import { Type } from "typebox";
|
|
45
|
+
|
|
46
|
+
/** Telegram caps a single message at 4096 UTF-16 code units. */
|
|
47
|
+
const TELEGRAM_MAX_LENGTH = 4096;
|
|
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
|
+
|
|
79
|
+
/** BotFather walkthrough shown on the onboarding guide screen. */
|
|
80
|
+
const ONBOARD_GUIDE = [
|
|
81
|
+
"## Connect Telegram",
|
|
82
|
+
"",
|
|
83
|
+
"1. Open [@BotFather](https://t.me/BotFather) in Telegram and send `/newbot`.",
|
|
84
|
+
"2. Pick a name and username; BotFather replies with a **bot token** like",
|
|
85
|
+
" `123456:ABC-DEF...`.",
|
|
86
|
+
"3. Copy that token and paste it on the next screen.",
|
|
87
|
+
"",
|
|
88
|
+
"Wolli verifies the token and stores it for this agent.",
|
|
89
|
+
].join("\n");
|
|
90
|
+
|
|
91
|
+
type ParseMode = "MarkdownV2" | "HTML" | "plain";
|
|
92
|
+
|
|
93
|
+
interface TelegramAccount {
|
|
94
|
+
botToken: string;
|
|
95
|
+
allowedChatIds?: number[];
|
|
96
|
+
parseMode?: ParseMode;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One `Bot` instance per token, shared between the long-poll producer (`run`) and
|
|
101
|
+
* the request/response actions so they reuse a single `bot.api`. A `Bot` is lazy —
|
|
102
|
+
* constructing it makes no network call — so this is safe to build on first use.
|
|
103
|
+
*/
|
|
104
|
+
const bots = new Map<string, Bot>();
|
|
105
|
+
function getBot(token: string): Bot {
|
|
106
|
+
let bot = bots.get(token);
|
|
107
|
+
if (!bot) {
|
|
108
|
+
bot = new Bot(token);
|
|
109
|
+
bots.set(token, bot);
|
|
110
|
+
}
|
|
111
|
+
return bot;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Split `text` into ≤4096-code-unit chunks without cutting a surrogate pair. JS
|
|
116
|
+
* string length is already in UTF-16 code units, which is exactly Telegram's unit.
|
|
117
|
+
*/
|
|
118
|
+
function chunkText(text: string, max = TELEGRAM_MAX_LENGTH): string[] {
|
|
119
|
+
if (text.length <= max) return [text];
|
|
120
|
+
const chunks: string[] = [];
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < text.length) {
|
|
123
|
+
let end = Math.min(i + max, text.length);
|
|
124
|
+
if (end < text.length) {
|
|
125
|
+
// If the boundary lands on a high surrogate, push it into the next chunk.
|
|
126
|
+
const code = text.charCodeAt(end - 1);
|
|
127
|
+
if (code >= 0xd800 && code <= 0xdbff) end -= 1;
|
|
128
|
+
}
|
|
129
|
+
chunks.push(text.slice(i, end));
|
|
130
|
+
i = end;
|
|
131
|
+
}
|
|
132
|
+
return chunks;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A Telegram API error caused by parse-mode entity parsing (not e.g. a bad chat id). */
|
|
136
|
+
function isParseError(err: unknown): boolean {
|
|
137
|
+
return err instanceof GrammyError && err.error_code === 400 && /can't parse|can't find|entit/i.test(err.description);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Send one already-chunked message. Tries the configured parse mode first and, if
|
|
142
|
+
* Telegram rejects the formatting, resends the same text as plain (no parse mode) so
|
|
143
|
+
* a stray `*` or `_` from the model never silently drops the reply.
|
|
144
|
+
*/
|
|
145
|
+
async function sendChunk(
|
|
146
|
+
bot: Bot,
|
|
147
|
+
chatId: number,
|
|
148
|
+
text: string,
|
|
149
|
+
parseMode: ParseMode,
|
|
150
|
+
replyToMessageId: number | undefined,
|
|
151
|
+
): Promise<number> {
|
|
152
|
+
const reply = replyToMessageId ? { reply_parameters: { message_id: replyToMessageId } } : {};
|
|
153
|
+
try {
|
|
154
|
+
const other = parseMode === "plain" ? { ...reply } : { parse_mode: parseMode, ...reply };
|
|
155
|
+
const sent = await bot.api.sendMessage(chatId, text, other);
|
|
156
|
+
return sent.message_id;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (parseMode === "plain" || !isParseError(err)) throw err;
|
|
159
|
+
const sent = await bot.api.sendMessage(chatId, text, { ...reply });
|
|
160
|
+
return sent.message_id;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Guided setup: show the BotFather walkthrough, collect the bot token directly, verify
|
|
166
|
+
* it with a live `getMe()`, and return the raw token to store. Returns `undefined`
|
|
167
|
+
* (cancelled) if the user dismisses a step, submits nothing, or verification fails.
|
|
168
|
+
*/
|
|
169
|
+
async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: string } | undefined> {
|
|
170
|
+
// Guide text — onboarding renders over the wire (the daemon is the single writer), so the BotFather
|
|
171
|
+
// walkthrough prints as a notification ahead of the token prompt rather than a custom guide screen.
|
|
172
|
+
ctx.ui.notify(ONBOARD_GUIDE, "info");
|
|
173
|
+
|
|
174
|
+
// undefined = cancelled. The pasted value is the raw token itself, not a reference.
|
|
175
|
+
const entered = await ctx.ui.input("Paste the bot token from BotFather");
|
|
176
|
+
if (entered === undefined) return undefined; // cancelled
|
|
177
|
+
const token = entered.trim();
|
|
178
|
+
if (!token) {
|
|
179
|
+
ctx.ui.notify("No token entered.", "error");
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const me = await new Bot(token).api.getMe();
|
|
185
|
+
ctx.ui.notify(`Verified bot @${me.username}.`, "info");
|
|
186
|
+
} catch (err) {
|
|
187
|
+
ctx.ui.notify(`Could not verify the token: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { botToken: token };
|
|
192
|
+
}
|
|
193
|
+
|
|
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(),
|
|
217
|
+
}),
|
|
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({
|
|
224
|
+
chatId: Type.Number(),
|
|
225
|
+
text: Type.String(),
|
|
226
|
+
replyToMessageId: Type.Optional(Type.Number()),
|
|
227
|
+
}),
|
|
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);
|
|
237
|
+
|
|
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 };
|
|
246
|
+
},
|
|
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 };
|
|
264
|
+
},
|
|
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 };
|
|
275
|
+
},
|
|
276
|
+
},
|
|
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
|
+
}
|
|
285
|
+
|
|
286
|
+
const bot = getBot(botToken);
|
|
287
|
+
|
|
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
|
+
});
|
|
293
|
+
|
|
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;
|
|
299
|
+
|
|
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,
|
|
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
|
+
});
|
|
318
|
+
|
|
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);
|
|
332
|
+
});
|
|
333
|
+
})
|
|
334
|
+
.catch((err) => {
|
|
335
|
+
console.error("[telegram] failed to start long polling:", err instanceof Error ? err.message : err);
|
|
336
|
+
});
|
|
337
|
+
|
|
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
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type WorkflowContext, wolli } from "wolli";
|
|
14
|
+
import telegram from "./index.ts";
|
|
15
|
+
|
|
16
|
+
interface TelegramMessage {
|
|
17
|
+
chatId: number;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
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." });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
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;
|
|
42
|
+
}
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
await tg.sendMessage({ chatId: msg.chatId, text: `Unknown command: /${command}. Try /help.` });
|
|
54
|
+
}
|
|
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
|
+
});
|
|
File without changes
|