wolli 0.0.1 → 0.0.3

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.
Files changed (34) hide show
  1. package/LICENSE +182 -0
  2. package/README.md +136 -2
  3. package/built-in/plugins/discord/README.md +156 -0
  4. package/built-in/plugins/discord/discord-chat.ts +87 -0
  5. package/built-in/plugins/discord/index.ts +192 -0
  6. package/built-in/plugins/discord/package.json +16 -0
  7. package/built-in/plugins/scheduler/index.ts +244 -0
  8. package/built-in/plugins/scheduler/package.json +16 -0
  9. package/built-in/plugins/scheduler/scheduler-chat.ts +164 -0
  10. package/built-in/plugins/telegram/README.md +153 -0
  11. package/built-in/plugins/telegram/index.ts +311 -0
  12. package/built-in/plugins/telegram/package.json +17 -0
  13. package/built-in/plugins/telegram/telegram-chat.ts +169 -0
  14. package/built-in/skills/.gitkeep +0 -0
  15. package/dist/cli.js +325758 -0
  16. package/dist/photon_rs_bg.wasm +0 -0
  17. package/dist/theme/dark.json +86 -0
  18. package/dist/theme/light.json +85 -0
  19. package/dist/theme/theme-schema.json +335 -0
  20. package/dist/theme/theme.ts +1237 -0
  21. package/docs/extensions.md +2331 -0
  22. package/docs/index.md +36 -0
  23. package/docs/integrations.md +715 -0
  24. package/docs/plugins.md +299 -0
  25. package/docs/prompt-templates.md +92 -0
  26. package/docs/sdk.md +760 -0
  27. package/docs/skills.md +220 -0
  28. package/docs/themes.md +274 -0
  29. package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  30. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  31. package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  32. package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  33. package/package.json +40 -9
  34. package/index.js +0 -2
