wispy-cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,13 +142,124 @@ Wispy remembers across sessions via `~/.wispy/memory/`:
142
142
  /memory references "API docs: https://..."
143
143
  ```
144
144
 
145
+ ## Multi-Channel Bot Support (v0.6.0)
146
+
147
+ Run Wispy as a bot on Telegram, Discord, and Slack — all at once or individually.
148
+
149
+ ### Quick Start
150
+
151
+ ```bash
152
+ # Set up a channel (interactive)
153
+ wispy channel setup telegram
154
+ wispy channel setup discord
155
+ wispy channel setup slack
156
+
157
+ # Start all configured channels
158
+ wispy --serve
159
+
160
+ # Or start a single channel
161
+ wispy --telegram
162
+ wispy --discord
163
+ wispy --slack
164
+
165
+ # List configured channels
166
+ wispy channel list
167
+
168
+ # Test a channel connection
169
+ wispy channel test telegram
170
+ ```
171
+
172
+ ### Telegram Setup
173
+
174
+ 1. Talk to [@BotFather](https://t.me/BotFather) on Telegram
175
+ 2. Send `/newbot`, follow prompts, copy the token
176
+ 3. Run `wispy channel setup telegram`
177
+
178
+ **Or** set the env var:
179
+ ```bash
180
+ export WISPY_TELEGRAM_TOKEN=your-token
181
+ wispy --telegram
182
+ ```
183
+
184
+ Bot commands: `/start`, `/clear`, `/model`, `/help`
185
+
186
+ ### Discord Setup
187
+
188
+ 1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
189
+ 2. Create an app → Bot → copy the token
190
+ 3. Enable **Message Content Intent** in Bot settings
191
+ 4. Invite bot to your server with `bot` + `applications.commands` scopes
192
+ 5. Run `wispy channel setup discord`
193
+
194
+ **Or** set the env var:
195
+ ```bash
196
+ export WISPY_DISCORD_TOKEN=your-token
197
+ wispy --discord
198
+ ```
199
+
200
+ Triggers: mention @Wispy, `!clear`, `!model`, `!help`, or DM the bot.
201
+
202
+ ### Slack Setup
203
+
204
+ 1. Go to [Slack API](https://api.slack.com/apps) → Create New App → From scratch
205
+ 2. Enable **Socket Mode** → copy App-Level Token (`xapp-...`)
206
+ 3. OAuth & Permissions → copy Bot User OAuth Token (`xoxb-...`)
207
+ 4. Subscribe to `app_mention` + `message.im` events
208
+ 5. Add `/wispy` slash command
209
+ 6. Run `wispy channel setup slack`
210
+
211
+ **Or** set env vars:
212
+ ```bash
213
+ export WISPY_SLACK_BOT_TOKEN=xoxb-...
214
+ export WISPY_SLACK_APP_TOKEN=xapp-...
215
+ wispy --slack
216
+ ```
217
+
218
+ Triggers: `@mention`, DMs, `/wispy <message>` slash command.
219
+
220
+ ### channels.json Config
221
+
222
+ Stored at `~/.wispy/channels.json`:
223
+ ```json
224
+ {
225
+ "telegram": { "enabled": true, "token": "..." },
226
+ "discord": { "enabled": true, "token": "..." },
227
+ "slack": { "enabled": true, "botToken": "...", "appToken": "..." }
228
+ }
229
+ ```
230
+
231
+ ### Optional Dependencies
232
+
233
+ Channel adapters use peer dependencies — install only what you need:
234
+
235
+ ```bash
236
+ npm install grammy # Telegram
237
+ npm install discord.js # Discord
238
+ npm install @slack/bolt # Slack
239
+ ```
240
+
241
+ Each adapter gracefully fails with a helpful install message if the package isn't present.
242
+
243
+ ### Per-Chat Session Isolation
244
+
245
+ Each chat/channel/DM gets its own conversation history stored in:
246
+ `~/.wispy/channel-sessions/<channel>/<chatId>.json`
247
+
248
+ No cross-contamination between users or channels.
249
+
145
250
  ## Architecture
146
251
 
147
252
  ```
148
- wispy (CLI REPL)
253
+ wispy (CLI REPL / TUI)
149
254
  ↕ Gemini/Claude/OpenAI API (tool calling)
150
255
  ↕ AWOS Server (auto-started, sandboxed tools)
151
256
  ↕ Local Node (macOS file/browser/app actions)
