wispy-cli 0.5.0 → 0.6.1
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 +112 -1
- package/bin/wispy.mjs +77 -4
- package/lib/channels/base.mjs +66 -0
- package/lib/channels/discord.mjs +144 -0
- package/lib/channels/index.mjs +507 -0
- package/lib/channels/slack.mjs +181 -0
- package/lib/channels/telegram.mjs +125 -0
- package/package.json +24 -3
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
|
|
8
|
-
*
|
|
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,83 @@ 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
|
|
85
|
+
setInterval(() => {}, 60_000);
|
|
86
|
+
// eslint-disable-next-line no-constant-condition
|
|
87
|
+
await new Promise(() => {}); // keep alive
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── TUI mode ──────────────────────────────────────────────────────────────────
|
|
18
91
|
const tuiMode = args.includes("--tui");
|
|
19
92
|
|
|
20
93
|
if (tuiMode) {
|
|
21
|
-
// Remove --tui from args so wispy-tui doesn't see it
|
|
22
94
|
const newArgs = args.filter(a => a !== "--tui");
|
|
23
95
|
process.argv = [process.argv[0], process.argv[1], ...newArgs];
|
|
24
96
|
const tuiScript = path.join(__dirname, "..", "lib", "wispy-tui.mjs");
|
|
25
97
|
await import(tuiScript);
|
|
26
98
|
} else {
|
|
99
|
+
// Default: interactive REPL
|
|
27
100
|
const mainScript = path.join(__dirname, "..", "lib", "wispy-repl.mjs");
|
|
28
101
|
await import(mainScript);
|
|
29
102
|
}
|
|
@@ -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.
|
|
4
|
-
"description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration",
|
|
3
|
+
"version": "0.6.1",
|
|
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
|
}
|