@@ -0,0 +1,153 @@
1
+ # Telegram
2
+
3
+ Connect a wolli agent to Telegram. The bot runs over Telegram's long-polling API,
4
+ gives each chat its own wolli session, and replies in place. Setup is one message to
5
+ [@BotFather](https://t.me/BotFather) plus a single token prompt — no public URL or
6
+ TLS is required.
7
+
8
+ ## How the bot behaves
9
+
10
+ Before setup, here's the part most people want to know: this bot answers **every
11
+ text message it receives**. There is no `@mention` gate.
12
+
13
+ | Context | Behavior |
14
+ |---------|----------|
15
+ | **Private chats** | Responds to every text message. |
16
+ | **Group chats** | Responds to every text message it can see in any chat. No `@mention` required. Scope where it listens with `allowedChatIds`. |
17
+ | **Its own messages** | Ignored. The bot skips messages it sent itself, so it can't loop. |
18
+ | **Non-text messages** | Skipped. Photos, stickers, voice, and other media are not handled — only text messages reach the agent. |
19
+ | **Slash commands** | `/new`, `/status`, `/help` are handled locally and never sent to the model (see [Commands](#commands)). |
20
+
21
+ While a turn runs, the bot shows Telegram's **typing…** indicator and re-sends it
22
+ every ~4 seconds (Telegram clears the state on its own) until the reply lands.
23
+
24
+ Replies are chunked at **4096 characters** (Telegram's per-message limit) and each
25
+ chunk is sent with the configured **parse mode** (`MarkdownV2` by default). Telegram,
26
+ unlike Discord, needs a parse mode to render formatting — and if it rejects a chunk's
27
+ formatting, that chunk is **re-sent as plain text**, so a stray markdown character
28
+ never silently drops the reply.
29
+
30
+ If a new message arrives while the agent is mid-reply, it is **queued as a follow-up**
31
+ and answered after the current turn finishes — it does not interrupt the in-flight
32
+ response.
33
+
34
+ ## Commands
35
+
36
+ These commands are registered as the bot's command menu via BotFather on startup and
37
+ are handled by the integration directly, not forwarded to the model:
38
+
39
+ | Command | Action |
40
+ |---------|--------|
41
+ | `/new` | Start a fresh session for this chat. The new session becomes the active one; the previous session stays addressable but new messages route to the new one. |
42
+ | `/status` | Show the current session name and the model used in the last reply. |
43
+ | `/help` | List the available commands. |
44
+
45
+ Any other `/command` returns `Unknown command: /<name>. Try /help.`
46
+
47
+ ## Session model
48
+
49
+ Each chat gets its **own wolli session**, bound by a `telegram:chat` tag:
50
+
51
+ - **Inbound** — an incoming message is routed to the session tagged for its chat. If
52
+ none exists yet, one is created and tagged on the spot. Histories never bleed across
53
+ chats, and two chats can run in parallel.
54
+ - **Outbound** — the reply rides the producing session's tag, so the answer always
55
+ returns to the chat that started the turn — not to whoever messaged most recently.
56
+
57
+ `/new` creates a fresh session tagged for the chat; because it becomes the newest
58
+ match for that tag, subsequent messages route to it.
59
+
60
+ ## Setup
61
+
62
+ ### 1. Create a bot with BotFather
63
+
64
+ Open [@BotFather](https://t.me/BotFather) in Telegram and send `/newbot`. Pick a name
65
+ and a username; BotFather replies with a **bot token** like `123456:ABC-DEF...`. Copy
66
+ it — you'll paste it in the next step. Keep it secret.
67
+
68
+ ### 2. Install and onboard in wolli
69
+
70
+ ```bash
71
+ wolli <agent> plugins install ./built-in/plugins/telegram
72
+ ```
73
+
74
+ In an interactive terminal this runs onboarding immediately: it prints the BotFather
75
+ walkthrough, then prompts you to **paste the bot token**. Paste the token from step 1.
76
+ Wolli verifies it with a live `getMe()` call and stores it. That single token is the
77
+ only value you enter.
78
+
79
+ If you installed non-interactively, onboard later with:
80
+
81
+ ```bash
82
+ wolli <agent> plugins configure telegram
83
+ ```
84
+
85
+ ### 3. Restart the agent
86
+
87
+ ```bash
88
+ wolli restart <agent>
89
+ ```
90
+
91
+ This starts the long-poll producer that connects to Telegram. After onboarding a
92
+ fresh integration its producer starts only on the next daemon start, so restart the
93
+ agent once for the bot to come online and begin responding.
94
+
95
+ ## Configuration reference
96
+
97
+ Configuration lives per agent in `~/.wolli/agents/<name>/integrations.json` under
98
+ `telegram.default`:
99
+
100
+ ```json
101
+ {
102
+ "telegram": {
103
+ "default": {
104
+ "botToken": "123456:ABC-DEF...",
105
+ "allowedChatIds": [123456789],
106
+ "parseMode": "MarkdownV2"
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ | Field | Required | Default | Purpose |
113
+ |-------|----------|---------|---------|
114
+ | `botToken` | Yes | — | The BotFather token. Onboarding stores it raw; a `$ENV` / `!cmd` reference placed here by hand also resolves on read. |
115
+ | `allowedChatIds` | No | *(any chat)* | Allowlist of chat IDs the bot will respond in. Empty or absent accepts **any** chat (logged as a warning at startup). |
116
+ | `parseMode` | No | `MarkdownV2` | Outbound formatting: `"MarkdownV2"`, `"HTML"`, or `"plain"` (disables parse mode). |
117
+
118
+ Telegram chat IDs are **numbers** — keep them unquoted in JSON, unlike Discord's
119
+ quoted snowflakes.
120
+
121
+ `allowedChatIds` and `parseMode` are not asked during onboarding — edit
122
+ `integrations.json` to set them.
123
+
124
+ ## Not supported
125
+
126
+ The bot is deliberately focused on text chat. It does not provide:
127
+
128
+ - A durable cursor — on start it calls `deleteWebhook({ drop_pending_updates: true })`,
129
+ so a restart never replays a backlog but also drops messages sent while the bot was
130
+ offline.
131
+ - Inbound media or images — only text messages are handled.
132
+ - Webhook mode — the transport is long polling only.
133
+ - Callback queries or inline keyboards.
134
+ - Outbound rate-limit throttling.
135
+ - Mention-gating, or per-user / per-role allowlists — chat-level `allowedChatIds` is
136
+ the only scope control.
137
+
138
+ ## Troubleshooting
139
+
140
+ | Symptom | Fix |
141
+ |---------|-----|
142
+ | No reply right after the first install/onboard | Restart the agent (`wolli restart <agent>`) so the long-poll producer starts. |
143
+ | Bot ignores messages sent while it was offline | Expected — `deleteWebhook({ drop_pending_updates: true })` clears the backlog on start. The bot only sees messages that arrive while it is running. |
144
+ | Formatting looks broken, or a message seems to drop | A chunk that fails parse-mode parsing is re-sent as plain text. Set `parseMode: "plain"` to disable formatting entirely. |
145
+
146
+ ## Security
147
+
148
+ - `allowedChatIds` is the only access gate. With it empty, **any** chat is accepted —
149
+ set it to lock the bot to known chats.
150
+ - There is no per-user gate beyond that. Anyone who can post in an allowed chat can
151
+ drive the agent, so keep the bot in trusted chats.
152
+ - The bot token grants full control of the bot account — never commit it or share it.
153
+ Wolli writes `integrations.json` with mode `0600`.
@@ -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 ./built-in/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
+ }
File without changes