257
+
258
+ wispy --serve (Channel Bot Mode)
259
+ ↕ Telegram (grammY long polling)
260
+ ↕ Discord (discord.js gateway)
261
+ ↕ Slack (bolt socket mode)
262
+ ↕ Gemini/Claude/OpenAI API (per-chat sessions)
152
263
  ```
153
264
 
154
265
  ## Requirements
package/bin/wispy.mjs CHANGED
@@ -4,8 +4,15 @@
4
4
  * Wispy CLI entry point
5
5
  *
6
6
  * Flags:
7
- * --tui Launch Ink-based TUI mode
8
- * (default) Launch interactive REPL
7
+ * --tui Launch Ink-based TUI mode
8
+ * --serve Start all configured channel bots
9
+ * --telegram Start Telegram bot only
10
+ * --discord Start Discord bot only
11
+ * --slack Start Slack bot only
12
+ * channel setup <name> Interactive channel token setup
13
+ * channel list List configured channels
14
+ * channel test <name> Test channel connection
15
+ * (default) Launch interactive REPL
9
16
  */
10
17
 
11
18
  import { fileURLToPath } from "node:url";
@@ -13,17 +20,84 @@ import path from "node:path";
13
20
 
14
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
22
 
16
- // Check for --tui flag
17
23
  const args = process.argv.slice(2);
24
+
25
+ // ── channel sub-command ───────────────────────────────────────────────────────
26
+ if (args[0] === "channel") {
27
+ const { channelSetup, channelList, channelTest } = await import(
28
+ path.join(__dirname, "..", "lib", "channels", "index.mjs")
29
+ );
30
+
31
+ const sub = args[1];
32
+ const name = args[2];
33
+
34
+ if (sub === "setup" && name) {
35
+ await channelSetup(name);
36
+ } else if (sub === "list") {
37
+ await channelList();
38
+ } else if (sub === "test" && name) {
39
+ await channelTest(name);
40
+ } else {
41
+ console.log(`
42
+ 🌿 Wispy Channel Commands:
43
+
44
+ wispy channel setup telegram — interactive Telegram bot setup
45
+ wispy channel setup discord — interactive Discord bot setup
46
+ wispy channel setup slack — interactive Slack bot setup
47
+ wispy channel list — show configured channels
48
+ wispy channel test <name> — test a channel connection
49
+
50
+ wispy --serve — start all configured channel bots
51
+ wispy --telegram — start Telegram bot only
52
+ wispy --discord — start Discord bot only
53
+ wispy --slack — start Slack bot only
54
+ `);
55
+ }
56
+ process.exit(0);
57
+ }
58
+
59
+ // ── Bot / serve modes ─────────────────────────────────────────────────────────
60
+ const serveMode = args.includes("--serve");
61
+ const telegramMode = args.includes("--telegram");
62
+ const discordMode = args.includes("--discord");
63
+ const slackMode = args.includes("--slack");
64
+
65
+ if (serveMode || telegramMode || discordMode || slackMode) {
66
+ const { ChannelManager } = await import(
67
+ path.join(__dirname, "..", "lib", "channels", "index.mjs")
68
+ );
69
+
70
+ const manager = new ChannelManager();
71
+
72
+ const only = [];
73
+ if (telegramMode) only.push("telegram");
74
+ if (discordMode) only.push("discord");
75
+ if (slackMode) only.push("slack");
76
+ // serveMode → only stays empty → all channels started
77
+
78
+ await manager.startAll(only);
79
+
80
+ // Keep process alive and stop cleanly on Ctrl+C
81
+ process.on("SIGINT", async () => { await manager.stopAll(); process.exit(0); });
82
+ process.on("SIGTERM", async () => { await manager.stopAll(); process.exit(0); });
83
+
84
+ // Prevent Node from exiting (adapters keep their own event loops)
85
+ // but we still need something to hold the process open in case adapters
86
+ // don't (e.g. Telegram stops after connection error).
87
+ setInterval(() => {}, 60_000);
88
+ return;
89
+ }
90
+
91
+ // ── TUI mode ──────────────────────────────────────────────────────────────────
18
92
  const tuiMode = args.includes("--tui");
19
93
 
20
94
  if (tuiMode) {
21
- // Remove --tui from args so wispy-tui doesn't see it
22
95
  const newArgs = args.filter(a => a !== "--tui");
23
96
  process.argv = [process.argv[0], process.argv[1], ...newArgs];
24
97
  const tuiScript = path.join(__dirname, "..", "lib", "wispy-tui.mjs");
25
98
  await import(tuiScript);
26
99
  } else {
100
+ // Default: interactive REPL
27
101
  const mainScript = path.join(__dirname, "..", "lib", "wispy-repl.mjs");
28
102
  await import(mainScript);
29
103
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Base ChannelAdapter — abstract interface for all channel adapters
3
+ *
4
+ * Normalized message format:
5
+ * { chatId, userId, username, text, raw }
6
+ */
7
+
8
+ export class ChannelAdapter {
9
+ constructor(config = {}) {
10
+ this.config = config;
11
+ this._messageCallback = null;
12
+ }
13
+
14
+ /**
15
+ * Start the adapter (connect, begin polling, etc.)
16
+ * @returns {Promise<void>}
17
+ */
18
+ async start() {
19
+ throw new Error(`${this.constructor.name}.start() not implemented`);
20
+ }
21
+
22
+ /**
23
+ * Stop the adapter gracefully.
24
+ * @returns {Promise<void>}
25
+ */
26
+ async stop() {
27
+ throw new Error(`${this.constructor.name}.stop() not implemented`);
28
+ }
29
+
30
+ /**
31
+ * Send a message to a specific chat.
32
+ * @param {string} chatId
33
+ * @param {string} text
34
+ * @returns {Promise<void>}
35
+ */
36
+ async sendMessage(chatId, text) {
37
+ throw new Error(`${this.constructor.name}.sendMessage() not implemented`);
38
+ }
39
+
40
+ /**
41
+ * Register a callback invoked when a message arrives.
42
+ * @param {(msg: NormalizedMessage) => void} callback
43
+ */
44
+ onMessage(callback) {
45
+ this._messageCallback = callback;
46
+ }
47
+
48
+ /**
49
+ * Emit a normalized message to registered callback.
50
+ * @protected
51
+ */
52
+ _emit(chatId, userId, username, text, raw) {
53
+ if (this._messageCallback) {
54
+ this._messageCallback({ chatId, userId, username, text, raw });
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @typedef {Object} NormalizedMessage
61
+ * @property {string} chatId - Unique chat / channel / DM identifier
62
+ * @property {string} userId - Sender identifier
63
+ * @property {string} username - Human-readable sender name
64
+ * @property {string} text - Plain text content of the message
65
+ * @property {*} raw - Original platform event object
66
+ */
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Discord channel adapter — uses discord.js v14
3
+ *
4
+ * Token priority:
5
+ * 1. WISPY_DISCORD_TOKEN env var
6
+ * 2. ~/.wispy/channels.json → discord.token
7
+ *
8
+ * Trigger conditions:
9
+ * - Bot is mentioned (@Wispy ...)
10
+ * - Message starts with ! command prefix
11
+ * - DM to the bot
12
+ */
13
+
14
+ import { ChannelAdapter } from "./base.mjs";
15
+
16
+ export class DiscordAdapter extends ChannelAdapter {
17
+ constructor(config = {}) {
18
+ super(config);
19
+ this._client = null;
20
+ }
21
+
22
+ async start() {
23
+ let discord;
24
+ try {
25
+ discord = await import("discord.js");
26
+ } catch {
27
+ throw new Error(
28
+ 'discord.js is not installed. Run: npm install discord.js\n' +
29
+ '(or: npm install -g discord.js)'
30
+ );
31
+ }
32
+
33
+ const { Client, GatewayIntentBits, Partials, Events } = discord;
34
+
35
+ const token = process.env.WISPY_DISCORD_TOKEN ?? this.config.token;
36
+ if (!token) {
37
+ throw new Error(
38
+ 'Discord bot token not found.\n' +
39
+ 'Set WISPY_DISCORD_TOKEN or add "discord": {"token":"..."} to ~/.wispy/channels.json'
40
+ );
41
+ }
42
+
43
+ this._client = new Client({
44
+ intents: [
45
+ GatewayIntentBits.Guilds,
46
+ GatewayIntentBits.GuildMessages,
47
+ GatewayIntentBits.MessageContent,
48
+ GatewayIntentBits.DirectMessages,
49
+ ],
50
+ partials: [Partials.Channel, Partials.Message],
51
+ });
52
+
53
+ this._client.once(Events.ClientReady, (readyClient) => {
54
+ console.log(`🌿 Discord bot ready: ${readyClient.user.tag}`);
55
+ this._botUserId = readyClient.user.id;
56
+ });
57
+
58
+ this._client.on(Events.MessageCreate, async (message) => {
59
+ // Ignore own messages
60
+ if (message.author.bot) return;
61
+
62
+ const isDM = !message.guild;
63
+ const isMentioned = message.mentions.has(this._botUserId ?? "");
64
+ const isCommand = message.content.startsWith("!");
65
+
66
+ if (!isDM && !isMentioned && !isCommand) return;
67
+
68
+ const chatId = message.channel.id;
69
+ const userId = message.author.id;
70
+ const username = message.author.username;
71
+
72
+ // Strip mention from text
73
+ let text = message.content
74
+ .replace(/<@!?\d+>/g, "")
75
+ .trim();
76
+
77
+ // Handle ! commands
78
+ if (isCommand) {
79
+ const cmd = text.split(/\s+/)[0].toLowerCase();
80
+ if (cmd === "!clear") {
81
+ this._emit(chatId, userId, username, "__CLEAR__", message);
82
+ message.reply("🌿 Conversation cleared!");
83
+ return;
84
+ }
85
+ if (cmd === "!model") {
86
+ const model = process.env.WISPY_MODEL ?? "(auto)";
87
+ const provider = process.env.WISPY_PROVIDER ?? "(auto)";
88
+ message.reply(`🌿 Model: \`${model}\` | Provider: \`${provider}\``);
89
+ return;
90
+ }
91
+ if (cmd === "!help") {
92
+ message.reply(
93
+ "**🌿 Wispy Help**\n" +
94
+ "Mention me (@Wispy) or DM me to chat.\n\n" +
95
+ "Commands:\n" +
96
+ "`!clear` — reset this channel's conversation\n" +
97
+ "`!model` — show current AI model\n" +
98
+ "`!help` — this message"
99
+ );
100
+ return;
101
+ }
102
+ // Unknown ! command — pass through to AI
103
+ text = text.replace(/^!\S+\s*/, "");
104
+ if (!text) return;
105
+ }
106
+
107
+ if (!text) return;
108
+
109
+ this._emit(chatId, userId, username, text, message);
110
+ });
111
+
112
+ await this._client.login(token);
113
+ }
114
+
115
+ async stop() {
116
+ if (this._client) {
117
+ await this._client.destroy();
118
+ this._client = null;
119
+ }
120
+ }
121
+
122
+ async sendMessage(chatId, text) {
123
+ if (!this._client) throw new Error("Discord client not started");
124
+
125
+ const channel = await this._client.channels.fetch(chatId).catch(() => null);
126
+ if (!channel) throw new Error(`Discord channel not found: ${chatId}`);
127
+
128
+ // Discord limit is 2000 chars
129
+ const chunks = splitText(text, 1990);
130
+ for (const chunk of chunks) {
131
+ await channel.send(chunk);
132
+ }
133
+ }
134
+ }
135
+
136
+ /** Split text into chunks ≤ maxLen characters */
137
+ function splitText(text, maxLen) {
138
+ if (text.length <= maxLen) return [text];
139
+ const chunks = [];
140
+ for (let i = 0; i < text.length; i += maxLen) {
141
+ chunks.push(text.slice(i, i + maxLen));
142
+ }
143
+ return chunks;
144
+ }
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Channel Manager — loads config, starts/stops adapters, routes messages
3
+ *
4
+ * Config: ~/.wispy/channels.json
5
+ * {
6
+ * "telegram": { "enabled": true, "token": "..." },
7
+ * "discord": { "enabled": true, "token": "..." },
8
+ * "slack": { "enabled": true, "botToken": "...", "appToken": "..." }
9
+ * }
10
+ *
11
+ * Per-chat conversation history is stored in ~/.wispy/channel-sessions/<channel>/<chatId>.json
12
+ */
13
+
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
17
+
18
+ // ── Paths ─────────────────────────────────────────────────────────────────────
19
+
20
+ const WISPY_DIR = path.join(os.homedir(), ".wispy");
21
+ const CHANNELS_CONFIG = path.join(WISPY_DIR, "channels.json");
22
+ const SESSIONS_DIR = path.join(WISPY_DIR, "channel-sessions");
23
+
24
+ // ── Config helpers ────────────────────────────────────────────────────────────
25
+
26
+ export async function loadChannelsConfig() {
27
+ try {
28
+ const raw = await readFile(CHANNELS_CONFIG, "utf8");
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ export async function saveChannelsConfig(cfg) {
36
+ await mkdir(WISPY_DIR, { recursive: true });
37
+ await writeFile(CHANNELS_CONFIG, JSON.stringify(cfg, null, 2) + "\n", "utf8");
38
+ }
39
+
40
+ // ── Per-chat session storage ──────────────────────────────────────────────────
41
+
42
+ async function sessionPath(channelName, chatId) {
43
+ const dir = path.join(SESSIONS_DIR, channelName);
44
+ await mkdir(dir, { recursive: true });
45
+ // Sanitize chatId to be filename-safe
46
+ const safe = String(chatId).replace(/[^a-zA-Z0-9_\-]/g, "_");
47
+ return path.join(dir, `${safe}.json`);
48
+ }
49
+
50
+ async function loadSession(channelName, chatId) {
51
+ try {
52
+ const raw = await readFile(await sessionPath(channelName, chatId), "utf8");
53
+ return JSON.parse(raw);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ async function saveSession(channelName, chatId, messages) {
60
+ const p = await sessionPath(channelName, chatId);
61
+ const trimmed = messages.slice(-50);
62
+ await writeFile(p, JSON.stringify(trimmed, null, 2) + "\n", "utf8");
63
+ }
64
+
65
+ async function clearSession(channelName, chatId) {
66
+ await saveSession(channelName, chatId, []);
67
+ }
68
+
69
+ // ── AI engine (minimal inline, reuses provider detection from wispy-repl logic)─
70
+
71
+ import { createInterface } from "node:readline";
72
+ import { appendFile } from "node:fs/promises";
73
+
74
+ const PROVIDERS = {
75
+ google: { envKeys: ["GOOGLE_AI_KEY", "GEMINI_API_KEY"], defaultModel: "gemini-2.5-flash" },
76
+ anthropic: { envKeys: ["ANTHROPIC_API_KEY"], defaultModel: "claude-sonnet-4-20250514" },
77
+ openai: { envKeys: ["OPENAI_API_KEY"], defaultModel: "gpt-4o" },
78
+ openrouter: { envKeys: ["OPENROUTER_API_KEY"], defaultModel: "anthropic/claude-sonnet-4-20250514" },
79
+ groq: { envKeys: ["GROQ_API_KEY"], defaultModel: "llama-3.3-70b-versatile" },
80
+ deepseek: { envKeys: ["DEEPSEEK_API_KEY"], defaultModel: "deepseek-chat" },
81
+ ollama: { envKeys: ["OLLAMA_HOST"], defaultModel: "llama3.2", local: true },
82
+ };
83
+
84
+ function getEnvKey(envKeys) {
85
+ for (const k of envKeys) { if (process.env[k]) return process.env[k]; }
86
+ return null;
87
+ }
88
+
89
+ async function tryKeychainKey(service) {
90
+ try {
91
+ const { execFile } = await import("node:child_process");
92
+ const { promisify } = await import("node:util");
93
+ const exec = promisify(execFile);
94
+ const { stdout } = await exec("security", ["find-generic-password", "-s", service, "-a", "poropo", "-w"], { timeout: 3000 });
95
+ return stdout.trim() || null;
96
+ } catch { return null; }
97
+ }
98
+
99
+ let _detectedProvider = null;
100
+
101
+ async function getProvider() {
102
+ if (_detectedProvider) return _detectedProvider;
103
+
104
+ // WISPY_PROVIDER override
105
+ const forced = process.env.WISPY_PROVIDER;
106
+ if (forced && PROVIDERS[forced]) {
107
+ const key = getEnvKey(PROVIDERS[forced].envKeys);
108
+ if (key || PROVIDERS[forced].local) {
109
+ _detectedProvider = { provider: forced, key, model: process.env.WISPY_MODEL ?? PROVIDERS[forced].defaultModel };
110
+ return _detectedProvider;
111
+ }
112
+ }
113
+
114
+ // Config file
115
+ try {
116
+ const cfg = JSON.parse(await readFile(path.join(WISPY_DIR, "config.json"), "utf8"));
117
+ if (cfg.provider && PROVIDERS[cfg.provider]) {
118
+ const key = getEnvKey(PROVIDERS[cfg.provider].envKeys) ?? cfg.apiKey;
119
+ if (key || PROVIDERS[cfg.provider].local) {
120
+ _detectedProvider = { provider: cfg.provider, key, model: cfg.model ?? PROVIDERS[cfg.provider].defaultModel };
121
+ return _detectedProvider;
122
+ }
123
+ }
124
+ } catch {}
125
+
126
+ // Auto-detect env
127
+ for (const p of ["google","anthropic","openai","openrouter","groq","deepseek","ollama"]) {
128
+ const key = getEnvKey(PROVIDERS[p].envKeys);
129
+ if (key || (p === "ollama" && process.env.OLLAMA_HOST)) {
130
+ _detectedProvider = { provider: p, key, model: process.env.WISPY_MODEL ?? PROVIDERS[p].defaultModel };
131
+ return _detectedProvider;
132
+ }
133
+ }
134
+
135
+ // macOS Keychain
136
+ for (const [service, provider] of [["google-ai-key","google"],["anthropic-api-key","anthropic"],["openai-api-key","openai"]]) {
137
+ const key = await tryKeychainKey(service);
138
+ if (key) {
139
+ process.env[PROVIDERS[provider].envKeys[0]] = key;
140
+ _detectedProvider = { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
141
+ return _detectedProvider;
142
+ }
143
+ }
144
+
145
+ return null;
146
+ }
147
+
148
+ const OPENAI_COMPAT = {
149
+ openai: "https://api.openai.com/v1/chat/completions",
150
+ openrouter: "https://openrouter.ai/api/v1/chat/completions",
151
+ groq: "https://api.groq.com/openai/v1/chat/completions",
152
+ deepseek: "https://api.deepseek.com/v1/chat/completions",
153
+ ollama: `${process.env.OLLAMA_HOST ?? "http://localhost:11434"}/v1/chat/completions`,
154
+ };
155
+
156
+ async function callLLM(messages, providerInfo) {
157
+ const { provider, key, model } = providerInfo;
158
+
159
+ if (provider === "google") {
160
+ return callGemini(messages, key, model);
161
+ }
162
+ if (provider === "anthropic") {
163
+ return callAnthropic(messages, key, model);
164
+ }
165
+ return callOpenAICompat(messages, key, model, provider);
166
+ }
167
+
168
+ async function callGemini(messages, key, model) {
169
+ const systemInstruction = messages.find(m => m.role === "system")?.content ?? "";
170
+ const contents = messages
171
+ .filter(m => m.role !== "system")
172
+ .map(m => ({
173
+ role: m.role === "assistant" ? "model" : "user",
174
+ parts: [{ text: m.content }],
175
+ }));
176
+
177
+ const resp = await fetch(
178
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
179
+ {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/json" },
182
+ body: JSON.stringify({
183
+ system_instruction: systemInstruction ? { parts: [{ text: systemInstruction }] } : undefined,
184
+ contents,
185
+ generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
186
+ }),
187
+ }
188
+ );
189
+ if (!resp.ok) throw new Error(`Gemini API error ${resp.status}: ${await resp.text().then(t => t.slice(0,200))}`);
190
+ const data = await resp.json();
191
+ return data.candidates?.[0]?.content?.parts?.map(p => p.text ?? "").join("") ?? "";
192
+ }
193
+
194
+ async function callAnthropic(messages, key, model) {
195
+ const systemPrompt = messages.find(m => m.role === "system")?.content ?? "";
196
+ const anthropicMessages = messages.filter(m => m.role !== "system").map(m => ({
197
+ role: m.role === "assistant" ? "assistant" : "user",
198
+ content: m.content,
199
+ }));
200
+
201
+ const resp = await fetch("https://api.anthropic.com/v1/messages", {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
204
+ body: JSON.stringify({ model, max_tokens: 4096, system: systemPrompt, messages: anthropicMessages }),
205
+ });
206
+ if (!resp.ok) throw new Error(`Anthropic API error ${resp.status}: ${await resp.text().then(t => t.slice(0,200))}`);
207
+ const data = await resp.json();
208
+ return data.content?.map(c => c.text ?? "").join("") ?? "";
209
+ }
210
+
211
+ async function callOpenAICompat(messages, key, model, provider) {
212
+ const endpoint = OPENAI_COMPAT[provider] ?? OPENAI_COMPAT.openai;
213
+ const headers = { "Content-Type": "application/json" };
214
+ if (key) headers["Authorization"] = `Bearer ${key}`;
215
+ if (provider === "openrouter") headers["HTTP-Referer"] = "https://wispy.dev";
216
+
217
+ const oaiMessages = messages.filter(m => m.role !== "tool_result").map(m => ({
218
+ role: m.role === "assistant" ? "assistant" : m.role,
219
+ content: m.content,
220
+ }));
221
+
222
+ const resp = await fetch(endpoint, {
223
+ method: "POST",
224
+ headers,
225
+ body: JSON.stringify({ model, messages: oaiMessages, temperature: 0.7, max_tokens: 4096 }),
226
+ });
227
+ if (!resp.ok) throw new Error(`OpenAI-compat API error ${resp.status}: ${await resp.text().then(t => t.slice(0,200))}`);
228
+ const data = await resp.json();
229
+ return data.choices?.[0]?.message?.content ?? "";
230
+ }
231
+
232
+ const SYSTEM_PROMPT = `You are Wispy 🌿 — a small ghost AI assistant living in chat platforms.
233
+ You're helpful, concise, and friendly. You can answer questions, write code, help with tasks.
234
+ Keep responses concise and conversational. End each message with 🌿.
235
+ Respond in the same language the user writes in.`;
236
+
237
+ /**
238
+ * Process a user message and return Wispy's response.
239
+ * Maintains per-chat conversation history.
240
+ */
241
+ export async function processUserMessage(channelName, chatId, userText) {
242
+ // Handle __CLEAR__ signal
243
+ if (userText === "__CLEAR__") {
244
+ await clearSession(channelName, chatId);
245
+ return null; // Caller should send confirmation
246
+ }
247
+
248
+ const providerInfo = await getProvider();
249
+ if (!providerInfo) {
250
+ return "⚠️ No AI provider configured. Run `wispy` first to set up your API key. 🌿";
251
+ }
252
+
253
+ // Load conversation history
254
+ const history = await loadSession(channelName, chatId);
255
+
256
+ // Build messages array
257
+ const messages = [
258
+ { role: "system", content: SYSTEM_PROMPT },
259
+ ...history,
260
+ { role: "user", content: userText },
261
+ ];
262
+
263
+ let response;
264
+ try {
265
+ response = await callLLM(messages, providerInfo);
266
+ } catch (err) {
267
+ return `❌ Error: ${err.message.slice(0, 200)} 🌿`;
268
+ }
269
+
270
+ // Save history (keep last 40 turns)
271
+ const updatedHistory = [
272
+ ...history,
273
+ { role: "user", content: userText },
274
+ { role: "assistant", content: response },
275
+ ].slice(-40);
276
+ await saveSession(channelName, chatId, updatedHistory);
277
+
278
+ return response;
279
+ }
280
+
281
+ // ── ChannelManager ────────────────────────────────────────────────────────────
282
+
283
+ export class ChannelManager {
284
+ constructor() {
285
+ this._adapters = new Map(); // name → { adapter, config }
286
+ }
287
+
288
+ /**
289
+ * Load config, instantiate and start enabled adapters.
290
+ * @param {string[]} [only] - optional filter to only start specific channels
291
+ */
292
+ async startAll(only = []) {
293
+ const cfg = await loadChannelsConfig();
294
+
295
+ const entries = [
296
+ { name: "telegram", AdapterClass: () => import("./telegram.mjs").then(m => m.TelegramAdapter) },
297
+ { name: "discord", AdapterClass: () => import("./discord.mjs").then(m => m.DiscordAdapter) },
298
+ { name: "slack", AdapterClass: () => import("./slack.mjs").then(m => m.SlackAdapter) },
299
+ ];
300
+
301
+ for (const { name, AdapterClass } of entries) {
302
+ if (only.length > 0 && !only.includes(name)) continue;
303
+
304
+ const channelCfg = cfg[name] ?? {};
305
+ if (channelCfg.enabled === false) {
306
+ console.log(`⏭ ${name}: disabled in config`);
307
+ continue;
308
+ }
309
+
310
+ // Check if at least one token is present
311
+ const hasToken = this._hasToken(name, channelCfg);
312
+ if (!hasToken) {
313
+ console.log(`⚠ ${name}: no token configured — skipping (run \`wispy channel setup ${name}\`)`);
314
+ continue;
315
+ }
316
+
317
+ try {
318
+ const Cls = await AdapterClass();
319
+ const adapter = new Cls(channelCfg);
320
+
321
+ adapter.onMessage(async ({ chatId, userId, username, text }) => {
322
+ console.log(`[${name}] ${username}: ${text.slice(0, 80)}`);
323
+ try {
324
+ const reply = await processUserMessage(name, chatId, text);
325
+ if (reply) {
326
+ await adapter.sendMessage(chatId, reply);
327
+ }
328
+ } catch (err) {
329
+ console.error(`[${name}] Error processing message:`, err.message);
330
+ try {
331
+ await adapter.sendMessage(chatId, `❌ Error: ${err.message.slice(0, 200)} 🌿`);
332
+ } catch {}
333
+ }
334
+ });
335
+
336
+ await adapter.start();
337
+ this._adapters.set(name, { adapter, config: channelCfg });
338
+ console.log(`✅ ${name}: started`);
339
+ } catch (err) {
340
+ console.error(`❌ ${name}: failed to start — ${err.message}`);
341
+ }
342
+ }
343
+
344
+ if (this._adapters.size === 0) {
345
+ console.log("\n🌿 No channels started. Configure channels with:\n");
346
+ console.log(" wispy channel setup telegram");
347
+ console.log(" wispy channel setup discord");
348
+ console.log(" wispy channel setup slack\n");
349
+ } else {
350
+ console.log(`\n🌿 ${this._adapters.size} channel(s) running. Press Ctrl+C to stop.\n`);
351
+ }
352
+ }
353
+
354
+ async stopAll() {
355
+ for (const [name, { adapter }] of this._adapters) {
356
+ try {
357
+ await adapter.stop();
358
+ console.log(`🛑 ${name}: stopped`);
359
+ } catch (err) {
360
+ console.error(`⚠ ${name}: stop error — ${err.message}`);
361
+ }
362
+ }
363
+ this._adapters.clear();
364
+ }
365
+
366
+ _hasToken(name, cfg) {
367
+ if (name === "telegram") return !!(process.env.WISPY_TELEGRAM_TOKEN ?? cfg.token);
368
+ if (name === "discord") return !!(process.env.WISPY_DISCORD_TOKEN ?? cfg.token);
369
+ if (name === "slack") return !!(
370
+ (process.env.WISPY_SLACK_BOT_TOKEN ?? cfg.botToken) &&
371
+ (process.env.WISPY_SLACK_APP_TOKEN ?? cfg.appToken)
372
+ );
373
+ return false;
374
+ }
375
+ }
376
+
377
+ // ── Channel setup CLI helpers ─────────────────────────────────────────────────
378
+
379
+ export async function channelSetup(channelName) {
380
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
381
+ const ask = (q) => new Promise(r => rl.question(q, r));
382
+
383
+ const cfg = await loadChannelsConfig();
384
+
385
+ console.log(`\n🌿 Setting up ${channelName} channel\n`);
386
+
387
+ if (channelName === "telegram") {
388
+ console.log("1. Go to https://t.me/BotFather");
389
+ console.log("2. Send /newbot and follow instructions");
390
+ console.log("3. Copy the bot token\n");
391
+ const token = (await ask(" Paste bot token: ")).trim();
392
+ if (!token) { rl.close(); return; }
393
+ cfg.telegram = { ...(cfg.telegram ?? {}), enabled: true, token };
394
+ await saveChannelsConfig(cfg);
395
+ console.log("\n✅ Telegram token saved! Run: wispy --telegram");
396
+ }
397
+
398
+ else if (channelName === "discord") {
399
+ console.log("1. Go to https://discord.com/developers/applications");
400
+ console.log("2. Create an app → Bot → Copy token");
401
+ console.log("3. Enable Message Content Intent in Bot settings\n");
402
+ const token = (await ask(" Paste bot token: ")).trim();
403
+ if (!token) { rl.close(); return; }
404
+ cfg.discord = { ...(cfg.discord ?? {}), enabled: true, token };
405
+ await saveChannelsConfig(cfg);
406
+ console.log("\n✅ Discord token saved! Run: wispy --discord");
407
+ }
408
+
409
+ else if (channelName === "slack") {
410
+ console.log("1. Go to https://api.slack.com/apps → Create New App → From scratch");
411
+ console.log("2. Enable Socket Mode → copy App-Level Token (starts with xapp-)");
412
+ console.log("3. OAuth & Permissions → copy Bot User OAuth Token (starts with xoxb-)");
413
+ console.log("4. Subscribe to: app_mention, message.im events");
414
+ console.log("5. Add /wispy slash command\n");
415
+ const botToken = (await ask(" Paste bot token (xoxb-...): ")).trim();
416
+ const appToken = (await ask(" Paste app token (xapp-...): ")).trim();
417
+ if (!botToken || !appToken) { rl.close(); return; }
418
+ cfg.slack = { ...(cfg.slack ?? {}), enabled: true, botToken, appToken };
419
+ await saveChannelsConfig(cfg);
420
+ console.log("\n✅ Slack tokens saved! Run: wispy --slack");
421
+ }
422
+
423
+ else {
424
+ console.log(`Unknown channel: ${channelName}. Use: telegram, discord, slack`);
425
+ }
426
+
427
+ rl.close();
428
+ }
429
+
430
+ export async function channelList() {
431
+ const cfg = await loadChannelsConfig();
432
+ const channels = ["telegram", "discord", "slack"];
433
+
434
+ console.log("\n🌿 Configured channels:\n");
435
+ for (const name of channels) {
436
+ const c = cfg[name];
437
+ if (!c) {
438
+ console.log(` ○ ${name.padEnd(10)} not configured`);
439
+ continue;
440
+ }
441
+ const enabled = c.enabled !== false;
442
+ const hasToken = name === "slack"
443
+ ? !!(c.botToken && c.appToken)
444
+ : !!c.token;
445
+ const status = !hasToken ? "no token" : enabled ? "✅ enabled" : "disabled";
446
+ console.log(` ${enabled && hasToken ? "●" : "○"} ${name.padEnd(10)} ${status}`);
447
+ }
448
+ console.log(`\nConfig file: ${CHANNELS_CONFIG}\n`);
449
+ }
450
+
451
+ export async function channelTest(channelName) {
452
+ const cfg = await loadChannelsConfig();
453
+ const channelCfg = cfg[channelName] ?? {};
454
+
455
+ console.log(`\n🌿 Testing ${channelName} connection...\n`);
456
+
457
+ if (channelName === "telegram") {
458
+ const token = process.env.WISPY_TELEGRAM_TOKEN ?? channelCfg.token;
459
+ if (!token) { console.log("❌ No token configured"); return; }
460
+ try {
461
+ const { Bot } = await import("grammy").catch(() => { throw new Error("grammy not installed — run: npm install grammy"); });
462
+ const bot = new Bot(token);
463
+ const me = await bot.api.getMe();
464
+ console.log(`✅ Connected as @${me.username} (${me.first_name})`);
465
+ await bot.stop().catch(() => {});
466
+ } catch (err) {
467
+ console.log(`❌ ${err.message}`);
468
+ }
469
+ }
470
+
471
+ else if (channelName === "discord") {
472
+ const token = process.env.WISPY_DISCORD_TOKEN ?? channelCfg.token;
473
+ if (!token) { console.log("❌ No token configured"); return; }
474
+ try {
475
+ const { Client, GatewayIntentBits } = await import("discord.js").catch(() => { throw new Error("discord.js not installed — run: npm install discord.js"); });
476
+ const client = new Client({ intents: [GatewayIntentBits.Guilds] });
477
+ await client.login(token);
478
+ console.log(`✅ Connected as ${client.user?.tag}`);
479
+ await client.destroy();
480
+ } catch (err) {
481
+ console.log(`❌ ${err.message}`);
482
+ }
483
+ }
484
+
485
+ else if (channelName === "slack") {
486
+ const botToken = process.env.WISPY_SLACK_BOT_TOKEN ?? channelCfg.botToken;
487
+ if (!botToken) { console.log("❌ No bot token configured"); return; }
488
+ try {
489
+ const resp = await fetch("https://slack.com/api/auth.test", {
490
+ headers: { Authorization: `Bearer ${botToken}` },
491
+ });
492
+ const data = await resp.json();
493
+ if (data.ok) {
494
+ console.log(`✅ Connected as @${data.user} on team ${data.team}`);
495
+ } else {
496
+ console.log(`❌ Slack error: ${data.error}`);
497
+ }
498
+ } catch (err) {
499
+ console.log(`❌ ${err.message}`);
500
+ }
501
+ }
502
+
503
+ else {
504
+ console.log(`Unknown channel: ${channelName}`);
505
+ }
506
+ console.log("");
507
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Slack channel adapter — uses @slack/bolt (socket mode)
3
+ *
4
+ * Token priority:
5
+ * 1. WISPY_SLACK_BOT_TOKEN + WISPY_SLACK_APP_TOKEN env vars
6
+ * 2. ~/.wispy/channels.json → slack.botToken / slack.appToken
7
+ *
8
+ * Triggers:
9
+ * - app_mention in channels
10
+ * - Direct messages to the bot
11
+ * - /wispy slash command
12
+ */
13
+
14
+ import { ChannelAdapter } from "./base.mjs";
15
+
16
+ export class SlackAdapter extends ChannelAdapter {
17
+ constructor(config = {}) {
18
+ super(config);
19
+ this._app = null;
20
+ }
21
+
22
+ async start() {
23
+ let bolt;
24
+ try {
25
+ bolt = await import("@slack/bolt");
26
+ } catch {
27
+ throw new Error(
28
+ '@slack/bolt is not installed. Run: npm install @slack/bolt\n' +
29
+ '(or: npm install -g @slack/bolt)'
30
+ );
31
+ }
32
+
33
+ const { App } = bolt;
34
+
35
+ const botToken = process.env.WISPY_SLACK_BOT_TOKEN ?? this.config.botToken;
36
+ const appToken = process.env.WISPY_SLACK_APP_TOKEN ?? this.config.appToken;
37
+
38
+ if (!botToken || !appToken) {
39
+ throw new Error(
40
+ 'Slack tokens not found.\n' +
41
+ 'Set WISPY_SLACK_BOT_TOKEN and WISPY_SLACK_APP_TOKEN,\n' +
42
+ 'or add them under "slack" in ~/.wispy/channels.json'
43
+ );
44
+ }
45
+
46
+ this._app = new App({
47
+ token: botToken,
48
+ appToken,
49
+ socketMode: true,
50
+ });
51
+
52
+ // ── app_mention ───────────────────────────────────────────────────────────
53
+ this._app.event("app_mention", async ({ event, say }) => {
54
+ const chatId = event.channel;
55
+ const userId = event.user;
56
+ const username = event.user; // Slack doesn't provide username in event directly
57
+ const text = stripMention(event.text ?? "");
58
+
59
+ if (!text) {
60
+ await say("🌿 What can I help you with?");
61
+ return;
62
+ }
63
+
64
+ // Store say() so sendMessage can reply in-thread
65
+ this._sayers = this._sayers ?? new Map();
66
+ this._sayers.set(chatId, say);
67
+
68
+ this._emit(chatId, userId, username, text, event);
69
+ });
70
+
71
+ // ── Direct messages ───────────────────────────────────────────────────────
72
+ this._app.message(async ({ message, say }) => {
73
+ // Only handle DMs (channel_type === "im") and bot messages
74
+ if (message.subtype) return; // ignore message subtypes (edits, etc.)
75
+ if (!message.channel_type || message.channel_type !== "im") return;
76
+
77
+ const chatId = message.channel;
78
+ const userId = message.user ?? "";
79
+ const username = message.user ?? "user";
80
+ const text = (message.text ?? "").trim();
81
+
82
+ if (!text) return;
83
+
84
+ this._sayers = this._sayers ?? new Map();
85
+ this._sayers.set(chatId, say);
86
+
87
+ this._emit(chatId, userId, username, text, message);
88
+ });
89
+
90
+ // ── /wispy slash command ──────────────────────────────────────────────────
91
+ this._app.command("/wispy", async ({ command, ack, say }) => {
92
+ await ack();
93
+
94
+ const chatId = command.channel_id;
95
+ const userId = command.user_id;
96
+ const username = command.user_name;
97
+ const text = (command.text ?? "").trim();
98
+
99
+ if (!text) {
100
+ await say("🌿 Usage: `/wispy <your message>`");
101
+ return;
102
+ }
103
+
104
+ // Handle built-in slash subcommands
105
+ if (text === "clear") {
106
+ this._emit(chatId, userId, username, "__CLEAR__", command);
107
+ await say("🌿 Conversation cleared!");
108
+ return;
109
+ }
110
+ if (text === "model") {
111
+ const model = process.env.WISPY_MODEL ?? "(auto)";
112
+ await say(`🌿 Model: \`${model}\``);
113
+ return;
114
+ }
115
+ if (text === "help") {
116
+ await say(
117
+ "🌿 *Wispy Help*\n" +
118
+ "• Mention @Wispy in a channel to chat\n" +
119
+ "• DM Wispy directly\n" +
120
+ "• Use `/wispy <message>` anywhere\n\n" +
121
+ "Commands:\n" +
122
+ "`/wispy clear` — reset conversation\n" +
123
+ "`/wispy model` — show current AI model\n" +
124
+ "`/wispy help` — this message"
125
+ );
126
+ return;
127
+ }
128
+
129
+ this._sayers = this._sayers ?? new Map();
130
+ this._sayers.set(chatId, say);
131
+
132
+ this._emit(chatId, userId, username, text, command);
133
+ });
134
+
135
+ await this._app.start();
136
+ console.log("🌿 Slack bot started (socket mode)");
137
+ }
138
+
139
+ async stop() {
140
+ if (this._app) {
141
+ await this._app.stop();
142
+ this._app = null;
143
+ }
144
+ }
145
+
146
+ async sendMessage(chatId, text) {
147
+ if (!this._app) throw new Error("Slack app not started");
148
+
149
+ // Use stored say() if available (preserves thread context)
150
+ const say = this._sayers?.get(chatId);
151
+ if (say) {
152
+ // Slack has a 3001 char limit for blocks; use chat.postMessage for long text
153
+ const chunks = splitText(text, 3000);
154
+ for (const chunk of chunks) {
155
+ await say(chunk);
156
+ }
157
+ return;
158
+ }
159
+
160
+ // Fallback: use web client
161
+ const chunks = splitText(text, 3000);
162
+ for (const chunk of chunks) {
163
+ await this._app.client.chat.postMessage({ channel: chatId, text: chunk });
164
+ }
165
+ }
166
+ }
167
+
168
+ /** Strip leading Slack mention (<@UXXXXX>) from text */
169
+ function stripMention(text) {
170
+ return text.replace(/^<@[A-Z0-9]+>\s*/i, "").trim();
171
+ }
172
+
173
+ /** Split text into chunks ≤ maxLen characters */
174
+ function splitText(text, maxLen) {
175
+ if (text.length <= maxLen) return [text];
176
+ const chunks = [];
177
+ for (let i = 0; i < text.length; i += maxLen) {
178
+ chunks.push(text.slice(i, i + maxLen));
179
+ }
180
+ return chunks;
181
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Telegram channel adapter — uses grammY
3
+ *
4
+ * Token priority:
5
+ * 1. WISPY_TELEGRAM_TOKEN env var
6
+ * 2. ~/.wispy/channels.json → telegram.token
7
+ */
8
+
9
+ import { ChannelAdapter } from "./base.mjs";
10
+
11
+ export class TelegramAdapter extends ChannelAdapter {
12
+ constructor(config = {}) {
13
+ super(config);
14
+ this._bot = null;
15
+ }
16
+
17
+ async start() {
18
+ let grammy;
19
+ try {
20
+ grammy = await import("grammy");
21
+ } catch {
22
+ throw new Error(
23
+ 'grammy is not installed. Run: npm install grammy\n' +
24
+ '(or: npm install -g grammy)'
25
+ );
26
+ }
27
+
28
+ const { Bot, InputFile } = grammy;
29
+ const token = process.env.WISPY_TELEGRAM_TOKEN ?? this.config.token;
30
+ if (!token) {
31
+ throw new Error(
32
+ 'Telegram bot token not found.\n' +
33
+ 'Set WISPY_TELEGRAM_TOKEN or add "telegram": {"token":"..."} to ~/.wispy/channels.json'
34
+ );
35
+ }
36
+
37
+ this._bot = new Bot(token);
38
+
39
+ // ── Commands ─────────────────────────────────────────────────────────────
40
+ this._bot.command("start", (ctx) => {
41
+ ctx.reply(
42
+ "🌿 *Wispy* — AI workspace assistant\n\n" +
43
+ "Just send me a message and I'll help you out\\.\n\n" +
44
+ "Commands:\n" +
45
+ "/clear — reset this chat's conversation\n" +
46
+ "/model — show current AI model\n" +
47
+ "/help — show this help",
48
+ { parse_mode: "MarkdownV2" }
49
+ );
50
+ });
51
+
52
+ this._bot.command("help", (ctx) => {
53
+ ctx.reply(
54
+ "🌿 *Wispy Help*\n\n" +
55
+ "/start — welcome message\n" +
56
+ "/clear — reset conversation\n" +
57
+ "/model — show current model\n" +
58
+ "/help — this message\n\n" +
59
+ "Send any message to chat with Wispy\\!",
60
+ { parse_mode: "MarkdownV2" }
61
+ );
62
+ });
63
+
64
+ this._bot.command("clear", (ctx) => {
65
+ const chatId = String(ctx.chat.id);
66
+ this._emit(chatId, String(ctx.from?.id ?? ""), ctx.from?.username ?? "user", "__CLEAR__", ctx);
67
+ ctx.reply("🌿 Conversation cleared!");
68
+ });
69
+
70
+ this._bot.command("model", (ctx) => {
71
+ const model = process.env.WISPY_MODEL ?? "(auto)";
72
+ const provider = process.env.WISPY_PROVIDER ?? "(auto)";
73
+ ctx.reply(`🌿 Current model: \`${model}\`\nProvider: \`${provider}\``, { parse_mode: "Markdown" });
74
+ });
75
+
76
+ // ── Regular messages ──────────────────────────────────────────────────────
77
+ this._bot.on("message:text", (ctx) => {
78
+ const chatId = String(ctx.chat.id);
79
+ const userId = String(ctx.from?.id ?? "");
80
+ const username = ctx.from?.username ?? ctx.from?.first_name ?? "user";
81
+ const text = ctx.message.text ?? "";
82
+
83
+ // Skip command messages that weren't caught above
84
+ if (text.startsWith("/")) return;
85
+
86
+ this._emit(chatId, userId, username, text, ctx);
87
+ });
88
+
89
+ // Start long polling
90
+ this._bot.start();
91
+ console.log("🌿 Telegram bot started (long polling)");
92
+ }
93
+
94
+ async stop() {
95
+ if (this._bot) {
96
+ await this._bot.stop();
97
+ this._bot = null;
98
+ }
99
+ }
100
+
101
+ async sendMessage(chatId, text) {
102
+ if (!this._bot) throw new Error("Telegram bot not started");
103
+
104
+ // Telegram has 4096 char limit per message
105
+ const chunks = splitText(text, 4000);
106
+ for (const chunk of chunks) {
107
+ // Try Markdown first, fall back to plain text if it fails
108
+ try {
109
+ await this._bot.api.sendMessage(chatId, chunk, { parse_mode: "Markdown" });
110
+ } catch {
111
+ await this._bot.api.sendMessage(chatId, chunk);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ /** Split text into chunks ≤ maxLen characters */
118
+ function splitText(text, maxLen) {
119
+ if (text.length <= maxLen) return [text];
120
+ const chunks = [];
121
+ for (let i = 0; i < text.length; i += maxLen) {
122
+ chunks.push(text.slice(i, i + maxLen));
123
+ }
124
+ return chunks;
125
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "0.5.0",
4
- "description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration",
3
+ "version": "0.6.0",
4
+ "description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration and multi-channel bot support",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Minseo & Poropo",
7
7
  "repository": {
@@ -20,7 +20,12 @@
20
20
  "claude",
21
21
  "openai",
22
22
  "tui",
23
- "ink"
23
+ "ink",
24
+ "telegram",
25
+ "discord",
26
+ "slack",
27
+ "bot",
28
+ "chatbot"
24
29
  ],
25
30
  "type": "module",
26
31
  "bin": {
@@ -43,6 +48,22 @@
43
48
  "ink-text-input": "^6.0.0",
44
49
  "react": "^18.3.1"
45
50
  },
51
+ "peerDependencies": {
52
+ "grammy": ">=1.0.0",
53
+ "discord.js": ">=14.0.0",
54
+ "@slack/bolt": ">=3.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "grammy": {
58
+ "optional": true
59
+ },
60
+ "discord.js": {
61
+ "optional": true
62
+ },
63
+ "@slack/bolt": {
64
+ "optional": true
65
+ }
66
+ },
46
67
  "engines": {
47
68
  "node": ">=20.0.0"
48
69
  }