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.
@@ -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
- "extensions": ["./telegram-chat.ts"]
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
- "@opsyhq/wolli": "*"
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