alvin-bot 4.4.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/.env.example +43 -0
- package/BACKLOG.md +223 -0
- package/CHANGELOG.md +63 -0
- package/CLAUDE.example.md +152 -0
- package/CODE_OF_CONDUCT.md +52 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/SECURITY.md +38 -0
- package/SOUL.example.md +60 -0
- package/TOOLS.example.md +42 -0
- package/alvin-bot.config.example.json +24 -0
- package/bin/cli.js +1088 -0
- package/dist/.metadata_never_index +0 -0
- package/dist/claude.js +102 -0
- package/dist/config.js +65 -0
- package/dist/engine.js +90 -0
- package/dist/find-claude-binary.js +98 -0
- package/dist/handlers/commands.js +1489 -0
- package/dist/handlers/document.js +187 -0
- package/dist/handlers/message.js +200 -0
- package/dist/handlers/photo.js +154 -0
- package/dist/handlers/platform-message.js +275 -0
- package/dist/handlers/video.js +237 -0
- package/dist/handlers/voice.js +148 -0
- package/dist/i18n.js +299 -0
- package/dist/index.js +442 -0
- package/dist/init-data-dir.js +81 -0
- package/dist/middleware/auth.js +215 -0
- package/dist/migrate.js +139 -0
- package/dist/paths.js +87 -0
- package/dist/platforms/discord.js +161 -0
- package/dist/platforms/index.js +130 -0
- package/dist/platforms/signal.js +205 -0
- package/dist/platforms/slack.js +318 -0
- package/dist/platforms/telegram.js +111 -0
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/whatsapp.js +648 -0
- package/dist/providers/claude-sdk-provider.js +173 -0
- package/dist/providers/codex-cli-provider.js +121 -0
- package/dist/providers/index.js +7 -0
- package/dist/providers/openai-compatible.js +388 -0
- package/dist/providers/registry.js +209 -0
- package/dist/providers/tool-executor.js +450 -0
- package/dist/providers/types.js +205 -0
- package/dist/services/access.js +144 -0
- package/dist/services/asset-index.js +230 -0
- package/dist/services/browser-manager.js +161 -0
- package/dist/services/browser.js +121 -0
- package/dist/services/compaction.js +129 -0
- package/dist/services/cron.js +462 -0
- package/dist/services/custom-tools.js +317 -0
- package/dist/services/delivery-queue.js +154 -0
- package/dist/services/elevenlabs.js +58 -0
- package/dist/services/embeddings.js +386 -0
- package/dist/services/exec-guard.js +46 -0
- package/dist/services/fallback-order.js +151 -0
- package/dist/services/heartbeat.js +192 -0
- package/dist/services/hooks.js +44 -0
- package/dist/services/imagegen.js +72 -0
- package/dist/services/language-detect.js +144 -0
- package/dist/services/markdown.js +63 -0
- package/dist/services/mcp.js +252 -0
- package/dist/services/memory.js +133 -0
- package/dist/services/personality.js +227 -0
- package/dist/services/plugins.js +171 -0
- package/dist/services/reminders.js +97 -0
- package/dist/services/restart.js +48 -0
- package/dist/services/security-audit.js +66 -0
- package/dist/services/self-search.js +129 -0
- package/dist/services/session.js +93 -0
- package/dist/services/skills.js +287 -0
- package/dist/services/standing-orders.js +29 -0
- package/dist/services/subagents.js +142 -0
- package/dist/services/sudo.js +243 -0
- package/dist/services/telegram.js +113 -0
- package/dist/services/tool-discovery.js +214 -0
- package/dist/services/usage-tracker.js +137 -0
- package/dist/services/users.js +199 -0
- package/dist/services/voice.js +95 -0
- package/dist/tui/index.js +507 -0
- package/dist/web/canvas.js +30 -0
- package/dist/web/doctor-api.js +606 -0
- package/dist/web/openai-compat.js +252 -0
- package/dist/web/server.js +1351 -0
- package/dist/web/setup-api.js +1078 -0
- package/docs/mcp.example.json +16 -0
- package/docs/screenshots/00-Login.png +0 -0
- package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
- package/docs/screenshots/02-Chat.png +0 -0
- package/docs/screenshots/03-Dashboard-Overview.png +0 -0
- package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
- package/docs/screenshots/05-Personality-Editor.png +0 -0
- package/docs/screenshots/06-Memory-Manager.png +0 -0
- package/docs/screenshots/07-Active-Sessions.png +0 -0
- package/docs/screenshots/08-File-Browser.png +0 -0
- package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
- package/docs/screenshots/10-Custom-Tools.png +0 -0
- package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
- package/docs/screenshots/12-Messaging-Platforms.png +0 -0
- package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
- package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
- package/docs/screenshots/13-User-Management.png +0 -0
- package/docs/screenshots/14-Web-Terminal.png +0 -0
- package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
- package/docs/screenshots/16-Settings-and-Env.png +0 -0
- package/docs/screenshots/TG-commands.png +0 -0
- package/docs/screenshots/TG.png +0 -0
- package/docs/screenshots/_Mac-Installer.png +0 -0
- package/docs/tools.example.json +33 -0
- package/install.sh +165 -0
- package/package.json +190 -0
- package/plugins/calendar/index.js +270 -0
- package/plugins/email/index.js +231 -0
- package/plugins/finance/index.js +254 -0
- package/plugins/notes/index.js +227 -0
- package/plugins/smarthome/index.js +230 -0
- package/plugins/weather/index.js +122 -0
- package/skills/apple-notes/SKILL.md +31 -0
- package/skills/browse/SKILL.md +136 -0
- package/skills/code-project/SKILL.md +43 -0
- package/skills/data-analysis/SKILL.md +39 -0
- package/skills/document-creation/SKILL.md +48 -0
- package/skills/email-summary/SKILL.md +46 -0
- package/skills/github/SKILL.md +42 -0
- package/skills/summarize/SKILL.md +28 -0
- package/skills/system-admin/SKILL.md +39 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/web-research/SKILL.md +35 -0
- package/web/public/canvas.html +52 -0
- package/web/public/css/style.css +555 -0
- package/web/public/index.html +189 -0
- package/web/public/js/app.js +3102 -0
- package/web/public/js/i18n.js +1048 -0
- package/web/public/js/icons.js +104 -0
- package/web/public/login.html +48 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Manager — Load and manage multiple platform adapters.
|
|
3
|
+
*
|
|
4
|
+
* Automatically detects which platforms are configured (based on env vars)
|
|
5
|
+
* and starts the appropriate adapters.
|
|
6
|
+
*
|
|
7
|
+
* Env vars:
|
|
8
|
+
* - BOT_TOKEN → Telegram (always active if set)
|
|
9
|
+
* - DISCORD_TOKEN → Discord
|
|
10
|
+
* - WHATSAPP_ENABLED=true → WhatsApp (QR code scan required)
|
|
11
|
+
* - SLACK_BOT_TOKEN + SLACK_APP_TOKEN → Slack (Socket Mode)
|
|
12
|
+
* - SIGNAL_API_URL + SIGNAL_NUMBER → Signal
|
|
13
|
+
*/
|
|
14
|
+
const adapters = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* Register a platform adapter.
|
|
17
|
+
*/
|
|
18
|
+
export function registerAdapter(adapter) {
|
|
19
|
+
adapters.set(adapter.platform, adapter);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Get a specific adapter by platform name.
|
|
23
|
+
*/
|
|
24
|
+
export function getAdapter(platform) {
|
|
25
|
+
return adapters.get(platform);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Get all registered adapters.
|
|
29
|
+
*/
|
|
30
|
+
export function getAllAdapters() {
|
|
31
|
+
return Array.from(adapters.values());
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get platform status for dashboard.
|
|
35
|
+
*/
|
|
36
|
+
export function getPlatformStatus() {
|
|
37
|
+
return Array.from(adapters.entries()).map(([name, _]) => ({
|
|
38
|
+
platform: name,
|
|
39
|
+
active: true,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Auto-detect and load platform adapters based on env vars.
|
|
44
|
+
* Returns list of loaded platforms.
|
|
45
|
+
*/
|
|
46
|
+
export async function autoLoadPlatforms() {
|
|
47
|
+
const loaded = [];
|
|
48
|
+
// Discord
|
|
49
|
+
const discordToken = process.env.DISCORD_TOKEN;
|
|
50
|
+
if (discordToken) {
|
|
51
|
+
try {
|
|
52
|
+
const { DiscordAdapter } = await import("./discord.js");
|
|
53
|
+
const adapter = new DiscordAdapter(discordToken);
|
|
54
|
+
registerAdapter(adapter);
|
|
55
|
+
loaded.push("discord");
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error("Discord adapter failed to load:", err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// WhatsApp
|
|
62
|
+
if (process.env.WHATSAPP_ENABLED === "true") {
|
|
63
|
+
try {
|
|
64
|
+
const { WhatsAppAdapter } = await import("./whatsapp.js");
|
|
65
|
+
const adapter = new WhatsAppAdapter();
|
|
66
|
+
registerAdapter(adapter);
|
|
67
|
+
loaded.push("whatsapp");
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error("WhatsApp adapter failed to load:", err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Slack
|
|
74
|
+
const slackBotToken = process.env.SLACK_BOT_TOKEN;
|
|
75
|
+
const slackAppToken = process.env.SLACK_APP_TOKEN;
|
|
76
|
+
if (slackBotToken && slackAppToken) {
|
|
77
|
+
try {
|
|
78
|
+
const { SlackAdapter } = await import("./slack.js");
|
|
79
|
+
const adapter = new SlackAdapter(slackBotToken, slackAppToken);
|
|
80
|
+
registerAdapter(adapter);
|
|
81
|
+
loaded.push("slack");
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.error("Slack adapter failed to load:", err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Signal
|
|
88
|
+
const signalUrl = process.env.SIGNAL_API_URL;
|
|
89
|
+
const signalNumber = process.env.SIGNAL_NUMBER;
|
|
90
|
+
if (signalUrl && signalNumber) {
|
|
91
|
+
try {
|
|
92
|
+
const { SignalAdapter } = await import("./signal.js");
|
|
93
|
+
const adapter = new SignalAdapter(signalUrl, signalNumber);
|
|
94
|
+
registerAdapter(adapter);
|
|
95
|
+
loaded.push("signal");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error("Signal adapter failed to load:", err);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return loaded;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Start all registered adapters.
|
|
105
|
+
*/
|
|
106
|
+
export async function startAllAdapters(messageHandler) {
|
|
107
|
+
for (const [name, adapter] of adapters) {
|
|
108
|
+
try {
|
|
109
|
+
adapter.onMessage(messageHandler);
|
|
110
|
+
await adapter.start();
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.error(`Failed to start ${name} adapter:`, err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Stop all adapters.
|
|
119
|
+
*/
|
|
120
|
+
export async function stopAllAdapters() {
|
|
121
|
+
for (const [name, adapter] of adapters) {
|
|
122
|
+
try {
|
|
123
|
+
await adapter.stop();
|
|
124
|
+
console.log(`${name} adapter stopped`);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
console.error(`Failed to stop ${name}:`, err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal Platform Adapter
|
|
3
|
+
*
|
|
4
|
+
* Uses signal-cli (REST API mode) for Signal messaging.
|
|
5
|
+
* Optional — only loaded if SIGNAL_API_URL is set.
|
|
6
|
+
*
|
|
7
|
+
* Setup:
|
|
8
|
+
* 1. Run signal-cli in REST API mode:
|
|
9
|
+
* docker run -p 8080:8080 bbernhard/signal-cli-rest-api
|
|
10
|
+
* 2. Register/link a phone number via signal-cli
|
|
11
|
+
* 3. Set SIGNAL_API_URL=http://localhost:8080 and SIGNAL_NUMBER=+49... in .env
|
|
12
|
+
*/
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
let _signalState = {
|
|
17
|
+
status: "disconnected",
|
|
18
|
+
apiVersion: null,
|
|
19
|
+
number: null,
|
|
20
|
+
connectedAt: null,
|
|
21
|
+
error: null,
|
|
22
|
+
};
|
|
23
|
+
export function getSignalState() {
|
|
24
|
+
return { ..._signalState };
|
|
25
|
+
}
|
|
26
|
+
export class SignalAdapter {
|
|
27
|
+
platform = "signal";
|
|
28
|
+
handler = null;
|
|
29
|
+
apiUrl;
|
|
30
|
+
number;
|
|
31
|
+
pollInterval = null;
|
|
32
|
+
constructor(apiUrl, number) {
|
|
33
|
+
this.apiUrl = apiUrl.replace(/\/$/, "");
|
|
34
|
+
this.number = number;
|
|
35
|
+
}
|
|
36
|
+
async start() {
|
|
37
|
+
_signalState.status = "connecting";
|
|
38
|
+
_signalState.number = this.number;
|
|
39
|
+
// Verify connection
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${this.apiUrl}/v1/about`);
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`Signal API not reachable: ${res.status}`);
|
|
44
|
+
const about = await res.json().catch(() => ({}));
|
|
45
|
+
_signalState.status = "connected";
|
|
46
|
+
_signalState.apiVersion = about.version || about.versions?.[0] || null;
|
|
47
|
+
_signalState.connectedAt = Date.now();
|
|
48
|
+
console.log("📱 Signal adapter connected");
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
_signalState.status = "error";
|
|
52
|
+
_signalState.error = err instanceof Error ? err.message : String(err);
|
|
53
|
+
console.error("Signal adapter failed:", err);
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
// Poll for new messages every 2 seconds
|
|
57
|
+
this.pollInterval = setInterval(async () => {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`${this.apiUrl}/v1/receive/${encodeURIComponent(this.number)}`);
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
return;
|
|
62
|
+
const messages = await res.json();
|
|
63
|
+
for (const msg of messages) {
|
|
64
|
+
const data = msg.envelope?.dataMessage;
|
|
65
|
+
if (!data)
|
|
66
|
+
continue;
|
|
67
|
+
if (!this.handler)
|
|
68
|
+
continue;
|
|
69
|
+
const hasText = !!data.message;
|
|
70
|
+
const hasVoice = data.attachments?.some((a) => a.contentType?.startsWith("audio/") || a.voiceNote);
|
|
71
|
+
// Must have text or a voice attachment
|
|
72
|
+
if (!hasText && !hasVoice)
|
|
73
|
+
continue;
|
|
74
|
+
const isGroup = !!data.groupInfo;
|
|
75
|
+
// Download voice attachment if present
|
|
76
|
+
let mediaInfo = undefined;
|
|
77
|
+
if (hasVoice) {
|
|
78
|
+
try {
|
|
79
|
+
const voiceAtt = data.attachments.find((a) => a.contentType?.startsWith("audio/") || a.voiceNote);
|
|
80
|
+
if (voiceAtt?.id) {
|
|
81
|
+
const attRes = await fetch(`${this.apiUrl}/v1/attachments/${voiceAtt.id}`, { headers: { "Content-Type": "application/json" } });
|
|
82
|
+
if (attRes.ok) {
|
|
83
|
+
const tmpDir = join(tmpdir(), "alvin-bot");
|
|
84
|
+
if (!fs.existsSync(tmpDir))
|
|
85
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
86
|
+
const ext = voiceAtt.contentType?.includes("ogg") ? "ogg" : "mp3";
|
|
87
|
+
const audioPath = join(tmpDir, `signal_voice_${Date.now()}.${ext}`);
|
|
88
|
+
fs.writeFileSync(audioPath, Buffer.from(await attRes.arrayBuffer()));
|
|
89
|
+
mediaInfo = { type: "voice", path: audioPath, mimeType: voiceAtt.contentType || "audio/ogg" };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error("Signal: Failed to download voice:", err);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const incoming = {
|
|
98
|
+
platform: "signal",
|
|
99
|
+
messageId: msg.envelope.timestamp?.toString() || "",
|
|
100
|
+
chatId: isGroup ? data.groupInfo.groupId : msg.envelope.sourceNumber,
|
|
101
|
+
userId: msg.envelope.sourceNumber || "",
|
|
102
|
+
userName: msg.envelope.sourceName || msg.envelope.sourceNumber || "Unknown",
|
|
103
|
+
text: data.message || "",
|
|
104
|
+
isGroup,
|
|
105
|
+
isMention: !!(data.message && (data.message.includes("@bot") || data.message.includes("Alvin Bot"))),
|
|
106
|
+
isReplyToBot: false,
|
|
107
|
+
replyToText: data.quote?.text,
|
|
108
|
+
media: mediaInfo,
|
|
109
|
+
};
|
|
110
|
+
// In groups: only respond to mentions (voice in groups always allowed)
|
|
111
|
+
if (isGroup && !incoming.isMention && !hasVoice)
|
|
112
|
+
continue;
|
|
113
|
+
await this.handler(incoming);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch { /* poll error — retry next interval */ }
|
|
117
|
+
}, 2000);
|
|
118
|
+
}
|
|
119
|
+
async stop() {
|
|
120
|
+
if (this.pollInterval) {
|
|
121
|
+
clearInterval(this.pollInterval);
|
|
122
|
+
this.pollInterval = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async sendText(chatId, text) {
|
|
126
|
+
// Determine if chatId is a group or direct message
|
|
127
|
+
const isGroup = chatId.length > 20; // Signal group IDs are long base64 strings
|
|
128
|
+
const body = {
|
|
129
|
+
message: text,
|
|
130
|
+
number: this.number,
|
|
131
|
+
recipients: isGroup ? undefined : [chatId],
|
|
132
|
+
};
|
|
133
|
+
if (isGroup) {
|
|
134
|
+
// Send to group
|
|
135
|
+
await fetch(`${this.apiUrl}/v2/send`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "Content-Type": "application/json" },
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
...body,
|
|
140
|
+
recipients: [chatId],
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
await fetch(`${this.apiUrl}/v2/send`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify(body),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async sendPhoto(chatId, photo, caption) {
|
|
153
|
+
// Signal sends attachments as base64 in the message body
|
|
154
|
+
const base64 = typeof photo === "string"
|
|
155
|
+
? fs.readFileSync(photo).toString("base64")
|
|
156
|
+
: photo.toString("base64");
|
|
157
|
+
await fetch(`${this.apiUrl}/v2/send`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
message: caption || "",
|
|
162
|
+
number: this.number,
|
|
163
|
+
recipients: [chatId],
|
|
164
|
+
base64_attachments: [`data:image/png;base64,${base64}`],
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async sendDocument(chatId, doc, fileName, caption) {
|
|
169
|
+
const base64 = typeof doc === "string"
|
|
170
|
+
? fs.readFileSync(doc).toString("base64")
|
|
171
|
+
: doc.toString("base64");
|
|
172
|
+
await fetch(`${this.apiUrl}/v2/send`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "Content-Type": "application/json" },
|
|
175
|
+
body: JSON.stringify({
|
|
176
|
+
message: caption || fileName,
|
|
177
|
+
number: this.number,
|
|
178
|
+
recipients: [chatId],
|
|
179
|
+
base64_attachments: [`data:application/octet-stream;filename=${fileName};base64,${base64}`],
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async react(chatId, messageId, emoji) {
|
|
184
|
+
try {
|
|
185
|
+
await fetch(`${this.apiUrl}/v1/reactions/${encodeURIComponent(this.number)}`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
recipient: chatId,
|
|
190
|
+
reaction: emoji,
|
|
191
|
+
target_author: chatId,
|
|
192
|
+
timestamp: parseInt(messageId),
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
catch { /* ignore */ }
|
|
197
|
+
}
|
|
198
|
+
async setTyping(chatId) {
|
|
199
|
+
// Signal doesn't have a native typing indicator via REST API
|
|
200
|
+
// No-op to satisfy the interface
|
|
201
|
+
}
|
|
202
|
+
onMessage(handler) {
|
|
203
|
+
this.handler = handler;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack Platform Adapter
|
|
3
|
+
*
|
|
4
|
+
* Uses @slack/bolt (Socket Mode) for real-time messaging.
|
|
5
|
+
* Optional dependency — only loaded if SLACK_BOT_TOKEN + SLACK_APP_TOKEN are set.
|
|
6
|
+
*
|
|
7
|
+
* Socket Mode = no public URL needed. Works behind NAT/firewalls.
|
|
8
|
+
*
|
|
9
|
+
* Setup:
|
|
10
|
+
* 1. Create a Slack App at https://api.slack.com/apps
|
|
11
|
+
* 2. Enable Socket Mode (Settings → Socket Mode → Enable)
|
|
12
|
+
* 3. Generate an App-Level Token with connections:write scope → SLACK_APP_TOKEN (xapp-...)
|
|
13
|
+
* 4. Install to workspace → Bot User OAuth Token → SLACK_BOT_TOKEN (xoxb-...)
|
|
14
|
+
* 5. Add Bot Token Scopes: chat:write, channels:history, groups:history, im:history,
|
|
15
|
+
* mpim:history, app_mentions:read, files:write, reactions:write
|
|
16
|
+
* 6. Subscribe to events: message.im, message.groups, message.channels, app_mention
|
|
17
|
+
* 7. Set env vars and restart bot
|
|
18
|
+
*/
|
|
19
|
+
import fs from "fs";
|
|
20
|
+
let _slackState = {
|
|
21
|
+
status: "disconnected",
|
|
22
|
+
botName: null,
|
|
23
|
+
botId: null,
|
|
24
|
+
teamName: null,
|
|
25
|
+
connectedAt: null,
|
|
26
|
+
error: null,
|
|
27
|
+
};
|
|
28
|
+
export function getSlackState() {
|
|
29
|
+
return { ..._slackState };
|
|
30
|
+
}
|
|
31
|
+
// ── Adapter ────────────────────────────────────────────────────────────────
|
|
32
|
+
export class SlackAdapter {
|
|
33
|
+
platform = "slack";
|
|
34
|
+
handler = null;
|
|
35
|
+
app = null; // Bolt App instance
|
|
36
|
+
botUserId = "";
|
|
37
|
+
botToken;
|
|
38
|
+
appToken;
|
|
39
|
+
constructor(botToken, appToken) {
|
|
40
|
+
this.botToken = botToken;
|
|
41
|
+
this.appToken = appToken;
|
|
42
|
+
}
|
|
43
|
+
async start() {
|
|
44
|
+
_slackState = {
|
|
45
|
+
status: "connecting", botName: null, botId: null,
|
|
46
|
+
teamName: null, connectedAt: null, error: null,
|
|
47
|
+
};
|
|
48
|
+
let bolt;
|
|
49
|
+
try {
|
|
50
|
+
bolt = await import("@slack/bolt");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
const msg = "@slack/bolt not installed. Run: npm install @slack/bolt";
|
|
54
|
+
_slackState = { ..._slackState, status: "error", error: msg };
|
|
55
|
+
console.error(`\u274C Slack: ${msg}`);
|
|
56
|
+
throw new Error(msg);
|
|
57
|
+
}
|
|
58
|
+
const { App } = bolt;
|
|
59
|
+
try {
|
|
60
|
+
this.app = new App({
|
|
61
|
+
token: this.botToken,
|
|
62
|
+
appToken: this.appToken,
|
|
63
|
+
socketMode: true,
|
|
64
|
+
// Suppress Bolt's default logging (we log ourselves)
|
|
65
|
+
logLevel: "ERROR",
|
|
66
|
+
});
|
|
67
|
+
// Get bot identity
|
|
68
|
+
const authResult = await this.app.client.auth.test({ token: this.botToken });
|
|
69
|
+
this.botUserId = authResult.user_id || "";
|
|
70
|
+
_slackState.botName = authResult.user || null;
|
|
71
|
+
_slackState.botId = authResult.user_id || null;
|
|
72
|
+
_slackState.teamName = authResult.team || null;
|
|
73
|
+
// Handle all messages (DMs + channels where bot is mentioned)
|
|
74
|
+
this.app.message(async ({ message, say, client }) => {
|
|
75
|
+
await this.handleMessage(message, say, client);
|
|
76
|
+
});
|
|
77
|
+
// Handle @mentions explicitly (app_mention event)
|
|
78
|
+
this.app.event("app_mention", async ({ event, say, client }) => {
|
|
79
|
+
await this.handleMention(event, say, client);
|
|
80
|
+
});
|
|
81
|
+
await this.app.start();
|
|
82
|
+
_slackState.status = "connected";
|
|
83
|
+
_slackState.connectedAt = Date.now();
|
|
84
|
+
console.log(`\uD83D\uDCAC Slack connected (${_slackState.botName} @ ${_slackState.teamName})`);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
_slackState.status = "error";
|
|
88
|
+
_slackState.error = err instanceof Error ? err.message : String(err);
|
|
89
|
+
console.error("\u274C Slack adapter failed:", _slackState.error);
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// ── Message Handling ───────────────────────────────────────────────────────
|
|
94
|
+
async handleMessage(message, _say, client) {
|
|
95
|
+
if (!this.handler)
|
|
96
|
+
return;
|
|
97
|
+
// Skip bot messages (including own), message_changed, etc.
|
|
98
|
+
if (message.subtype)
|
|
99
|
+
return;
|
|
100
|
+
if (message.bot_id)
|
|
101
|
+
return;
|
|
102
|
+
if (!message.text && !message.files)
|
|
103
|
+
return;
|
|
104
|
+
const text = (message.text || "").trim();
|
|
105
|
+
const userId = message.user || "";
|
|
106
|
+
const channelId = message.channel || "";
|
|
107
|
+
const messageId = message.ts || "";
|
|
108
|
+
// Determine channel type
|
|
109
|
+
// DMs (im) have channel_type "im", group DMs are "mpim", channels are "channel"/"group"
|
|
110
|
+
const channelType = message.channel_type || "";
|
|
111
|
+
const isDM = channelType === "im";
|
|
112
|
+
const isGroup = !isDM;
|
|
113
|
+
// In channels: only respond to @mentions (handled by app_mention event)
|
|
114
|
+
// But message event also fires for DMs, so we handle DMs here
|
|
115
|
+
if (isGroup)
|
|
116
|
+
return; // Channel messages handled by app_mention
|
|
117
|
+
// Resolve user name
|
|
118
|
+
let userName = userId;
|
|
119
|
+
try {
|
|
120
|
+
const userInfo = await client.users.info({ user: userId });
|
|
121
|
+
userName = userInfo.user?.real_name || userInfo.user?.name || userId;
|
|
122
|
+
}
|
|
123
|
+
catch { /* fallback to userId */ }
|
|
124
|
+
// Check for file attachments
|
|
125
|
+
let media = undefined;
|
|
126
|
+
if (message.files && message.files.length > 0) {
|
|
127
|
+
const file = message.files[0];
|
|
128
|
+
media = this.parseSlackFile(file);
|
|
129
|
+
}
|
|
130
|
+
// Check for thread/reply context
|
|
131
|
+
let replyToText;
|
|
132
|
+
if (message.thread_ts && message.thread_ts !== message.ts) {
|
|
133
|
+
try {
|
|
134
|
+
const thread = await client.conversations.replies({
|
|
135
|
+
channel: channelId,
|
|
136
|
+
ts: message.thread_ts,
|
|
137
|
+
limit: 1,
|
|
138
|
+
});
|
|
139
|
+
const parent = thread.messages?.[0];
|
|
140
|
+
if (parent?.text) {
|
|
141
|
+
replyToText = parent.text.length > 500 ? parent.text.slice(0, 500) + "..." : parent.text;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
const incoming = {
|
|
147
|
+
platform: "slack",
|
|
148
|
+
messageId,
|
|
149
|
+
chatId: channelId,
|
|
150
|
+
userId,
|
|
151
|
+
userName,
|
|
152
|
+
text,
|
|
153
|
+
isGroup: false,
|
|
154
|
+
isMention: false,
|
|
155
|
+
isReplyToBot: false,
|
|
156
|
+
replyToText,
|
|
157
|
+
media,
|
|
158
|
+
};
|
|
159
|
+
await this.handler(incoming);
|
|
160
|
+
}
|
|
161
|
+
async handleMention(event, _say, client) {
|
|
162
|
+
if (!this.handler)
|
|
163
|
+
return;
|
|
164
|
+
if (event.bot_id)
|
|
165
|
+
return;
|
|
166
|
+
let text = (event.text || "").trim();
|
|
167
|
+
const userId = event.user || "";
|
|
168
|
+
const channelId = event.channel || "";
|
|
169
|
+
const messageId = event.ts || "";
|
|
170
|
+
// Strip the @mention from text
|
|
171
|
+
text = text.replace(new RegExp(`<@${this.botUserId}>`, "g"), "").trim();
|
|
172
|
+
if (!text)
|
|
173
|
+
return;
|
|
174
|
+
// Resolve user name
|
|
175
|
+
let userName = userId;
|
|
176
|
+
try {
|
|
177
|
+
const userInfo = await client.users.info({ user: userId });
|
|
178
|
+
userName = userInfo.user?.real_name || userInfo.user?.name || userId;
|
|
179
|
+
}
|
|
180
|
+
catch { /* fallback */ }
|
|
181
|
+
// File attachments
|
|
182
|
+
let media = undefined;
|
|
183
|
+
if (event.files && event.files.length > 0) {
|
|
184
|
+
media = this.parseSlackFile(event.files[0]);
|
|
185
|
+
}
|
|
186
|
+
const incoming = {
|
|
187
|
+
platform: "slack",
|
|
188
|
+
messageId,
|
|
189
|
+
chatId: channelId,
|
|
190
|
+
userId,
|
|
191
|
+
userName,
|
|
192
|
+
text,
|
|
193
|
+
isGroup: true,
|
|
194
|
+
isMention: true,
|
|
195
|
+
isReplyToBot: false,
|
|
196
|
+
media,
|
|
197
|
+
};
|
|
198
|
+
await this.handler(incoming);
|
|
199
|
+
}
|
|
200
|
+
parseSlackFile(file) {
|
|
201
|
+
if (!file)
|
|
202
|
+
return undefined;
|
|
203
|
+
const mime = file.mimetype || "";
|
|
204
|
+
if (mime.startsWith("image/")) {
|
|
205
|
+
return { type: "photo", url: file.url_private, mimeType: mime, fileName: file.name };
|
|
206
|
+
}
|
|
207
|
+
if (mime.startsWith("audio/")) {
|
|
208
|
+
return { type: "voice", url: file.url_private, mimeType: mime, fileName: file.name };
|
|
209
|
+
}
|
|
210
|
+
if (mime.startsWith("video/")) {
|
|
211
|
+
return { type: "video", url: file.url_private, mimeType: mime, fileName: file.name };
|
|
212
|
+
}
|
|
213
|
+
return { type: "document", url: file.url_private, mimeType: mime, fileName: file.name };
|
|
214
|
+
}
|
|
215
|
+
// ── Sending ──────────────────────────────────────────────────────────────
|
|
216
|
+
async sendText(chatId, text, options) {
|
|
217
|
+
if (!this.app)
|
|
218
|
+
return;
|
|
219
|
+
// Slack block limit is ~3000 chars for text blocks, message limit ~40000
|
|
220
|
+
// But keep it practical — split at 3800 like Telegram
|
|
221
|
+
const chunks = text.length > 3800
|
|
222
|
+
? text.match(/.{1,3800}/gs) || [text]
|
|
223
|
+
: [text];
|
|
224
|
+
for (const chunk of chunks) {
|
|
225
|
+
await this.app.client.chat.postMessage({
|
|
226
|
+
token: this.botToken,
|
|
227
|
+
channel: chatId,
|
|
228
|
+
text: chunk,
|
|
229
|
+
// Thread reply if replyTo is set
|
|
230
|
+
...(options?.replyTo ? { thread_ts: options.replyTo } : {}),
|
|
231
|
+
// Convert markdown bold/italic to Slack mrkdwn
|
|
232
|
+
mrkdwn: true,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async sendPhoto(chatId, photo, caption) {
|
|
237
|
+
if (!this.app)
|
|
238
|
+
return;
|
|
239
|
+
if (typeof photo === "string") {
|
|
240
|
+
// File path
|
|
241
|
+
await this.app.client.filesUploadV2({
|
|
242
|
+
token: this.botToken,
|
|
243
|
+
channel_id: chatId,
|
|
244
|
+
file: fs.createReadStream(photo),
|
|
245
|
+
filename: "image.png",
|
|
246
|
+
initial_comment: caption,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
// Buffer
|
|
251
|
+
await this.app.client.filesUploadV2({
|
|
252
|
+
token: this.botToken,
|
|
253
|
+
channel_id: chatId,
|
|
254
|
+
file_uploads: [{
|
|
255
|
+
file: photo,
|
|
256
|
+
filename: "image.png",
|
|
257
|
+
}],
|
|
258
|
+
initial_comment: caption,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async sendDocument(chatId, doc, fileName, caption) {
|
|
263
|
+
if (!this.app)
|
|
264
|
+
return;
|
|
265
|
+
if (typeof doc === "string") {
|
|
266
|
+
await this.app.client.filesUploadV2({
|
|
267
|
+
token: this.botToken,
|
|
268
|
+
channel_id: chatId,
|
|
269
|
+
file: fs.createReadStream(doc),
|
|
270
|
+
filename: fileName,
|
|
271
|
+
initial_comment: caption,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
await this.app.client.filesUploadV2({
|
|
276
|
+
token: this.botToken,
|
|
277
|
+
channel_id: chatId,
|
|
278
|
+
file_uploads: [{
|
|
279
|
+
file: doc,
|
|
280
|
+
filename: fileName,
|
|
281
|
+
}],
|
|
282
|
+
initial_comment: caption,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async react(chatId, messageId, emoji) {
|
|
287
|
+
if (!this.app)
|
|
288
|
+
return;
|
|
289
|
+
try {
|
|
290
|
+
// Slack emoji names don't include colons
|
|
291
|
+
const name = emoji.replace(/^:|:$/g, "");
|
|
292
|
+
await this.app.client.reactions.add({
|
|
293
|
+
token: this.botToken,
|
|
294
|
+
channel: chatId,
|
|
295
|
+
timestamp: messageId,
|
|
296
|
+
name,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch { /* ignore — emoji might not exist */ }
|
|
300
|
+
}
|
|
301
|
+
async setTyping(chatId) {
|
|
302
|
+
// Slack doesn't have a public typing indicator API for bots
|
|
303
|
+
// The closest is the "is typing" shown during Web API calls
|
|
304
|
+
}
|
|
305
|
+
async stop() {
|
|
306
|
+
if (this.app) {
|
|
307
|
+
try {
|
|
308
|
+
await this.app.stop();
|
|
309
|
+
}
|
|
310
|
+
catch { /* ignore */ }
|
|
311
|
+
this.app = null;
|
|
312
|
+
}
|
|
313
|
+
_slackState.status = "disconnected";
|
|
314
|
+
}
|
|
315
|
+
onMessage(handler) {
|
|
316
|
+
this.handler = handler;
|
|
317
|
+
}
|
|
318
|
+
}
|