wolli 0.0.1 → 0.0.2

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,311 @@
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
+ * paired extension (`telegram-chat.ts`, declared under `wolli.extensions`) maps those
7
+ * events into a chat loop and is resolved in place by the package manager. See
8
+ * `INTEGRATION.md` for the transport-vs-mapping split.
9
+ *
10
+ * Transport is grammY long polling (`@grammyjs/runner`'s `run`), so no public URL
11
+ * or TLS is needed. The package brings its OWN grammy + @grammyjs/runner deps.
12
+ *
13
+ * ## Install + configure
14
+ *
15
+ * wolli <agent> plugins install ./plugins/telegram
16
+ * # then paste the BotFather token into the guided prompt — that's it.
17
+ *
18
+ * Onboarding asks for the BotFather token directly, verifies it with a live `getMe()`,
19
+ * and stores the raw token in `~/.wolli/agents/<name>/integrations.json`:
20
+ *
21
+ * { "telegram": { "default": { "botToken": "123456:ABC..." } } }
22
+ *
23
+ * `botToken` is still resolved on read, so a `$ENV` / `!cmd` reference placed there by
24
+ * hand keeps working — onboarding just stores the literal token. `allowedChatIds` is an
25
+ * allowlist — empty/absent means "allow any chat" (logged as a warning). `parseMode`
26
+ * is how outbound text is formatted (default `"MarkdownV2"`; `"plain"` disables it).
27
+ * `allowedChatIds`/`parseMode` are not asked during onboarding — edit integrations.json
28
+ * to set them.
29
+ *
30
+ * ## Known v1 limitations
31
+ * - No durable cursor: `run()` calls `deleteWebhook({ drop_pending_updates: true })`
32
+ * on start, so a restart never replays a backlog — but it also drops messages
33
+ * sent while the bot was offline. Acceptable for v1.
34
+ * - No inbound media/images, no webhook mode, no callback queries / inline keyboards.
35
+ * - No outbound throttling (add `@grammyjs/transformer-throttler` if rate limits bite).
36
+ */
37
+
38
+ import { run } from "@grammyjs/runner";
39
+ // The integration types are host-provided via the loader's VIRTUAL_MODULES / aliases, so
40
+ // @opsyhq/wolli is a peerDependency, not a dependency.
41
+ import type { IntegrationOnboardContext, IntegrationsAPI } from "@opsyhq/wolli";
42
+ import { Bot, GrammyError } from "grammy";
43
+ import { Type } from "typebox";
44
+
45
+ /** Telegram caps a single message at 4096 UTF-16 code units. */
46
+ const TELEGRAM_MAX_LENGTH = 4096;
47
+
48
+ /** BotFather walkthrough shown on the onboarding guide screen. */
49
+ const ONBOARD_GUIDE = [
50
+ "## Connect Telegram",
51
+ "",
52
+ "1. Open [@BotFather](https://t.me/BotFather) in Telegram and send `/newbot`.",
53
+ "2. Pick a name and username; BotFather replies with a **bot token** like",
54
+ " `123456:ABC-DEF...`.",
55
+ "3. Copy that token and paste it on the next screen.",
56
+ "",
57
+ "Wolli verifies the token and stores it for this agent.",
58
+ ].join("\n");
59
+
60
+ type ParseMode = "MarkdownV2" | "HTML" | "plain";
61
+
62
+ interface TelegramAccount {
63
+ botToken: string;
64
+ allowedChatIds?: number[];
65
+ parseMode?: ParseMode;
66
+ }
67
+
68
+ /**
69
+ * One `Bot` instance per token, shared between the long-poll producer (`run`) and
70
+ * the request/response actions so they reuse a single `bot.api`. A `Bot` is lazy —
71
+ * constructing it makes no network call — so this is safe to build on first use.
72
+ */
73
+ const bots = new Map<string, Bot>();
74
+ function getBot(token: string): Bot {
75
+ let bot = bots.get(token);
76
+ if (!bot) {
77
+ bot = new Bot(token);
78
+ bots.set(token, bot);
79
+ }
80
+ return bot;
81
+ }
82
+
83
+ /**
84
+ * Split `text` into ≤4096-code-unit chunks without cutting a surrogate pair. JS
85
+ * string length is already in UTF-16 code units, which is exactly Telegram's unit.
86
+ */
87
+ function chunkText(text: string, max = TELEGRAM_MAX_LENGTH): string[] {
88
+ if (text.length <= max) return [text];
89
+ const chunks: string[] = [];
90
+ let i = 0;
91
+ while (i < text.length) {
92
+ let end = Math.min(i + max, text.length);
93
+ if (end < text.length) {
94
+ // If the boundary lands on a high surrogate, push it into the next chunk.
95
+ const code = text.charCodeAt(end - 1);
96
+ if (code >= 0xd800 && code <= 0xdbff) end -= 1;
97
+ }
98
+ chunks.push(text.slice(i, end));
99
+ i = end;
100
+ }
101
+ return chunks;
102
+ }
103
+
104
+ /** A Telegram API error caused by parse-mode entity parsing (not e.g. a bad chat id). */
105
+ function isParseError(err: unknown): boolean {
106
+ return err instanceof GrammyError && err.error_code === 400 && /can't parse|can't find|entit/i.test(err.description);
107
+ }
108
+
109
+ /**
110
+ * Send one already-chunked message. Tries the configured parse mode first and, if
111
+ * Telegram rejects the formatting, resends the same text as plain (no parse mode) so
112
+ * a stray `*` or `_` from the model never silently drops the reply.
113
+ */
114
+ async function sendChunk(
115
+ bot: Bot,
116
+ chatId: number,
117
+ text: string,
118
+ parseMode: ParseMode,
119
+ replyToMessageId: number | undefined,
120
+ ): Promise<number> {
121
+ const reply = replyToMessageId ? { reply_parameters: { message_id: replyToMessageId } } : {};
122
+ try {
123
+ const other = parseMode === "plain" ? { ...reply } : { parse_mode: parseMode, ...reply };
124
+ const sent = await bot.api.sendMessage(chatId, text, other);
125
+ return sent.message_id;
126
+ } catch (err) {
127
+ if (parseMode === "plain" || !isParseError(err)) throw err;
128
+ const sent = await bot.api.sendMessage(chatId, text, { ...reply });
129
+ return sent.message_id;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Guided setup: show the BotFather walkthrough, collect the bot token directly, verify
135
+ * it with a live `getMe()`, and return the raw token to store. Returns `undefined`
136
+ * (cancelled) if the user dismisses a step, submits nothing, or verification fails.
137
+ */
138
+ async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: string } | undefined> {
139
+ // Guide text — onboarding renders over the wire (the daemon is the single writer), so the BotFather
140
+ // walkthrough prints as a notification ahead of the token prompt rather than a custom guide screen.
141
+ ctx.ui.notify(ONBOARD_GUIDE, "info");
142
+
143
+ // undefined = cancelled. The pasted value is the raw token itself, not a reference.
144
+ const entered = await ctx.ui.input("Paste the bot token from BotFather");
145
+ if (entered === undefined) return undefined; // cancelled
146
+ const token = entered.trim();
147
+ if (!token) {
148
+ ctx.ui.notify("No token entered.", "error");
149
+ return undefined;
150
+ }
151
+
152
+ try {
153
+ const me = await new Bot(token).api.getMe();
154
+ ctx.ui.notify(`Verified bot @${me.username}.`, "info");
155
+ } catch (err) {
156
+ ctx.ui.notify(`Could not verify the token: ${err instanceof Error ? err.message : String(err)}`, "error");
157
+ return undefined;
158
+ }
159
+
160
+ return { botToken: token };
161
+ }
162
+
163
+ export default function (wolli: IntegrationsAPI) {
164
+ wolli.registerIntegration({
165
+ name: "telegram",
166
+ account: Type.Object({
167
+ /** BotFather token. Onboarding stores it raw; a `$ENV`/`!cmd` reference also works. */
168
+ botToken: Type.String(),
169
+ /** Allowlist of chat ids. Empty/absent = allow all (logged as a warning). */
170
+ allowedChatIds: Type.Optional(Type.Array(Type.Number())),
171
+ /** Outbound formatting; `"plain"` disables parse mode. */
172
+ parseMode: Type.Optional(
173
+ Type.Union([Type.Literal("MarkdownV2"), Type.Literal("HTML"), Type.Literal("plain")]),
174
+ ),
175
+ }),
176
+ events: {
177
+ message: Type.Object({
178
+ chatId: Type.Number(),
179
+ messageId: Type.Number(),
180
+ text: Type.String(),
181
+ from: Type.Object({
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(),
188
+ }),
189
+ },
190
+ onboard,
191
+ actions: {
192
+ sendMessage: {
193
+ description: "Send a text message to a chat (chunked at 4096, with plain-text fallback).",
194
+ parameters: Type.Object({
195
+ chatId: Type.Number(),
196
+ text: Type.String(),
197
+ replyToMessageId: Type.Optional(Type.Number()),
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);
208
+
209
+ const messageIds: number[] = [];
210
+ // Reply-to applies only to the first chunk; later chunks just continue.
211
+ let replyTo = replyToMessageId;
212
+ for (const chunk of chunkText(text)) {
213
+ messageIds.push(await sendChunk(bot, chatId, chunk, parseMode, replyTo));
214
+ replyTo = undefined;
215
+ }
216
+ return { messageIds };
217
+ },
218
+ },
219
+ sendChatAction: {
220
+ description: "Show a chat action (e.g. the 'typing…' indicator).",
221
+ parameters: Type.Object({
222
+ chatId: Type.Number(),
223
+ action: Type.String(),
224
+ }),
225
+ execute: async (params, ctx) => {
226
+ const { chatId, action } = params as { chatId: number; action: string };
227
+ const account = ctx.account as TelegramAccount;
228
+ const bot = getBot(account.botToken);
229
+ // grammY types `action` as a union; the runtime accepts any valid string.
230
+ await bot.api.sendChatAction(chatId, action as "typing");
231
+ return { ok: true };
232
+ },
233
+ },
234
+ setCommands: {
235
+ description: "Register the bot's slash-command menu (BotFather command list).",
236
+ parameters: Type.Object({
237
+ commands: Type.Array(
238
+ Type.Object({
239
+ command: Type.String(),
240
+ description: Type.String(),
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
+ },
251
+ },
252
+ },
253
+ run(ctx) {
254
+ const account = ctx.account as TelegramAccount;
255
+ const { botToken, allowedChatIds } = account;
256
+ const allowAll = !allowedChatIds || allowedChatIds.length === 0;
257
+ if (allowAll) {
258
+ console.warn("[telegram] no allowedChatIds configured — accepting messages from ANY chat.");
259
+ }
260
+
261
+ const bot = getBot(botToken);
262
+
263
+ bot.on("message:text", (c) => {
264
+ // Ignore our own messages and anything outside the allowlist.
265
+ if (c.from?.id === c.me.id) return;
266
+ const chatId = c.chat.id;
267
+ if (!allowAll && !allowedChatIds?.includes(chatId)) return;
268
+
269
+ ctx.emit("message", {
270
+ chatId,
271
+ messageId: c.msg.message_id,
272
+ text: c.msg.text,
273
+ from: {
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
+ });
282
+
283
+ // Swallow producer-side errors so a transient poll failure can't crash the host.
284
+ bot.catch((err) => {
285
+ console.error("[telegram] bot error:", err.message);
286
+ });
287
+
288
+ // Fire-and-forget startup: drop any webhook + backlog, then start long polling.
289
+ // The runner never resolves, so we must NOT await it.
290
+ let runner: ReturnType<typeof run> | undefined;
291
+ void bot.api
292
+ .deleteWebhook({ drop_pending_updates: true })
293
+ .then(() => {
294
+ if (ctx.signal.aborted) return;
295
+ runner = run(bot);
296
+ })
297
+ .catch((err) => {
298
+ console.error("[telegram] failed to start long polling:", err instanceof Error ? err.message : err);
299
+ });
300
+
301
+ // Belt and suspenders: stop the runner on abort and via the returned disposer.
302
+ // The stop-before-start swap on reload relies on this to avoid Telegram's 409
303
+ // ("two pollers on one token") conflict.
304
+ const dispose = () => {
305
+ if (runner?.isRunning()) void runner.stop();
306
+ };
307
+ ctx.signal.addEventListener("abort", dispose);
308
+ return dispose;
309
+ },
310
+ });
311
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "wolli-integration-telegram",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "wolli": {
7
+ "integrations": ["./index.ts"],
8
+ "extensions": ["./telegram-chat.ts"]
9
+ },
10
+ "dependencies": {
11
+ "grammy": "1.44.0",
12
+ "@grammyjs/runner": "2.0.3"
13
+ },
14
+ "peerDependencies": {
15
+ "@opsyhq/wolli": "*"
16
+ }
17
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Telegram chat extension — the mapping half (paired with `index.ts`).
3
+ *
4
+ * The integration (`index.ts`) is the transport (long-poll, token, `message` events);
5
+ * this extension maps that transport onto a Wolli session:
6
+ *
7
+ * - inbound: `telegram.on("message")` routes the text into this chat's own session by
8
+ * `wolli.findSessions({ "telegram:chat": <id> })` → `openSession` the match (or
9
+ * `createSession` + tag a fresh one) → `session.sendUserMessage(text)`
10
+ * - outbound: `wolli.on("agent_end")` reads the PRODUCING session's tag off
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.
23
+ */
24
+
25
+ import type { AgentMessage } from "@opsyhq/agent";
26
+ import type { ExtensionAPI } from "@opsyhq/wolli";
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
+ ];
35
+
36
+ interface TelegramMessage {
37
+ chatId: number;
38
+ messageId: number;
39
+ text: string;
40
+ from: { id: number; username?: string; firstName?: string };
41
+ chatType: string;
42
+ date: number;
43
+ }
44
+
45
+ /** Concatenate the text blocks of the last assistant message; "" for a pure tool-call turn. */
46
+ function finalAssistantText(messages: AgentMessage[]): string {
47
+ for (let i = messages.length - 1; i >= 0; i--) {
48
+ const m = messages[i];
49
+ if (m.role !== "assistant") continue;
50
+ return m.content
51
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
52
+ .map((c) => c.text)
53
+ .join("")
54
+ .trim();
55
+ }
56
+ return "";
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);
113
+ return;
114
+ }
115
+
116
+ // Route into this chat's own session: rehydrate the tag-bound one, or create + tag a fresh one.
117
+ const chatTag = { "telegram:chat": String(m.chatId) };
118
+ const [match] = await wolli.findSessions(chatTag);
119
+ const session = match
120
+ ? await wolli.openSession(match.id)
121
+ : await wolli.createSession({
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;
137
+ }
138
+ };
139
+
140
+ wolli.on("agent_start", async (_event, ctx) => {
141
+ const chat = ctx.session.getTags()["telegram:chat"];
142
+ if (!chat) return; // not a telegram-bound session
143
+ const chatId = Number(chat);
144
+ const sendTyping = () => {
145
+ void tg.call("sendChatAction", { chatId, action: "typing" }).catch(() => {});
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;
158
+ }
159
+
160
+ // Reply rides the producing session's binding, so the answer returns to the chat that
161
+ // started this turn — not whoever messaged last.
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
+ });
169
+ }
package/index.js DELETED
@@ -1,2 +0,0 @@
1
- // wolli — placeholder. Real implementation coming soon.
2
- module.exports = {};