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.
Files changed (136) hide show
  1. package/.env.example +43 -0
  2. package/BACKLOG.md +223 -0
  3. package/CHANGELOG.md +63 -0
  4. package/CLAUDE.example.md +152 -0
  5. package/CODE_OF_CONDUCT.md +52 -0
  6. package/CONTRIBUTING.md +72 -0
  7. package/LICENSE +21 -0
  8. package/README.md +529 -0
  9. package/SECURITY.md +38 -0
  10. package/SOUL.example.md +60 -0
  11. package/TOOLS.example.md +42 -0
  12. package/alvin-bot.config.example.json +24 -0
  13. package/bin/cli.js +1088 -0
  14. package/dist/.metadata_never_index +0 -0
  15. package/dist/claude.js +102 -0
  16. package/dist/config.js +65 -0
  17. package/dist/engine.js +90 -0
  18. package/dist/find-claude-binary.js +98 -0
  19. package/dist/handlers/commands.js +1489 -0
  20. package/dist/handlers/document.js +187 -0
  21. package/dist/handlers/message.js +200 -0
  22. package/dist/handlers/photo.js +154 -0
  23. package/dist/handlers/platform-message.js +275 -0
  24. package/dist/handlers/video.js +237 -0
  25. package/dist/handlers/voice.js +148 -0
  26. package/dist/i18n.js +299 -0
  27. package/dist/index.js +442 -0
  28. package/dist/init-data-dir.js +81 -0
  29. package/dist/middleware/auth.js +215 -0
  30. package/dist/migrate.js +139 -0
  31. package/dist/paths.js +87 -0
  32. package/dist/platforms/discord.js +161 -0
  33. package/dist/platforms/index.js +130 -0
  34. package/dist/platforms/signal.js +205 -0
  35. package/dist/platforms/slack.js +318 -0
  36. package/dist/platforms/telegram.js +111 -0
  37. package/dist/platforms/types.js +8 -0
  38. package/dist/platforms/whatsapp.js +648 -0
  39. package/dist/providers/claude-sdk-provider.js +173 -0
  40. package/dist/providers/codex-cli-provider.js +121 -0
  41. package/dist/providers/index.js +7 -0
  42. package/dist/providers/openai-compatible.js +388 -0
  43. package/dist/providers/registry.js +209 -0
  44. package/dist/providers/tool-executor.js +450 -0
  45. package/dist/providers/types.js +205 -0
  46. package/dist/services/access.js +144 -0
  47. package/dist/services/asset-index.js +230 -0
  48. package/dist/services/browser-manager.js +161 -0
  49. package/dist/services/browser.js +121 -0
  50. package/dist/services/compaction.js +129 -0
  51. package/dist/services/cron.js +462 -0
  52. package/dist/services/custom-tools.js +317 -0
  53. package/dist/services/delivery-queue.js +154 -0
  54. package/dist/services/elevenlabs.js +58 -0
  55. package/dist/services/embeddings.js +386 -0
  56. package/dist/services/exec-guard.js +46 -0
  57. package/dist/services/fallback-order.js +151 -0
  58. package/dist/services/heartbeat.js +192 -0
  59. package/dist/services/hooks.js +44 -0
  60. package/dist/services/imagegen.js +72 -0
  61. package/dist/services/language-detect.js +144 -0
  62. package/dist/services/markdown.js +63 -0
  63. package/dist/services/mcp.js +252 -0
  64. package/dist/services/memory.js +133 -0
  65. package/dist/services/personality.js +227 -0
  66. package/dist/services/plugins.js +171 -0
  67. package/dist/services/reminders.js +97 -0
  68. package/dist/services/restart.js +48 -0
  69. package/dist/services/security-audit.js +66 -0
  70. package/dist/services/self-search.js +129 -0
  71. package/dist/services/session.js +93 -0
  72. package/dist/services/skills.js +287 -0
  73. package/dist/services/standing-orders.js +29 -0
  74. package/dist/services/subagents.js +142 -0
  75. package/dist/services/sudo.js +243 -0
  76. package/dist/services/telegram.js +113 -0
  77. package/dist/services/tool-discovery.js +214 -0
  78. package/dist/services/usage-tracker.js +137 -0
  79. package/dist/services/users.js +199 -0
  80. package/dist/services/voice.js +95 -0
  81. package/dist/tui/index.js +507 -0
  82. package/dist/web/canvas.js +30 -0
  83. package/dist/web/doctor-api.js +606 -0
  84. package/dist/web/openai-compat.js +252 -0
  85. package/dist/web/server.js +1351 -0
  86. package/dist/web/setup-api.js +1078 -0
  87. package/docs/mcp.example.json +16 -0
  88. package/docs/screenshots/00-Login.png +0 -0
  89. package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
  90. package/docs/screenshots/02-Chat.png +0 -0
  91. package/docs/screenshots/03-Dashboard-Overview.png +0 -0
  92. package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
  93. package/docs/screenshots/05-Personality-Editor.png +0 -0
  94. package/docs/screenshots/06-Memory-Manager.png +0 -0
  95. package/docs/screenshots/07-Active-Sessions.png +0 -0
  96. package/docs/screenshots/08-File-Browser.png +0 -0
  97. package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
  98. package/docs/screenshots/10-Custom-Tools.png +0 -0
  99. package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
  100. package/docs/screenshots/12-Messaging-Platforms.png +0 -0
  101. package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
  102. package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
  103. package/docs/screenshots/13-User-Management.png +0 -0
  104. package/docs/screenshots/14-Web-Terminal.png +0 -0
  105. package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
  106. package/docs/screenshots/16-Settings-and-Env.png +0 -0
  107. package/docs/screenshots/TG-commands.png +0 -0
  108. package/docs/screenshots/TG.png +0 -0
  109. package/docs/screenshots/_Mac-Installer.png +0 -0
  110. package/docs/tools.example.json +33 -0
  111. package/install.sh +165 -0
  112. package/package.json +190 -0
  113. package/plugins/calendar/index.js +270 -0
  114. package/plugins/email/index.js +231 -0
  115. package/plugins/finance/index.js +254 -0
  116. package/plugins/notes/index.js +227 -0
  117. package/plugins/smarthome/index.js +230 -0
  118. package/plugins/weather/index.js +122 -0
  119. package/skills/apple-notes/SKILL.md +31 -0
  120. package/skills/browse/SKILL.md +136 -0
  121. package/skills/code-project/SKILL.md +43 -0
  122. package/skills/data-analysis/SKILL.md +39 -0
  123. package/skills/document-creation/SKILL.md +48 -0
  124. package/skills/email-summary/SKILL.md +46 -0
  125. package/skills/github/SKILL.md +42 -0
  126. package/skills/summarize/SKILL.md +28 -0
  127. package/skills/system-admin/SKILL.md +39 -0
  128. package/skills/weather/SKILL.md +34 -0
  129. package/skills/web-research/SKILL.md +35 -0
  130. package/web/public/canvas.html +52 -0
  131. package/web/public/css/style.css +555 -0
  132. package/web/public/index.html +189 -0
  133. package/web/public/js/app.js +3102 -0
  134. package/web/public/js/i18n.js +1048 -0
  135. package/web/public/js/icons.js +104 -0
  136. 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
+ }