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,648 @@
1
+ /**
2
+ * WhatsApp Platform Adapter — Baileys Edition
3
+ *
4
+ * Uses @whiskeysockets/baileys (pure WebSocket, no Puppeteer/Chrome).
5
+ * Optional dependency — only loaded if WHATSAPP_ENABLED=true.
6
+ *
7
+ * Features:
8
+ * - Self-chat (Note to Self) as AI notepad
9
+ * - Group chat with per-group + per-contact whitelist
10
+ * - Voice/audio transcription, photo/document processing
11
+ * - Persistent auth via multi-file auth state
12
+ * - Auto-reconnect with backoff
13
+ *
14
+ * Setup:
15
+ * 1. Set WHATSAPP_ENABLED=true in .env (or via Web UI → Platforms)
16
+ * 2. Open Web UI → Platforms → scan the QR code with your phone
17
+ * 3. Start chatting in your "Saved Messages" / self-chat
18
+ */
19
+ import fs from "fs";
20
+ import { dirname, join } from "path";
21
+ import { WHATSAPP_AUTH as AUTH_DIR, WA_GROUPS as GROUP_CONFIG_FILE, WA_MEDIA_DIR } from "../paths.js";
22
+ function loadGroupConfig() {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(GROUP_CONFIG_FILE, "utf-8"));
25
+ }
26
+ catch {
27
+ return { groups: [] };
28
+ }
29
+ }
30
+ function saveGroupConfig(config) {
31
+ const dir = dirname(GROUP_CONFIG_FILE);
32
+ if (!fs.existsSync(dir))
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ fs.writeFileSync(GROUP_CONFIG_FILE, JSON.stringify(config, null, 2));
35
+ }
36
+ export function getGroupRule(groupId) {
37
+ return loadGroupConfig().groups.find(g => g.groupId === groupId);
38
+ }
39
+ export function getGroupRules() {
40
+ return loadGroupConfig().groups;
41
+ }
42
+ export function upsertGroupRule(rule) {
43
+ const config = loadGroupConfig();
44
+ const existing = config.groups.find(g => g.groupId === rule.groupId);
45
+ if (existing) {
46
+ Object.assign(existing, rule, { updatedAt: Date.now() });
47
+ saveGroupConfig(config);
48
+ return existing;
49
+ }
50
+ const newRule = {
51
+ groupId: rule.groupId,
52
+ groupName: rule.groupName || "Unknown Group",
53
+ enabled: rule.enabled ?? false,
54
+ allowedParticipants: rule.allowedParticipants || [],
55
+ participantNames: rule.participantNames || {},
56
+ requireMention: rule.requireMention ?? true,
57
+ allowMedia: rule.allowMedia ?? true,
58
+ requireApproval: rule.requireApproval ?? true,
59
+ updatedAt: Date.now(),
60
+ };
61
+ config.groups.push(newRule);
62
+ saveGroupConfig(config);
63
+ return newRule;
64
+ }
65
+ export function deleteGroupRule(groupId) {
66
+ const config = loadGroupConfig();
67
+ const before = config.groups.length;
68
+ config.groups = config.groups.filter(g => g.groupId !== groupId);
69
+ if (config.groups.length < before) {
70
+ saveGroupConfig(config);
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+ const _pendingApprovals = new Map();
76
+ let _approvalRequestFn = null;
77
+ export function setApprovalRequestFn(fn) {
78
+ _approvalRequestFn = fn;
79
+ }
80
+ export function getPendingApproval(id) {
81
+ return _pendingApprovals.get(id);
82
+ }
83
+ export function removePendingApproval(id) {
84
+ const p = _pendingApprovals.get(id);
85
+ _pendingApprovals.delete(id);
86
+ return p;
87
+ }
88
+ export function getPendingApprovals() {
89
+ return Array.from(_pendingApprovals.values());
90
+ }
91
+ let _approvalChannel = "telegram";
92
+ export function getApprovalChannel() {
93
+ return _approvalChannel;
94
+ }
95
+ export function setApprovalChannel(channel) {
96
+ _approvalChannel = channel;
97
+ }
98
+ export function matchApprovalResponse(text) {
99
+ const t = text.trim().toLowerCase();
100
+ const entries = Array.from(_pendingApprovals.entries());
101
+ if (entries.length === 0)
102
+ return null;
103
+ const approveWords = ["ok", "ja", "yes", "go", "1", "approve"];
104
+ const denyWords = ["nein", "no", "nope", "2", "ablehnen", "deny", "stop"];
105
+ for (const [id] of entries) {
106
+ if (t.includes(id)) {
107
+ const isApprove = approveWords.some(w => t.includes(w));
108
+ return { id, approved: isApprove };
109
+ }
110
+ }
111
+ const [latestId] = entries[entries.length - 1];
112
+ if (approveWords.some(w => t === w || t.startsWith(w + " "))) {
113
+ return { id: latestId, approved: true };
114
+ }
115
+ if (denyWords.some(w => t === w || t.startsWith(w + " "))) {
116
+ return { id: latestId, approved: false };
117
+ }
118
+ return null;
119
+ }
120
+ function cleanupStaleApprovals() {
121
+ const cutoff = Date.now() - 30 * 60_000;
122
+ for (const [id, p] of _pendingApprovals) {
123
+ if (p.timestamp < cutoff) {
124
+ _pendingApprovals.delete(id);
125
+ if (p.incoming.media?.path)
126
+ fs.unlink(p.incoming.media.path, () => { });
127
+ }
128
+ }
129
+ }
130
+ setInterval(cleanupStaleApprovals, 5 * 60_000);
131
+ let _whatsappState = {
132
+ status: "disconnected",
133
+ qrString: null,
134
+ qrTimestamp: null,
135
+ connectedAt: null,
136
+ error: null,
137
+ info: null,
138
+ };
139
+ export function getWhatsAppState() {
140
+ return { ..._whatsappState };
141
+ }
142
+ // ── JID Helpers & Contact Cache ────────────────────────────────────────────
143
+ function normalizeJid(jid) {
144
+ return jid.replace(/:.*@/, "@");
145
+ }
146
+ /** In-memory contact name cache: JID → display name. Populated from incoming messages. */
147
+ const _contactNames = new Map();
148
+ /** Persist contact cache to disk for survival across restarts */
149
+ const CONTACT_CACHE_FILE = join(AUTH_DIR, "contact-names.json");
150
+ function loadContactCache() {
151
+ try {
152
+ const data = JSON.parse(fs.readFileSync(CONTACT_CACHE_FILE, "utf-8"));
153
+ for (const [k, v] of Object.entries(data)) {
154
+ _contactNames.set(k, v);
155
+ }
156
+ }
157
+ catch { /* first run */ }
158
+ }
159
+ function saveContactCache() {
160
+ try {
161
+ if (!fs.existsSync(AUTH_DIR))
162
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
163
+ fs.writeFileSync(CONTACT_CACHE_FILE, JSON.stringify(Object.fromEntries(_contactNames), null, 2));
164
+ }
165
+ catch { /* non-critical */ }
166
+ }
167
+ /** Remember a contact's display name */
168
+ function cacheContactName(jid, name) {
169
+ if (!name || name === jid)
170
+ return;
171
+ const normalized = normalizeJid(jid);
172
+ if (_contactNames.get(normalized) !== name) {
173
+ _contactNames.set(normalized, name);
174
+ saveContactCache();
175
+ }
176
+ }
177
+ /** Get a contact's display name (cached push name > phone number > raw JID) */
178
+ export function getContactDisplayName(jid) {
179
+ const normalized = normalizeJid(jid);
180
+ const cached = _contactNames.get(normalized);
181
+ if (cached)
182
+ return cached;
183
+ // If it's a LID (no phone number embedded), show as-is
184
+ if (jid.includes("@lid"))
185
+ return jid.replace(/@lid$/, "");
186
+ // Otherwise extract phone number
187
+ const num = jidToNumber(jid);
188
+ return num.startsWith("49") ? `+${num}` : num || jid;
189
+ }
190
+ // Load cache at module init
191
+ loadContactCache();
192
+ function jidToNumber(jid) {
193
+ return jid.replace(/@.*$/, "").replace(/:.*$/, "");
194
+ }
195
+ // ── Adapter ────────────────────────────────────────────────────────────────
196
+ let _adapterInstance = null;
197
+ export function getWhatsAppAdapter() {
198
+ return _adapterInstance;
199
+ }
200
+ export class WhatsAppAdapter {
201
+ platform = "whatsapp";
202
+ handler = null;
203
+ sock = null;
204
+ // Loop prevention
205
+ botSentIds = new Set();
206
+ botSentTexts = new Set();
207
+ // Reconnect state
208
+ reconnectAttempt = 0;
209
+ maxReconnectAttempts = 10;
210
+ constructor() {
211
+ _adapterInstance = this;
212
+ }
213
+ async start() {
214
+ _whatsappState = {
215
+ status: "connecting", qrString: null, qrTimestamp: null,
216
+ connectedAt: null, error: null, info: null,
217
+ };
218
+ await this.connect();
219
+ }
220
+ async connect() {
221
+ let baileys;
222
+ try {
223
+ baileys = await import("@whiskeysockets/baileys");
224
+ }
225
+ catch {
226
+ const msg = "@whiskeysockets/baileys not installed. Run: npm install @whiskeysockets/baileys";
227
+ _whatsappState = { ..._whatsappState, status: "error", error: msg };
228
+ console.error(`\u274C WhatsApp: ${msg}`);
229
+ throw new Error(msg);
230
+ }
231
+ const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, Browsers, getContentType, downloadMediaMessage, } = baileys;
232
+ const P = (await import("pino")).default;
233
+ const { Boom } = await import("@hapi/boom");
234
+ const logger = P({ level: "silent" });
235
+ const authDir = join(AUTH_DIR, "baileys-auth");
236
+ if (!fs.existsSync(authDir))
237
+ fs.mkdirSync(authDir, { recursive: true });
238
+ const { state, saveCreds } = await useMultiFileAuthState(authDir);
239
+ const { version } = await fetchLatestBaileysVersion();
240
+ const sock = makeWASocket({
241
+ version,
242
+ auth: {
243
+ creds: state.creds,
244
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
245
+ },
246
+ logger,
247
+ printQRInTerminal: false,
248
+ browser: Browsers.ubuntu("Alvin Bot"),
249
+ markOnlineOnConnect: false,
250
+ generateHighQualityLinkPreview: false,
251
+ });
252
+ this.sock = sock;
253
+ // Save credentials on update
254
+ sock.ev.on("creds.update", saveCreds);
255
+ // Connection state
256
+ sock.ev.on("connection.update", (update) => {
257
+ const { connection, lastDisconnect, qr } = update;
258
+ if (qr) {
259
+ _whatsappState.status = "qr";
260
+ _whatsappState.qrString = qr;
261
+ _whatsappState.qrTimestamp = Date.now();
262
+ _whatsappState.error = null;
263
+ console.log("\uD83D\uDCF1 WhatsApp: QR code ready \u2014 scan via Web UI \u2192 Platforms");
264
+ }
265
+ if (connection === "open") {
266
+ _whatsappState.status = "connected";
267
+ _whatsappState.qrString = null;
268
+ _whatsappState.connectedAt = Date.now();
269
+ _whatsappState.error = null;
270
+ _whatsappState.info = sock.user?.name || sock.user?.id || null;
271
+ this.reconnectAttempt = 0;
272
+ console.log(`\uD83D\uDCF1 WhatsApp connected (${_whatsappState.info || "unknown"})`);
273
+ // Send welcome to self-chat
274
+ const myJid = sock.user?.id;
275
+ if (myJid) {
276
+ sock.sendMessage(myJid, { text: "\uD83E\uDD16 *Alvin Bot is now connected on WhatsApp!*\n\nSchreib hier (Eigene Nachrichten) um mit mir zu chatten." }).catch(() => { });
277
+ }
278
+ }
279
+ if (connection === "close") {
280
+ const statusCode = lastDisconnect?.error?.output?.statusCode;
281
+ if (statusCode === DisconnectReason.loggedOut) {
282
+ _whatsappState.status = "logged_out";
283
+ _whatsappState.error = "Logged out. Delete auth and re-scan QR.";
284
+ console.log("\uD83D\uDCF1 WhatsApp: Logged out");
285
+ // Clear auth for fresh start
286
+ fs.rmSync(authDir, { recursive: true, force: true });
287
+ }
288
+ else if (this.reconnectAttempt < this.maxReconnectAttempts) {
289
+ this.reconnectAttempt++;
290
+ const delay = Math.min(3000 * this.reconnectAttempt, 30000);
291
+ _whatsappState.status = "connecting";
292
+ _whatsappState.error = `Reconnecting (attempt ${this.reconnectAttempt})...`;
293
+ console.log(`\uD83D\uDCF1 WhatsApp: Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempt})`);
294
+ setTimeout(() => this.connect().catch(console.error), delay);
295
+ }
296
+ else {
297
+ _whatsappState.status = "error";
298
+ _whatsappState.error = "Max reconnect attempts reached";
299
+ console.error("\u274C WhatsApp: Max reconnect attempts reached");
300
+ }
301
+ }
302
+ });
303
+ // Contact cache: learn names from Baileys contact sync
304
+ sock.ev.on("contacts.upsert", (contacts) => {
305
+ for (const c of contacts) {
306
+ if (c.id && (c.notify || c.name)) {
307
+ cacheContactName(c.id, c.notify || c.name);
308
+ }
309
+ }
310
+ });
311
+ sock.ev.on("contacts.update", (updates) => {
312
+ for (const c of updates) {
313
+ if (c.id && (c.notify || c.name)) {
314
+ cacheContactName(c.id, c.notify || c.name);
315
+ }
316
+ }
317
+ });
318
+ // Message handler
319
+ sock.ev.on("messages.upsert", async ({ messages, type }) => {
320
+ if (type !== "notify")
321
+ return;
322
+ for (const msg of messages) {
323
+ // Cache sender push name from every incoming message
324
+ if (msg.pushName && msg.key?.participant) {
325
+ cacheContactName(msg.key.participant, msg.pushName);
326
+ }
327
+ else if (msg.pushName && msg.key?.remoteJid && !msg.key.remoteJid.endsWith("@g.us")) {
328
+ cacheContactName(msg.key.remoteJid, msg.pushName);
329
+ }
330
+ try {
331
+ await this.handleIncomingMessage(msg, sock, getContentType, downloadMediaMessage);
332
+ }
333
+ catch (err) {
334
+ console.error("WhatsApp message handler error:", err instanceof Error ? err.message : err);
335
+ }
336
+ }
337
+ });
338
+ }
339
+ // ── Message Processing ──────────────────────────────────────────────────────
340
+ async handleIncomingMessage(msg, sock, getContentType, downloadMediaMessage) {
341
+ if (!this.handler)
342
+ return;
343
+ if (!msg.message)
344
+ return;
345
+ const jid = msg.key.remoteJid;
346
+ if (!jid)
347
+ return;
348
+ // Skip newsletters/broadcasts/status
349
+ if (jid.endsWith("@newsletter") || jid.endsWith("@broadcast") || jid === "status@broadcast")
350
+ return;
351
+ const msgType = getContentType(msg.message);
352
+ if (!msgType)
353
+ return;
354
+ // Extract text
355
+ const text = (msg.message.conversation
356
+ || msg.message.extendedTextMessage?.text
357
+ || msg.message.imageMessage?.caption
358
+ || msg.message.videoMessage?.caption
359
+ || msg.message.documentMessage?.caption
360
+ || "").trim();
361
+ const isVoice = msgType === "audioMessage" && msg.message.audioMessage?.ptt === true;
362
+ const isAudio = msgType === "audioMessage" && !msg.message.audioMessage?.ptt;
363
+ const isImage = msgType === "imageMessage" || msgType === "stickerMessage";
364
+ const isDocument = msgType === "documentMessage";
365
+ const isVideo = msgType === "videoMessage";
366
+ const hasMedia = isVoice || isAudio || isImage || isDocument || isVideo;
367
+ if (!text && !hasMedia)
368
+ return;
369
+ // Loop prevention
370
+ const msgId = msg.key.id || "";
371
+ if (this.botSentIds.has(msgId)) {
372
+ this.botSentIds.delete(msgId);
373
+ return;
374
+ }
375
+ const fromMe = msg.key.fromMe === true;
376
+ const isGroup = jid.endsWith("@g.us");
377
+ const isSelf = this.isSelfChat(jid);
378
+ // Skip own messages in groups and DMs (but allow self-chat)
379
+ if (fromMe && !isSelf)
380
+ return;
381
+ // Loop prevention for self-chat
382
+ if (fromMe && text && this.botSentTexts.has(text.substring(0, 100)))
383
+ return;
384
+ // ── Access control ─────────────────────────────────────────────
385
+ const selfChatOnly = process.env.WHATSAPP_SELF_CHAT_ONLY === "true";
386
+ const allowGroups = process.env.WHATSAPP_ALLOW_GROUPS === "true";
387
+ if (isSelf) {
388
+ // Self-chat: check approval responses
389
+ if (text && _approvalChannel === "whatsapp" && _pendingApprovals.size > 0) {
390
+ const match = matchApprovalResponse(text);
391
+ if (match) {
392
+ const pending = removePendingApproval(match.id);
393
+ if (pending) {
394
+ if (match.approved) {
395
+ await this.sendText(jid, `\u2705 Approved: ${pending.senderName} in ${pending.groupName}`);
396
+ if (this.handler)
397
+ await this.handler(pending.incoming);
398
+ }
399
+ else {
400
+ await this.sendText(jid, `\u274C Abgelehnt: ${pending.senderName}`);
401
+ if (pending.incoming.media?.path)
402
+ fs.unlink(pending.incoming.media.path, () => { });
403
+ }
404
+ return;
405
+ }
406
+ }
407
+ }
408
+ }
409
+ else if (isGroup) {
410
+ if (selfChatOnly || !allowGroups)
411
+ return;
412
+ const rule = getGroupRule(jid);
413
+ if (!rule || !rule.enabled)
414
+ return;
415
+ // Participant whitelist
416
+ const senderId = msg.key.participant || "";
417
+ if (rule.allowedParticipants.length > 0) {
418
+ const senderNorm = jidToNumber(senderId);
419
+ const allowed = rule.allowedParticipants.some(p => jidToNumber(p) === senderNorm);
420
+ if (!allowed)
421
+ return;
422
+ }
423
+ // Mention requirement
424
+ if (rule.requireMention) {
425
+ const botName = sock.user?.name || "Alvin Bot";
426
+ const myJid = sock.user?.id || "";
427
+ const mentionedJids = msg.message.extendedTextMessage?.contextInfo?.mentionedJid || [];
428
+ const nativeMention = mentionedJids.some((m) => normalizeJid(m) === normalizeJid(myJid));
429
+ const textMention = text && (text.toLowerCase().includes("@alvin") ||
430
+ text.toLowerCase().includes("@bot") ||
431
+ text.toLowerCase().includes(botName.toLowerCase()));
432
+ if (!nativeMention && !textMention && !hasMedia)
433
+ return;
434
+ }
435
+ if (hasMedia && !rule.allowMedia && !text)
436
+ return;
437
+ }
438
+ else {
439
+ // DM
440
+ if (selfChatOnly)
441
+ return;
442
+ if (process.env.WHATSAPP_ALLOW_DMS !== "true")
443
+ return;
444
+ }
445
+ // ── Download media ─────────────────────────────────────────────
446
+ let mediaInfo = undefined;
447
+ if (hasMedia) {
448
+ try {
449
+ const buffer = await downloadMediaMessage(msg, "buffer", {}, {
450
+ reuploadRequest: sock.updateMediaMessage,
451
+ });
452
+ if (!fs.existsSync(WA_MEDIA_DIR))
453
+ fs.mkdirSync(WA_MEDIA_DIR, { recursive: true });
454
+ if (isVoice || isAudio) {
455
+ const mime = msg.message.audioMessage?.mimetype || "audio/ogg";
456
+ const ext = mime.includes("ogg") ? "ogg" : "mp3";
457
+ const path = join(WA_MEDIA_DIR, `wa_voice_${Date.now()}.${ext}`);
458
+ fs.writeFileSync(path, buffer);
459
+ mediaInfo = { type: "voice", path, mimeType: mime };
460
+ }
461
+ else if (isImage) {
462
+ const mime = msg.message.imageMessage?.mimetype || msg.message.stickerMessage?.mimetype || "image/jpeg";
463
+ const ext = mime.includes("png") ? "png" : mime.includes("webp") ? "webp" : "jpg";
464
+ const path = join(WA_MEDIA_DIR, `wa_photo_${Date.now()}.${ext}`);
465
+ fs.writeFileSync(path, buffer);
466
+ mediaInfo = { type: "photo", path, mimeType: mime };
467
+ }
468
+ else if (isDocument) {
469
+ const fileName = msg.message.documentMessage?.fileName || `wa_doc_${Date.now()}`;
470
+ const mime = msg.message.documentMessage?.mimetype || "application/octet-stream";
471
+ const path = join(WA_MEDIA_DIR, fileName);
472
+ fs.writeFileSync(path, buffer);
473
+ mediaInfo = { type: "document", path, mimeType: mime, fileName };
474
+ }
475
+ else if (isVideo) {
476
+ const mime = msg.message.videoMessage?.mimetype || "video/mp4";
477
+ const ext = mime.includes("mp4") ? "mp4" : "webm";
478
+ const path = join(WA_MEDIA_DIR, `wa_video_${Date.now()}.${ext}`);
479
+ fs.writeFileSync(path, buffer);
480
+ mediaInfo = { type: "video", path, mimeType: mime };
481
+ }
482
+ }
483
+ catch (err) {
484
+ console.error(`WhatsApp: Failed to download ${msgType}:`, err instanceof Error ? err.message : err);
485
+ }
486
+ }
487
+ // ── Build IncomingMessage ──────────────────────────────────────
488
+ const senderName = isSelf
489
+ ? (sock.user?.name || "User")
490
+ : (msg.pushName || jidToNumber(msg.key.participant || jid));
491
+ const quoted = msg.message.extendedTextMessage?.contextInfo?.quotedMessage;
492
+ const quotedText = quoted?.conversation || quoted?.extendedTextMessage?.text || undefined;
493
+ const incoming = {
494
+ platform: "whatsapp",
495
+ messageId: msgId,
496
+ chatId: jid,
497
+ userId: isSelf ? "self" : (msg.key.participant || jid),
498
+ userName: senderName,
499
+ text: text || "",
500
+ isGroup,
501
+ isMention: isGroup && !!text && (text.includes("@alvin") || text.includes("@bot")),
502
+ isReplyToBot: false,
503
+ replyToText: quotedText,
504
+ media: mediaInfo,
505
+ };
506
+ // ── Approval gate ─────────────────────────────────────────────
507
+ if (isGroup && !isSelf && !fromMe) {
508
+ const rule = getGroupRule(jid);
509
+ if (rule?.requireApproval && _approvalRequestFn) {
510
+ const approvalId = `wa_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
511
+ let preview = text || "";
512
+ if (preview.length > 200)
513
+ preview = preview.slice(0, 200) + "\u2026";
514
+ if (hasMedia && !preview) {
515
+ const labels = { audioMessage: "\uD83C\uDFA4 Voice", imageMessage: "\uD83D\uDCF7 Image", documentMessage: "\uD83D\uDCC4 Document", videoMessage: "\uD83C\uDFAC Video", stickerMessage: "\uD83C\uDFF7 Sticker" };
516
+ preview = labels[msgType || ""] || `\uD83D\uDCCE ${msgType}`;
517
+ }
518
+ else if (hasMedia) {
519
+ preview = `\uD83D\uDCCE +Media: ${preview}`;
520
+ }
521
+ const pending = {
522
+ id: approvalId,
523
+ incoming,
524
+ groupName: jid, // Will be resolved by caller if needed
525
+ senderName,
526
+ senderNumber: jidToNumber(msg.key.participant || jid),
527
+ preview,
528
+ mediaType: hasMedia ? msgType : undefined,
529
+ timestamp: Date.now(),
530
+ };
531
+ _pendingApprovals.set(approvalId, pending);
532
+ await _approvalRequestFn(pending);
533
+ return;
534
+ }
535
+ }
536
+ await this.handler(incoming);
537
+ }
538
+ isSelfChat(jid) {
539
+ const myJid = this.sock?.user?.id;
540
+ if (!myJid)
541
+ return false;
542
+ return normalizeJid(jid) === normalizeJid(myJid);
543
+ }
544
+ // ── Public API: Groups ────────────────────────────────────────────────────
545
+ async getGroups() {
546
+ if (!this.sock || _whatsappState.status !== "connected")
547
+ return [];
548
+ try {
549
+ const groups = await this.sock.groupFetchAllParticipating();
550
+ return Object.values(groups).map((g) => ({
551
+ id: g.id,
552
+ name: g.subject || "Unnamed Group",
553
+ participantCount: g.participants?.length || 0,
554
+ })).sort((a, b) => a.name.localeCompare(b.name));
555
+ }
556
+ catch (err) {
557
+ console.error("WhatsApp: Failed to fetch groups:", err);
558
+ return [];
559
+ }
560
+ }
561
+ async getGroupParticipants(groupId) {
562
+ if (!this.sock || _whatsappState.status !== "connected")
563
+ return [];
564
+ try {
565
+ const meta = await this.sock.groupMetadata(groupId);
566
+ const myJid = this.sock.user?.id || "";
567
+ return (meta.participants || [])
568
+ .filter((p) => normalizeJid(p.id) !== normalizeJid(myJid))
569
+ .map((p) => ({
570
+ id: p.id,
571
+ name: getContactDisplayName(p.id),
572
+ isAdmin: p.admin === "admin" || p.admin === "superadmin",
573
+ number: jidToNumber(p.id),
574
+ }))
575
+ .sort((a, b) => a.name.localeCompare(b.name));
576
+ }
577
+ catch (err) {
578
+ console.error("WhatsApp: Failed to fetch participants:", err);
579
+ return [];
580
+ }
581
+ }
582
+ // ── Sending ──────────────────────────────────────────────────────────────
583
+ async sendText(chatId, text) {
584
+ if (!this.sock)
585
+ return;
586
+ const textHash = text.substring(0, 100);
587
+ this.botSentTexts.add(textHash);
588
+ setTimeout(() => this.botSentTexts.delete(textHash), 30_000);
589
+ const sent = await this.sock.sendMessage(chatId, { text });
590
+ if (sent?.key?.id) {
591
+ this.botSentIds.add(sent.key.id);
592
+ setTimeout(() => this.botSentIds.delete(sent.key.id), 60_000);
593
+ }
594
+ }
595
+ async sendPhoto(chatId, photo, caption) {
596
+ if (!this.sock)
597
+ return;
598
+ const image = typeof photo === "string" ? { url: photo } : photo;
599
+ await this.sock.sendMessage(chatId, { image, caption });
600
+ }
601
+ async sendDocument(chatId, doc, fileName, caption) {
602
+ if (!this.sock)
603
+ return;
604
+ const document = typeof doc === "string" ? { url: doc } : doc;
605
+ await this.sock.sendMessage(chatId, { document, fileName, mimetype: "application/octet-stream", caption });
606
+ }
607
+ async sendVoice(chatId, audio) {
608
+ if (!this.sock)
609
+ return;
610
+ const audioData = typeof audio === "string" ? { url: audio } : audio;
611
+ await this.sock.sendMessage(chatId, { audio: audioData, mimetype: "audio/ogg; codecs=opus", ptt: true });
612
+ }
613
+ async react(chatId, messageId, emoji) {
614
+ if (!this.sock)
615
+ return;
616
+ await this.sock.sendMessage(chatId, { react: { text: emoji, key: { remoteJid: chatId, id: messageId } } });
617
+ }
618
+ async setTyping(chatId) {
619
+ if (!this.sock)
620
+ return;
621
+ try {
622
+ await this.sock.sendPresenceUpdate("composing", chatId);
623
+ }
624
+ catch { /* ignore */ }
625
+ }
626
+ getOwnerChatId() {
627
+ return this.sock?.user?.id || null;
628
+ }
629
+ async processApprovedMessage(incoming) {
630
+ if (!this.handler)
631
+ return;
632
+ await this.handler(incoming);
633
+ }
634
+ async stop() {
635
+ if (this.sock) {
636
+ try {
637
+ this.sock.end(undefined);
638
+ }
639
+ catch { /* ignore */ }
640
+ this.sock = null;
641
+ }
642
+ _whatsappState.status = "disconnected";
643
+ _adapterInstance = null;
644
+ }
645
+ onMessage(handler) {
646
+ this.handler = handler;
647
+ }
648
+ }