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.
Files changed (33) hide show
  1. package/README.md +23 -24
  2. package/built-in/plugins/discord/discord-chat.ts +47 -84
  3. package/built-in/plugins/discord/index.ts +139 -97
  4. package/built-in/plugins/discord/package.json +2 -2
  5. package/built-in/plugins/github/README.md +148 -0
  6. package/built-in/plugins/github/github-api.test.ts +260 -0
  7. package/built-in/plugins/github/github-api.ts +491 -0
  8. package/built-in/plugins/github/github-chat.ts +216 -0
  9. package/built-in/plugins/github/github-review.ts +102 -0
  10. package/built-in/plugins/github/github-workspace.ts +84 -0
  11. package/built-in/plugins/github/index.ts +795 -0
  12. package/built-in/plugins/github/package.json +14 -0
  13. package/built-in/plugins/scheduler/cron.ts +131 -0
  14. package/built-in/plugins/scheduler/index.ts +147 -151
  15. package/built-in/plugins/scheduler/package.json +3 -2
  16. package/built-in/plugins/scheduler/scheduler-due.ts +22 -0
  17. package/built-in/plugins/telegram/README.md +3 -3
  18. package/built-in/plugins/telegram/index.ts +177 -139
  19. package/built-in/plugins/telegram/package.json +2 -2
  20. package/built-in/plugins/telegram/telegram-chat.ts +79 -155
  21. package/dist/cli.js +6208 -6355
  22. package/docs/hooks.md +103 -0
  23. package/docs/index.md +10 -8
  24. package/docs/integrations.md +78 -663
  25. package/docs/introduction.md +95 -0
  26. package/docs/plugins.md +43 -36
  27. package/docs/providers.md +63 -0
  28. package/docs/sdk.md +42 -47
  29. package/docs/tools.md +81 -0
  30. package/docs/workflows.md +170 -0
  31. package/package.json +1 -1
  32. package/built-in/plugins/scheduler/scheduler-chat.ts +0 -164
  33. 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
- * 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.
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": { "default": { "botToken": "123456:ABC..." } } }
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 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";
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 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
- ),
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
- events: {
177
- message: Type.Object({
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
- 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(),
226
+ replyToMessageId: Type.Optional(Type.Number()),
188
227
  }),
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);
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
- 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
- },
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
- 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
- },
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
- 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
- },
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
- 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
- }
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
- const bot = getBot(botToken);
286
+ const bot = getBot(botToken);
262
287
 
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;
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
- 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
- });
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
- // 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);
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
- // 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);
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
- // 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
- }
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
  }
@@ -1,169 +1,93 @@
1
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.
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 { 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
- ];
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
- /** 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);
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
- // 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;
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
- 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;
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
- // 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
- });
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
+ });