whatsapp-pi 1.0.45 → 1.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ A WhatsApp integration extension for the **[Pi Coding Agent](https://github.com/
8
8
 
9
9
  [![GitHub](https://img.shields.io/badge/github-repo-black.svg?style=flat-square&logo=github)](https://github.com/RaphaCastelloes/whatsapp-pi)
10
10
 
11
- Pi is a powerful agentic AI coding assistant that operates in your terminal. This extension allows you to chat and pair-program with your Pi agent directly through WhatsApp, featuring message filtering, allow-listing, and reliable message delivery.
11
+ Pi is a powerful agentic AI coding assistant that operates in your terminal. This extension allows you to chat and pair-program with your Pi agent directly through WhatsApp, featuring message filtering, allow-listing, recents/history browsing, message detail/reply, group-only binding, and reliable message delivery.
12
12
 
13
13
  ## Features
14
14
 
@@ -16,10 +16,14 @@ Pi is a powerful agentic AI coding assistant that operates in your terminal. Thi
16
16
  - **Allow List**: Control which numbers can interact with Pi
17
17
  - Add contacts with optional names for easy identification
18
18
  - View ignored numbers (not in allow list) and add them when needed
19
+ - Manage aliases and print allowed numbers from the menu
20
+ - **Recents & History**: Browse recent conversations, inspect full message history, and reply from message detail view
19
21
  - **Reliable Messaging**: Queue-based message sending with retry logic
20
- - **TUI Integration**: Menu-driven interface for managing connections and contacts
22
+ - **TUI Integration**: Menu-driven interface for managing connections, contacts, and recent chats
23
+ - **Group-Only Mode**: Bind the agent to a single WhatsApp group with `--whatsapp-group`
21
24
  - **Media Support**:
22
25
  - **Vision Analysis**: Automatically forwards WhatsApp images to Pi for analysis.
26
+ - **Audio Transcription**: Transcribes voice notes when Whisper is installed.
23
27
  - **Document Handling**: Downloads and stores documents (PDF, text) for agent access.
24
28
 
25
29
  ## Prerequisites
@@ -46,7 +50,7 @@ pi install npm:whatsapp-pi
46
50
  pi
47
51
  ```
48
52
 
49
- To automatically connect to WhatsApp on startup (if you are already authenticated):
53
+ After connecting WhatsApp once from the menu and scanning the QR code, you can start Pi with auto-connect enabled:
50
54
  ```bash
51
55
  pi --whatsapp-pi-online
52
56
  ```
@@ -74,6 +78,11 @@ For verbose mode (shows Baileys trace logs for debugging):
74
78
  pi -e whatsapp-pi.ts --verbose
75
79
  ```
76
80
 
81
+ To test startup auto-connect locally after you have already paired WhatsApp:
82
+ ```bash
83
+ pi -e whatsapp-pi.ts --whatsapp-pi-online
84
+ ```
85
+
77
86
  ## Commands
78
87
 
79
88
  - `/whatsapp` - Open the WhatsApp management menu
@@ -82,12 +91,21 @@ pi -e whatsapp-pi.ts --verbose
82
91
  - **Connect / Reconnect WhatsApp** - Start WhatsApp connection using saved credentials when available; QR code appears only if pairing is required
83
92
  - **Disconnect WhatsApp** - Stop WhatsApp connection
84
93
  - **Logoff (Delete Session)** - Remove all credentials and session data
94
+ - **Recents** - Open recent conversations, view history, and reply
85
95
  - **Allowed Numbers** - Manage contacts that can interact with Pi
86
96
  - **Blocked Numbers** - View ignored numbers and manage them
87
97
 
88
98
  ### Allowed Numbers Management
89
99
  - **Add Number** - Add a new contact to the allow list (format: +5511999999999)
90
- - **Select a contact** - Open a submenu with **Send Message**, **Remove Number**, and **Back**
100
+ - **Select a contact** - Open a submenu with **History**, **Send Message**, **Print Number**, alias actions, **Remove Number**, and **Back**
101
+ - **Back** - Return to main menu
102
+
103
+ ### Recents Management
104
+ - **History** - Open full message history for that conversation
105
+ - **Send Message** - Send a new message without Pi suffix
106
+ - **Reply** - Open message detail, then press `R` to reply
107
+ - **Allow Number** - Move a recent sender into the allow list
108
+ - **Remove Alias** - Clear saved alias for that sender
91
109
  - **Back** - Return to main menu
92
110
 
93
111
  ### Blocked Numbers Management
@@ -101,11 +119,11 @@ pi -e whatsapp-pi.ts --verbose
101
119
  ```
102
120
  src/
103
121
  ├── models/ # Type definitions
104
- ├── services/ # Core services (WhatsApp, Session, MessageSender)
105
- └── ui/ # Menu handlers
122
+ ├── services/ # Core services (WhatsApp, Session, Recents, Media)
123
+ └── ui/ # Menu handlers and TUI views
106
124
 
107
- specs/ # Feature specifications
108
- tests/ # Unit and integration tests
125
+ tests/
126
+ └── unit/ # Unit tests
109
127
  ```
110
128
 
111
129
  ## Development
@@ -117,15 +135,13 @@ npm test
117
135
 
118
136
  ## Implementation Notes
119
137
 
120
- ### Recent Feature Updates (2026-04)
121
-
122
- - **Auto-Connect Support**: Use the `--whatsapp-pi-online` flag to automatically connect to WhatsApp on startup if you have a valid active session.
123
- - **Vision Analysis**: Images sent via WhatsApp are automatically downloaded and forwarded to the Pi agent as base64, enabling vision-based interactions.
124
- - **Document Message Support**:
125
- - WhatsApp documents (PDFs, text files, etc.) are downloaded and saved to `./.pi-data/whatsapp/documents/`.
126
- - The Pi agent receives a notification with the file path and metadata.
127
- - **Full US-English Localization**: All user-facing menus, TUI notifications, console logs, and agent communication headers have been localized to `en-US` for a consistent experience.
128
- - **Intelligent Message Filtering**:
129
- - **Loop Prevention**: The bot automatically ignores any message ending with the `π` symbol, preventing infinite loops between instances.
130
- - **Manual Interaction**: Users can now interact with the bot from their own WhatsApp account; the bot will process `fromMe` messages as long as they don't contain the bot's signature.
131
- - **Storage Management**: All persistent data (auth state, documents, config) is centralized in the `.pi-data/` directory.
138
+ ### Recent Feature Updates (2026-05)
139
+
140
+ - **Auto-Connect Support**: Use the `--whatsapp-pi-online` flag to connect on startup when credentials already exist.
141
+ - **Group-Only Mode**: Use `--whatsapp-group <jid>` to bind Pi to a single WhatsApp group.
142
+ - **Recents Store**: Recent conversations and message history are persisted in `~/.pi/whatsapp-pi/recents/recents.json`.
143
+ - **Message Detail / Reply**: Open a message from history to inspect full content and reply with `R`.
144
+ - **Media Support**: Images are forwarded for vision analysis, audio is transcribed with Whisper, and documents are saved under `./.pi-data/whatsapp/documents/`.
145
+ - **Session Handling**: Saved state, allow list, and startup reconnects are restored automatically when available.
146
+ - **Intelligent Message Filtering**: Messages ending with `π` are ignored to prevent bot loops.
147
+ - **Storage Management**: Persistent data lives under `.pi-data/` plus the recents store in the user home directory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.45",
3
+ "version": "1.0.47",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -32,7 +32,7 @@
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@whiskeysockets/baileys": "^6.11.0",
35
+ "baileys": "^6.7.21",
36
36
  "pino": "^10.3.1",
37
37
  "qrcode-terminal": "^0.12.0"
38
38
  },
@@ -1,4 +1,4 @@
1
- import { downloadContentFromMessage } from '@whiskeysockets/baileys';
1
+ import { downloadContentFromMessage } from 'baileys';
2
2
  import { exec } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { writeFile, mkdir } from 'node:fs/promises';
@@ -1,4 +1,4 @@
1
- import { downloadContentFromMessage } from '@whiskeysockets/baileys';
1
+ import { downloadContentFromMessage } from 'baileys';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { AudioService } from './audio.service.js';
@@ -102,7 +102,7 @@ export class IncomingMediaService {
102
102
  }
103
103
 
104
104
  private async saveDocument(fileName: string, buffer: Buffer): Promise<string> {
105
- const sanitized = fileName.replace(/[^a-z0-9._-]/gi, '_');
105
+ const sanitized = fileName.replace(/[^a-z0-9._-]/gi, '_');
106
106
  const savedFileName = `${Date.now()}_${sanitized}`;
107
107
  const documentDir = join(process.cwd(), '.pi-data', 'whatsapp', 'documents');
108
108
  const absolutePath = join(documentDir, savedFileName);
@@ -1,4 +1,4 @@
1
- import { extractMessageContent } from '@whiskeysockets/baileys';
1
+ import { extractMessageContent } from 'baileys';
2
2
 
3
3
  export type IncomingResolution =
4
4
  | { kind: 'text'; text: string }
@@ -1,5 +1,13 @@
1
1
  import { WhatsAppService } from './whatsapp.service.js';
2
2
  import { MessageRequest, MessageResult, WhatsAppError } from '../models/whatsapp.types.js';
3
+ import { appendFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { homedir } from 'os';
6
+
7
+ const LOG_FILE = join(homedir(), '.pi', 'whatsapp-pi', 'whatsapp-pi.log');
8
+ function fileLog(msg: string) {
9
+ try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [MessageSender] ${msg}\n`); } catch {}
10
+ }
3
11
 
4
12
  export class MessageSender {
5
13
  private whatsappService: WhatsAppService;
@@ -37,7 +45,10 @@ export class MessageSender {
37
45
  * @returns Promise resolving to a result object indicating success or failure.
38
46
  */
39
47
  public async send(request: MessageRequest): Promise<MessageResult> {
40
- const maxRetries = request.options?.maxRetries ?? 3;
48
+ const isGroup = request.recipientJid.endsWith('@g.us');
49
+ // Groups need more retries because the first send bootstraps
50
+ // the Signal sender-key session (causes "No sessions" on first attempts)
51
+ const maxRetries = isGroup ? 5 : (request.options?.maxRetries ?? 3);
41
52
  let attempts = 0;
42
53
  let lastError: unknown = null;
43
54
 
@@ -53,12 +64,18 @@ export class MessageSender {
53
64
  throw new WhatsAppError('SOCKET_NOT_INIT', 'WhatsApp socket not initialized');
54
65
  }
55
66
 
56
- // 3. Send the message
67
+ // 3. Pre-load group metadata on first attempt
68
+ if (isGroup && attempts === 1) {
69
+ await this.whatsappService.prepareGroupSession(request.recipientJid);
70
+ }
71
+
72
+ // 4. Send the message
57
73
  // Note: Branding π is applied here to ensure consistency
58
74
  const response = await socket.sendMessage(request.recipientJid, {
59
75
  text: `${request.text} π`
60
76
  });
61
77
 
78
+ fileLog(`SUCCESS sending to ${request.recipientJid} on attempt ${attempts}`);
62
79
  return {
63
80
  success: true,
64
81
  messageId: response?.key?.id,
@@ -66,19 +83,24 @@ export class MessageSender {
66
83
  };
67
84
  } catch (error: unknown) {
68
85
  lastError = error;
69
- console.error(`[MessageSender] Attempt ${attempts} failed for ${request.recipientJid}: ${error instanceof Error ? error.message : String(error)}`);
86
+ const errorMsg = error instanceof Error ? error.message : String(error);
87
+ fileLog(`Attempt ${attempts}/${maxRetries} FAILED for ${request.recipientJid}: ${errorMsg}`);
88
+ console.error(`[MessageSender] Attempt ${attempts} failed for ${request.recipientJid}: ${errorMsg}`);
70
89
 
71
90
  // Specific handling for non-retryable errors
72
91
  if (error instanceof WhatsAppError && error.code === 'TIMEOUT') {
73
92
  break;
74
93
  }
75
94
 
76
- // 4. Backoff before retry
95
+ // 5. Backoff before retry
77
96
  if (attempts < maxRetries) {
78
- const backoff = Math.pow(2, attempts) * 1000;
79
- if (this.whatsappService.isVerbose()) {
80
- console.log(`[MessageSender] Retrying in ${backoff}ms...`);
81
- }
97
+ // "No sessions" in groups needs much longer waits —
98
+ // Baileys syncs sender-keys in the background between retries
99
+ const isNoSessions = errorMsg.includes('No sessions');
100
+ const baseBackoff = (isGroup && isNoSessions) ? 5000 : 1000;
101
+ const backoff = Math.pow(2, attempts) * baseBackoff;
102
+ fileLog(`Retrying in ${backoff / 1000}s...`);
103
+ console.log(`[MessageSender] Retrying in ${backoff / 1000}s...`);
82
104
  await this.sleep(backoff);
83
105
  }
84
106
  }
@@ -137,6 +137,8 @@ export class RecentsService {
137
137
  }
138
138
 
139
139
  private normalizeNumber(input: string): string {
140
+ // Group JIDs should be stored as-is
141
+ if (input.endsWith('@g.us')) return input;
140
142
  const cleaned = input.replace(/@s\.whatsapp\.net$/, '');
141
143
  if (cleaned.startsWith('+')) {
142
144
  return cleaned;
@@ -1,33 +1,46 @@
1
- import { useMultiFileAuthState } from '@whiskeysockets/baileys';
2
- import { join } from 'path';
3
- import { readFile, writeFile, mkdir, rm, rename } from 'fs/promises';
1
+ import { useMultiFileAuthState } from 'baileys';
2
+ import { join } from 'path';
3
+ import { readFile, writeFile, mkdir, rm, rename } from 'fs/promises';
4
4
  import { homedir } from 'os';
5
5
  import { SessionStatus } from '../models/whatsapp.types.js';
6
6
 
7
- export interface Contact {
8
- number: string;
9
- name?: string;
10
- }
11
-
12
- export class SessionManager {
13
- // Data is stored in the user's home directory to persist across updates
14
- private readonly baseDir: string;
15
- private readonly authStateDir: string;
16
- private readonly configPath: string;
7
+ export interface Contact {
8
+ number: string;
9
+ name?: string;
10
+ }
11
+
12
+ export class SessionManager {
13
+ // Data is stored in the user's home directory to persist across updates
14
+ private readonly baseDir = join(homedir(), '.pi', 'whatsapp-pi');
15
+ private authStateDir = join(this.baseDir, 'auth');
16
+ private readonly configPath = join(this.baseDir, 'config.json');
17
+
18
+ static isGroupJid(jid: string): boolean {
19
+ return jid.endsWith('@g.us');
20
+ }
21
+
22
+ /**
23
+ * Sets a group-specific auth directory so each agent bound to a group
24
+ * registers as its own WhatsApp linked device.
25
+ */
26
+ setGroupJidForAuth(groupJid: string) {
27
+ const sanitized = groupJid.replace(/[^a-zA-Z0-9]/g, '_');
28
+ this.authStateDir = join(this.baseDir, `auth-${sanitized}`);
29
+ }
17
30
 
18
31
  private status: SessionStatus = 'logged-out';
19
32
  private allowList: Contact[] = [];
20
33
  private blockList: Contact[] = [];
21
34
  private ignoredNumbers: Contact[] = [];
22
35
  private hasAuthState = false;
23
- private openaiKey: string = '';
24
- private visionModel: string = 'gpt-4o';
25
-
26
- constructor(baseDir = join(homedir(), '.pi', 'whatsapp-pi')) {
27
- this.baseDir = baseDir;
28
- this.authStateDir = join(this.baseDir, 'auth');
29
- this.configPath = join(this.baseDir, 'config.json');
30
- }
36
+ private openaiKey: string = '';
37
+ private visionModel: string = 'gpt-4o';
38
+
39
+ constructor(baseDir = join(homedir(), '.pi', 'whatsapp-pi')) {
40
+ this.baseDir = baseDir;
41
+ this.authStateDir = join(this.baseDir, 'auth');
42
+ this.configPath = join(this.baseDir, 'config.json');
43
+ }
31
44
 
32
45
  private async ensureStorageDirectories() {
33
46
  await mkdir(this.baseDir, { recursive: true });
@@ -39,17 +52,17 @@ export class SessionManager {
39
52
  await this.ensureStorageDirectories();
40
53
  await this.loadConfig();
41
54
  await this.syncAuthStateFromDisk();
42
- } catch {
43
- // Initialization is best-effort; callers can continue with defaults.
44
- }
55
+ } catch {
56
+ // Initialization is best-effort; callers can continue with defaults.
57
+ }
45
58
  }
46
59
 
47
- private async loadConfig() {
48
- try {
49
- const data = await readFile(this.configPath, 'utf-8');
50
- const { config, recovered } = this.parseConfig(data);
51
-
52
- const cleanContact = (item: any): Contact | null => {
60
+ private async loadConfig() {
61
+ try {
62
+ const data = await readFile(this.configPath, 'utf-8');
63
+ const { config, recovered } = this.parseConfig(data);
64
+
65
+ const cleanContact = (item: any): Contact | null => {
53
66
  if (typeof item === 'string') return { number: item };
54
67
  if (item && typeof item === 'object') {
55
68
  let num = item.number;
@@ -68,92 +81,92 @@ export class SessionManager {
68
81
  this.blockList = (config.blockList || []).map(cleanContact).filter(Boolean) as Contact[];
69
82
  this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
70
83
  this.status = config.status || 'logged-out';
71
- this.hasAuthState = Boolean(config.hasAuthState);
72
- this.openaiKey = config.openaiKey || '';
73
- this.visionModel = config.visionModel || 'gpt-4o';
74
-
75
- if (recovered) {
76
- await this.saveConfig();
77
- }
78
- } catch {
79
- // File not found is fine
80
- }
81
- }
82
-
83
- private parseConfig(data: string): { config: any; recovered: boolean } {
84
- try {
85
- return { config: JSON.parse(data), recovered: false };
86
- } catch (error) {
87
- const objectEnd = this.findFirstJsonObjectEnd(data);
88
- if (objectEnd < 0) {
89
- throw error;
90
- }
91
-
92
- return {
93
- config: JSON.parse(data.slice(0, objectEnd + 1)),
94
- recovered: true
95
- };
96
- }
97
- }
98
-
99
- private findFirstJsonObjectEnd(data: string): number {
100
- let depth = 0;
101
- let inString = false;
102
- let escaped = false;
103
-
104
- for (let i = 0; i < data.length; i++) {
105
- const char = data[i];
106
-
107
- if (inString) {
108
- if (escaped) {
109
- escaped = false;
110
- } else if (char === '\\') {
111
- escaped = true;
112
- } else if (char === '"') {
113
- inString = false;
114
- }
115
- continue;
116
- }
117
-
118
- if (char === '"') {
119
- inString = true;
120
- continue;
121
- }
122
-
123
- if (char === '{') {
124
- depth++;
125
- } else if (char === '}') {
126
- depth--;
127
- if (depth === 0) {
128
- return i;
129
- }
130
- }
131
- }
132
-
133
- return -1;
134
- }
135
-
136
- public async saveConfig() {
137
- const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.tmp`;
138
- try {
139
- this.hasAuthState = this.hasAuthState || await this.hasCredentialsFile();
140
- const config = {
141
- allowList: this.allowList,
142
- blockList: this.blockList,
143
- ignoredNumbers: this.ignoredNumbers,
84
+ this.hasAuthState = Boolean(config.hasAuthState);
85
+ this.openaiKey = config.openaiKey || '';
86
+ this.visionModel = config.visionModel || 'gpt-4o';
87
+
88
+ if (recovered) {
89
+ await this.saveConfig();
90
+ }
91
+ } catch {
92
+ // File not found is fine
93
+ }
94
+ }
95
+
96
+ private parseConfig(data: string): { config: any; recovered: boolean } {
97
+ try {
98
+ return { config: JSON.parse(data), recovered: false };
99
+ } catch (error) {
100
+ const objectEnd = this.findFirstJsonObjectEnd(data);
101
+ if (objectEnd < 0) {
102
+ throw error;
103
+ }
104
+
105
+ return {
106
+ config: JSON.parse(data.slice(0, objectEnd + 1)),
107
+ recovered: true
108
+ };
109
+ }
110
+ }
111
+
112
+ private findFirstJsonObjectEnd(data: string): number {
113
+ let depth = 0;
114
+ let inString = false;
115
+ let escaped = false;
116
+
117
+ for (let i = 0; i < data.length; i++) {
118
+ const char = data[i];
119
+
120
+ if (inString) {
121
+ if (escaped) {
122
+ escaped = false;
123
+ } else if (char === '\\') {
124
+ escaped = true;
125
+ } else if (char === '"') {
126
+ inString = false;
127
+ }
128
+ continue;
129
+ }
130
+
131
+ if (char === '"') {
132
+ inString = true;
133
+ continue;
134
+ }
135
+
136
+ if (char === '{') {
137
+ depth++;
138
+ } else if (char === '}') {
139
+ depth--;
140
+ if (depth === 0) {
141
+ return i;
142
+ }
143
+ }
144
+ }
145
+
146
+ return -1;
147
+ }
148
+
149
+ public async saveConfig() {
150
+ const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.tmp`;
151
+ try {
152
+ this.hasAuthState = this.hasAuthState || await this.hasCredentialsFile();
153
+ const config = {
154
+ allowList: this.allowList,
155
+ blockList: this.blockList,
156
+ ignoredNumbers: this.ignoredNumbers,
144
157
  status: this.status,
145
158
  hasAuthState: this.hasAuthState,
146
- openaiKey: this.openaiKey,
147
- visionModel: this.visionModel
148
- };
149
- await mkdir(this.baseDir, { recursive: true });
150
- await writeFile(tempPath, JSON.stringify(config, null, 2));
151
- await rename(tempPath, this.configPath);
152
- } catch (error) {
153
- await rm(tempPath, { force: true }).catch(() => {});
154
- console.error('Failed to save config:', error);
155
- }
156
- }
159
+ openaiKey: this.openaiKey,
160
+ visionModel: this.visionModel
161
+ };
162
+ await mkdir(this.baseDir, { recursive: true });
163
+ await writeFile(tempPath, JSON.stringify(config, null, 2));
164
+ await rename(tempPath, this.configPath);
165
+ } catch (error) {
166
+ await rm(tempPath, { force: true }).catch(() => {});
167
+ console.error('Failed to save config:', error);
168
+ }
169
+ }
157
170
 
158
171
  getAllowList(): Contact[] {
159
172
  return this.allowList;
@@ -275,10 +288,10 @@ export class SessionManager {
275
288
  }
276
289
  }
277
290
 
278
- public async isRegistered(): Promise<boolean> {
279
- await this.syncAuthStateFromDisk();
280
- return this.hasAuthState;
281
- }
291
+ public async isRegistered(): Promise<boolean> {
292
+ await this.syncAuthStateFromDisk();
293
+ return this.hasAuthState;
294
+ }
282
295
 
283
296
  async markAuthStateAvailable() {
284
297
  if (!this.hasAuthState) {
@@ -292,27 +305,27 @@ export class SessionManager {
292
305
  return await useMultiFileAuthState(this.authStateDir);
293
306
  }
294
307
 
295
- private async syncAuthStateFromDisk() {
296
- const nextHasAuthState = await this.hasCredentialsFile();
297
- const nextStatus = nextHasAuthState || this.status !== 'connected'
298
- ? this.status
299
- : 'disconnected';
300
-
301
- if (nextHasAuthState !== this.hasAuthState || nextStatus !== this.status) {
302
- this.hasAuthState = nextHasAuthState;
303
- this.status = nextStatus;
304
- await this.saveConfig();
305
- }
306
- }
307
-
308
- private async hasCredentialsFile(): Promise<boolean> {
309
- try {
310
- await readFile(join(this.authStateDir, 'creds.json'));
311
- return true;
312
- } catch {
313
- return false;
314
- }
315
- }
308
+ private async syncAuthStateFromDisk() {
309
+ const nextHasAuthState = await this.hasCredentialsFile();
310
+ const nextStatus = nextHasAuthState || this.status !== 'connected'
311
+ ? this.status
312
+ : 'disconnected';
313
+
314
+ if (nextHasAuthState !== this.hasAuthState || nextStatus !== this.status) {
315
+ this.hasAuthState = nextHasAuthState;
316
+ this.status = nextStatus;
317
+ await this.saveConfig();
318
+ }
319
+ }
320
+
321
+ private async hasCredentialsFile(): Promise<boolean> {
322
+ try {
323
+ await readFile(join(this.authStateDir, 'creds.json'));
324
+ return true;
325
+ } catch {
326
+ return false;
327
+ }
328
+ }
316
329
 
317
330
  async deleteAuthState() {
318
331
  try {
@@ -1,27 +1,48 @@
1
+ import { appendFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
1
5
  export class WhatsAppPiLogger {
2
- constructor(private verbose = false) {}
6
+ private logFile: string;
7
+
8
+ constructor(private verbose = false) {
9
+ const logDir = join(homedir(), '.pi', 'whatsapp-pi');
10
+ try { mkdirSync(logDir, { recursive: true }); } catch {}
11
+ this.logFile = join(logDir, 'whatsapp-pi.log');
12
+ }
3
13
 
4
14
  setVerbose(verbose: boolean) {
5
15
  this.verbose = verbose;
6
16
  }
7
17
 
18
+ private writeToFile(level: string, message: string, args: unknown[]) {
19
+ const timestamp = new Date().toISOString();
20
+ const extra = args.length ? ' ' + args.map(a => String(a)).join(' ') : '';
21
+ const line = `[${timestamp}] [${level}] ${message}${extra}\n`;
22
+ try { appendFileSync(this.logFile, line); } catch {}
23
+ }
24
+
8
25
  info(message: string, ...args: unknown[]) {
9
26
  console.log(message, ...args);
27
+ this.writeToFile('INFO', message, args);
10
28
  }
11
29
 
12
30
  log(message: string, ...args: unknown[]) {
31
+ this.writeToFile('LOG', message, args);
13
32
  if (this.verbose) {
14
33
  console.log(message, ...args);
15
34
  }
16
35
  }
17
36
 
18
37
  warn(message: string, ...args: unknown[]) {
38
+ this.writeToFile('WARN', message, args);
19
39
  if (this.verbose) {
20
40
  console.warn(message, ...args);
21
41
  }
22
42
  }
23
43
 
24
44
  error(message: string, ...args: unknown[]) {
45
+ this.writeToFile('ERROR', message, args);
25
46
  if (this.verbose) {
26
47
  console.error(message, ...args);
27
48
  }
@@ -3,12 +3,20 @@ import {
3
3
  DisconnectReason,
4
4
  fetchLatestBaileysVersion,
5
5
  makeCacheableSignalKeyStore
6
- } from '@whiskeysockets/baileys';
6
+ } from 'baileys';
7
7
  import P from 'pino';
8
8
  import { SessionManager } from './session.manager.js';
9
9
  import { IncomingMessage, SessionStatus } from '../models/whatsapp.types.js';
10
10
  import { MessageSender } from './message.sender.js';
11
11
  import { installBaileysConsoleFilter } from './baileys-console-filter.js';
12
+ import { appendFileSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { homedir } from 'os';
15
+
16
+ const LOG_FILE = join(homedir(), '.pi', 'whatsapp-pi', 'whatsapp-pi.log');
17
+ function fileLog(msg: string) {
18
+ try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [WhatsApp-Pi] ${msg}\n`); } catch {}
19
+ }
12
20
 
13
21
  export interface WhatsAppStartOptions {
14
22
  allowPairingOnAuthFailure?: boolean;
@@ -28,6 +36,7 @@ interface IncomingMessageKey {
28
36
  id?: string;
29
37
  remoteJid?: string;
30
38
  fromMe?: boolean;
39
+ participant?: string;
31
40
  }
32
41
 
33
42
  interface IncomingMessageContent {
@@ -58,6 +67,8 @@ interface WhatsAppSocketLike {
58
67
  sendMessage(jid: string, content: { text: string }): Promise<{ key?: { id?: string } } | undefined>;
59
68
  sendPresenceUpdate(presence: 'composing' | 'recording' | 'paused', jid: string): Promise<void>;
60
69
  readMessages(messages: Array<{ remoteJid: string; id: string; fromMe: boolean }>): Promise<void>;
70
+ groupMetadata(jid: string): Promise<{ id: string; subject: string; participants: Array<{ id: string }> }>;
71
+ groupFetchAllParticipating(): Promise<Record<string, { id: string; subject: string; participants: Array<{ id: string }> }>>;
61
72
  }
62
73
 
63
74
  interface LastDisconnectLike {
@@ -71,16 +82,16 @@ interface BoomLikeError {
71
82
  message?: string;
72
83
  }
73
84
 
74
- export class WhatsAppService {
75
- private static readonly INITIAL_RECONNECT_DELAY_MS = 5_000;
76
- private static readonly MAX_RECONNECT_DELAY_MS = 120_000;
77
-
78
- private socket?: WhatsAppSocketLike;
79
- private sessionManager: SessionManager;
80
- private messageSender: MessageSender;
81
- private isReconnecting = false;
82
- private reconnectAttempts = 0;
83
- private verboseMode = false;
85
+ export class WhatsAppService {
86
+ private static readonly INITIAL_RECONNECT_DELAY_MS = 5_000;
87
+ private static readonly MAX_RECONNECT_DELAY_MS = 120_000;
88
+
89
+ private socket?: WhatsAppSocketLike;
90
+ private sessionManager: SessionManager;
91
+ private messageSender: MessageSender;
92
+ private isReconnecting = false;
93
+ private reconnectAttempts = 0;
94
+ private verboseMode = false;
84
95
  private onIncomingMessageRecorded?: (message: IncomingMessage) => void | Promise<void>;
85
96
  private saveCreds?: () => Promise<void>;
86
97
  private restoreBaileysConsoleFilter?: () => void;
@@ -89,12 +100,22 @@ export class WhatsAppService {
89
100
  private onMessage?: (m: MessagesUpsertEvent) => void;
90
101
  private onStatusUpdate?: (status: string) => void;
91
102
  private lastRemoteJid: string | null = null;
103
+ private boundGroupJid: string | null = null;
104
+ private groupMetadataCache: Map<string, { id: string; subject: string; participants: Array<{ id: string }> }> = new Map();
92
105
 
93
106
  constructor(sessionManager: SessionManager) {
94
107
  this.sessionManager = sessionManager;
95
108
  this.messageSender = new MessageSender(this);
96
109
  }
97
110
 
111
+ public setGroupBinding(groupJid: string) {
112
+ this.boundGroupJid = groupJid;
113
+ }
114
+
115
+ public getBoundGroupJid(): string | null {
116
+ return this.boundGroupJid;
117
+ }
118
+
98
119
  public getStatus(): SessionStatus {
99
120
  return this.sessionManager.getStatus();
100
121
  }
@@ -168,17 +189,17 @@ export class WhatsAppService {
168
189
  return '';
169
190
  }
170
191
 
171
- private clearReconnectTimeout() {
172
- if (this.reconnectTimeout) {
173
- clearTimeout(this.reconnectTimeout);
174
- this.reconnectTimeout = undefined;
175
- }
176
- }
177
-
178
- private getReconnectDelayMs(): number {
179
- const delay = WhatsAppService.INITIAL_RECONNECT_DELAY_MS * (2 ** Math.max(0, this.reconnectAttempts - 1));
180
- return Math.min(delay, WhatsAppService.MAX_RECONNECT_DELAY_MS);
181
- }
192
+ private clearReconnectTimeout() {
193
+ if (this.reconnectTimeout) {
194
+ clearTimeout(this.reconnectTimeout);
195
+ this.reconnectTimeout = undefined;
196
+ }
197
+ }
198
+
199
+ private getReconnectDelayMs(): number {
200
+ const delay = WhatsAppService.INITIAL_RECONNECT_DELAY_MS * (2 ** Math.max(0, this.reconnectAttempts - 1));
201
+ return Math.min(delay, WhatsAppService.MAX_RECONNECT_DELAY_MS);
202
+ }
182
203
 
183
204
  private cleanupSocket() {
184
205
  this.clearReconnectTimeout();
@@ -228,6 +249,8 @@ export class WhatsAppService {
228
249
 
229
250
  const logger = P({ level: this.verboseMode ? 'trace' : 'silent' });
230
251
 
252
+ const groupMetadataCache = this.groupMetadataCache;
253
+
231
254
  const socket = makeWASocket({
232
255
  version,
233
256
  printQRInTerminal: false,
@@ -236,7 +259,10 @@ export class WhatsAppService {
236
259
  keys: makeCacheableSignalKeyStore(state.keys, logger)
237
260
  },
238
261
  syncFullHistory: false,
239
- logger
262
+ logger,
263
+ cachedGroupMetadata: async (jid: string) => {
264
+ return groupMetadataCache.get(jid) as any;
265
+ }
240
266
  }) as WhatsAppSocketLike;
241
267
 
242
268
  return socket;
@@ -304,7 +330,7 @@ export class WhatsAppService {
304
330
  private async handlePairingQr(qr: string) {
305
331
  this.sessionManager.setStatus('pairing');
306
332
  this.onQRCode?.(qr);
307
- this.onStatusUpdate?.('| WhatsApp: Disconnected');
333
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
308
334
  }
309
335
 
310
336
  private async handleConnectionOpen() {
@@ -312,11 +338,11 @@ export class WhatsAppService {
312
338
  console.log('WhatsApp connection successfully opened');
313
339
  }
314
340
 
315
- this.isReconnecting = false;
316
- this.reconnectAttempts = 0;
317
- this.clearReconnectTimeout();
318
- await this.saveCreds?.();
319
- await this.sessionManager.markAuthStateAvailable();
341
+ this.isReconnecting = false;
342
+ this.reconnectAttempts = 0;
343
+ this.clearReconnectTimeout();
344
+ await this.saveCreds?.();
345
+ await this.sessionManager.markAuthStateAvailable();
320
346
  this.sessionManager.setStatus('connected');
321
347
  this.onStatusUpdate?.('| WhatsApp: Connected');
322
348
  }
@@ -349,58 +375,58 @@ export class WhatsAppService {
349
375
  console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
350
376
  }
351
377
 
352
- if (shouldTreatAsLoggedOut) {
353
- if (this.verboseMode) {
354
- console.error(`Session rejected [${statusCode}] - preserving auth state`);
355
- }
356
- if (isBadMac) {
357
- if (this.verboseMode) {
358
- console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
359
- console.error('[WhatsApp-Pi] Use Logoff (Delete Session) only if reconnect keeps failing.');
360
- }
361
- this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
362
- } else if (isAuthRejected && allowPairingOnAuthFailure) {
363
- this.onStatusUpdate?.('| WhatsApp: Session Preserved (Reconnect Failed)');
364
- }
365
- this.cleanupSocket();
366
- this.isReconnecting = false;
367
- this.reconnectAttempts = 0;
368
- await this.sessionManager.setStatus('disconnected');
369
- if (!isBadMac) {
370
- this.onStatusUpdate?.('| WhatsApp: Disconnected');
371
- }
372
- return;
373
- }
374
-
375
- if (statusCode === DisconnectReason.connectionReplaced) {
376
- if (this.verboseMode) {
377
- console.error('Connection replaced - another instance connected');
378
- }
379
- this.cleanupSocket();
380
- this.isReconnecting = false;
381
- this.reconnectAttempts = 0;
382
- await this.sessionManager.setStatus('disconnected');
383
- this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
384
- return;
385
- }
386
-
387
- if (shouldReconnect && !this.isReconnecting) {
388
- this.isReconnecting = true;
389
- this.reconnectAttempts++;
390
- const reconnectDelayMs = this.getReconnectDelayMs();
391
- this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
392
- this.clearReconnectTimeout();
393
- await this.saveCreds?.();
394
- this.cleanupSocket();
395
- this.reconnectTimeout = setTimeout(() => {
396
- this.isReconnecting = false;
397
- void this.start(options);
398
- }, reconnectDelayMs);
399
- } else if (!shouldReconnect) {
400
- this.reconnectAttempts = 0;
401
- this.sessionManager.setStatus('logged-out');
402
- this.onStatusUpdate?.('| WhatsApp: Disconnected');
403
- }
378
+ if (shouldTreatAsLoggedOut) {
379
+ if (this.verboseMode) {
380
+ console.error(`Session rejected [${statusCode}] - preserving auth state`);
381
+ }
382
+ if (isBadMac) {
383
+ if (this.verboseMode) {
384
+ console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
385
+ console.error('[WhatsApp-Pi] Use Logoff (Delete Session) only if reconnect keeps failing.');
386
+ }
387
+ this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
388
+ } else if (isAuthRejected && allowPairingOnAuthFailure) {
389
+ this.onStatusUpdate?.('| WhatsApp: Session Preserved (Reconnect Failed)');
390
+ }
391
+ this.cleanupSocket();
392
+ this.isReconnecting = false;
393
+ this.reconnectAttempts = 0;
394
+ await this.sessionManager.setStatus('disconnected');
395
+ if (!isBadMac) {
396
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
397
+ }
398
+ return;
399
+ }
400
+
401
+ if (statusCode === DisconnectReason.connectionReplaced) {
402
+ if (this.verboseMode) {
403
+ console.error('Connection replaced - another instance connected');
404
+ }
405
+ this.cleanupSocket();
406
+ this.isReconnecting = false;
407
+ this.reconnectAttempts = 0;
408
+ await this.sessionManager.setStatus('disconnected');
409
+ this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
410
+ return;
411
+ }
412
+
413
+ if (shouldReconnect && !this.isReconnecting) {
414
+ this.isReconnecting = true;
415
+ this.reconnectAttempts++;
416
+ const reconnectDelayMs = this.getReconnectDelayMs();
417
+ this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
418
+ this.clearReconnectTimeout();
419
+ await this.saveCreds?.();
420
+ this.cleanupSocket();
421
+ this.reconnectTimeout = setTimeout(() => {
422
+ this.isReconnecting = false;
423
+ void this.start(options);
424
+ }, reconnectDelayMs);
425
+ } else if (!shouldReconnect) {
426
+ this.reconnectAttempts = 0;
427
+ this.sessionManager.setStatus('logged-out');
428
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
429
+ }
404
430
  }
405
431
 
406
432
  private extractText(message: IncomingMessageContent | undefined): string {
@@ -448,25 +474,41 @@ export class WhatsAppService {
448
474
  if (this.isPiGeneratedMessage(text)) return;
449
475
 
450
476
  const remoteJid = message.key.remoteJid;
451
- if (remoteJid.endsWith('@g.us')) return;
477
+ const isGroup = remoteJid.endsWith('@g.us');
452
478
 
453
- const senderJid = this.normalizeContactNumber(remoteJid.split('@')[0]);
479
+ if (this.boundGroupJid) {
480
+ // Group-only mode: reject everything except the bound group
481
+ if (remoteJid !== this.boundGroupJid) return;
482
+ }
483
+
484
+ // Eagerly cache group metadata on incoming messages so it's
485
+ // available for sender-key encryption when we reply
486
+ if (isGroup) {
487
+ void this.prepareGroupSession(remoteJid);
488
+ }
489
+
490
+ const senderJid = isGroup
491
+ ? remoteJid
492
+ : this.normalizeContactNumber(remoteJid.split('@')[0]);
454
493
  void this.recordIncomingMessage(message, remoteJid, text);
455
494
 
456
- if (this.sessionManager.isBlocked(senderJid)) {
457
- if (this.isVerbose()) {
458
- console.log(`Ignoring message from ${senderJid} (explicitly blocked)`);
495
+ // In group-only mode, skip allow/block checks — the binding is the authorization
496
+ if (!this.boundGroupJid) {
497
+ if (this.sessionManager.isBlocked(senderJid)) {
498
+ if (this.isVerbose()) {
499
+ console.log(`Ignoring message from ${senderJid} (explicitly blocked)`);
500
+ }
501
+ return;
459
502
  }
460
- return;
461
- }
462
503
 
463
- if (!this.sessionManager.isAllowed(senderJid)) {
464
- if (this.isVerbose()) {
465
- console.log(`Ignoring message from ${senderJid} (not in allow list)`);
504
+ if (!this.sessionManager.isAllowed(senderJid)) {
505
+ if (this.isVerbose()) {
506
+ console.log(`Ignoring message from ${senderJid} (not in allow list)`);
507
+ }
508
+ const pushName = message.pushName || undefined;
509
+ await this.sessionManager.trackIgnoredNumber(senderJid, pushName);
510
+ return;
466
511
  }
467
- const pushName = message.pushName || undefined;
468
- await this.sessionManager.trackIgnoredNumber(senderJid, pushName);
469
- return;
470
512
  }
471
513
 
472
514
  this.lastRemoteJid = remoteJid;
@@ -497,6 +539,29 @@ export class WhatsAppService {
497
539
  return this.socket;
498
540
  }
499
541
 
542
+ /**
543
+ * Pre-loads group metadata into the cache for Baileys' cachedGroupMetadata.
544
+ * This ensures Baileys can resolve group participants for Signal
545
+ * sender-key encryption, preventing "No sessions" errors.
546
+ */
547
+ public async prepareGroupSession(jid: string): Promise<void> {
548
+ if (!jid.endsWith('@g.us')) return;
549
+ if (this.groupMetadataCache.has(jid)) {
550
+ fileLog(`Group metadata cache HIT for ${jid}`);
551
+ return;
552
+ }
553
+ const socket = this.getActiveSocket();
554
+ if (!socket) return;
555
+ try {
556
+ fileLog(`Fetching group metadata for ${jid}...`);
557
+ const metadata = await socket.groupMetadata(jid);
558
+ this.groupMetadataCache.set(jid, metadata);
559
+ fileLog(`Cached group metadata for ${jid} (${metadata.participants?.length ?? 0} participants)`);
560
+ } catch (error) {
561
+ fileLog(`FAILED to fetch group metadata for ${jid}: ${error instanceof Error ? error.message : String(error)}`);
562
+ }
563
+ }
564
+
500
565
  async sendMessage(jid: string, text: string) {
501
566
  // Ensure we show the typing indicator before sending
502
567
  await this.sendPresence(jid, 'composing');
@@ -63,14 +63,14 @@ export class MenuHandler {
63
63
  await this.whatsappService.stop();
64
64
  ctx.ui.notify('WhatsApp Agent Disconnected', 'warning');
65
65
  break;
66
- case 'Logoff (Delete Session)': {
67
- const confirmLogoff = await ctx.ui.confirm('Logoff', 'Delete all credentials?');
68
- if (confirmLogoff) {
69
- await this.whatsappService.logout();
70
- ctx.ui.notify('Logged off and credentials deleted', 'info');
71
- }
72
- break;
73
- }
66
+ case 'Logoff (Delete Session)': {
67
+ const confirmLogoff = await ctx.ui.confirm('Logoff', 'Delete all credentials?');
68
+ if (confirmLogoff) {
69
+ await this.whatsappService.logout();
70
+ ctx.ui.notify('Logged off and credentials deleted', 'info');
71
+ }
72
+ break;
73
+ }
74
74
  case 'Allowed Numbers':
75
75
  await this.manageAllowList(ctx);
76
76
  break;
@@ -457,7 +457,9 @@ export class MenuHandler {
457
457
  }
458
458
 
459
459
  private formatAllowedContactOption(contact: Contact): string {
460
- return contact.name ? `${contact.name} (${contact.number})` : contact.number;
460
+ const isGroup = SessionManager.isGroupJid(contact.number);
461
+ const prefix = isGroup ? '[Group] ' : '';
462
+ return contact.name ? `${prefix}${contact.name} (${contact.number})` : `${prefix}${contact.number}`;
461
463
  }
462
464
 
463
465
  private sortContactsAlphabetically(contacts: Contact[]): Contact[] {
@@ -514,7 +516,9 @@ export class MenuHandler {
514
516
  private getConversationDisplayName(conversation: RecentConversationSummary): string {
515
517
  const allowedContact = this.sessionManager.getAllowedContact(conversation.senderNumber);
516
518
  const displayName = allowedContact?.name || conversation.senderName;
517
- return displayName ? `${displayName} (${conversation.senderNumber})` : conversation.senderNumber;
519
+ const isGroup = SessionManager.isGroupJid(conversation.senderNumber);
520
+ const prefix = isGroup ? '[Group] ' : '';
521
+ return displayName ? `${prefix}${displayName} (${conversation.senderNumber})` : `${prefix}${conversation.senderNumber}`;
518
522
  }
519
523
 
520
524
  private formatDateTime(timestamp: number): string {
@@ -29,10 +29,10 @@ export class MessageDetailView {
29
29
  ) {
30
30
  this.props.onClose();
31
31
  }
32
- }
33
-
34
- render(width: number): string[] {
35
- const bodyText = this.props.text.length > 0 ? this.props.text : '[No readable text available]';
32
+ }
33
+
34
+ render(width: number): string[] {
35
+ const bodyText = this.props.text.length > 0 ? this.props.text : '[No readable text available]';
36
36
 
37
37
  const availableWidth = Math.max(20, width - 4);
38
38
  const rawHeaderLines = [
package/whatsapp-pi.ts CHANGED
@@ -30,6 +30,12 @@ export default function (pi: ExtensionAPI) {
30
30
  default: false
31
31
  });
32
32
 
33
+ pi.registerFlag("whatsapp-group", {
34
+ description: "Bind this agent to a specific WhatsApp group JID (e.g. 120363012345@g.us). When set, only messages from this group are processed.",
35
+ type: "string",
36
+ default: ""
37
+ });
38
+
33
39
  const sessionManager = new SessionManager();
34
40
  const whatsappService = new WhatsAppService(sessionManager);
35
41
  const recentsService = new RecentsService(sessionManager);
@@ -77,6 +83,15 @@ export default function (pi: ExtensionAPI) {
77
83
  whatsappService.setStatusCallback((status) => {
78
84
  ctx.ui.setStatus('whatsapp', status);
79
85
  });
86
+
87
+ // Set up group binding if configured
88
+ const boundGroupJid = (pi.getFlag("whatsapp-group") as string) || "";
89
+ if (boundGroupJid) {
90
+ whatsappService.setGroupBinding(boundGroupJid);
91
+ sessionManager.setGroupJidForAuth(boundGroupJid);
92
+ logger.log(`[WhatsApp-Pi] Group-only mode: bound to ${boundGroupJid}`);
93
+ }
94
+
80
95
  await sessionManager.ensureInitialized();
81
96
  await recentsService.ensureInitialized();
82
97
  installGracefulShutdownHandlers();
@@ -87,9 +102,13 @@ export default function (pi: ExtensionAPI) {
87
102
  }
88
103
  };
89
104
  whatsappService.setIncomingMessageRecorder(async (message) => {
105
+ const isGroup = message.remoteJid.endsWith('@g.us');
106
+ const senderNumber = isGroup
107
+ ? message.remoteJid
108
+ : `+${message.remoteJid.split('@')[0]}`;
90
109
  await recentsService.recordMessage({
91
110
  messageId: message.id,
92
- senderNumber: `+${message.remoteJid.split('@')[0]}`,
111
+ senderNumber,
93
112
  senderName: message.pushName,
94
113
  text: message.text || '',
95
114
  direction: 'incoming',
@@ -163,21 +182,29 @@ export default function (pi: ExtensionAPI) {
163
182
  }
164
183
  });
165
184
 
185
+ // Track whether send_wa_message tool already sent a reply this turn
186
+ let toolSentToJid: string | null = null;
187
+
166
188
  // Handle incoming messages by injecting them as user prompts
167
189
  whatsappService.setMessageCallback(async (m) => {
168
190
  const msg = m.messages?.[0];
169
191
  if (!msg?.message) return;
170
192
 
171
- const sender = msg.key.remoteJid?.split('@')[0] || "unknown";
193
+ const remoteJid = msg.key.remoteJid;
194
+ const isGroup = remoteJid?.endsWith('@g.us') || false;
195
+ const participant = isGroup ? (msg.key.participant?.split('@')[0] || 'unknown') : (remoteJid?.split('@')[0] || 'unknown');
196
+ const sender = remoteJid?.split('@')[0] || "unknown";
172
197
  const pushName = msg.pushName || "WhatsApp User";
173
198
 
174
199
  // Mark as read and start typing indicator immediately
175
- const remoteJid = msg.key.remoteJid;
176
200
  if (remoteJid && msg.key.id) {
177
201
  whatsappService.markRead(remoteJid, msg.key.id, msg.key.fromMe);
178
202
  whatsappService.sendPresence(remoteJid, 'composing');
179
203
  }
180
204
 
205
+ // Reset tool-sent flag for this new incoming message
206
+ toolSentToJid = null;
207
+
181
208
  const resolved = extractIncomingText(msg.message);
182
209
  if (resolved.kind === 'system') {
183
210
  logger.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${resolved.text}`);
@@ -186,16 +213,21 @@ export default function (pi: ExtensionAPI) {
186
213
 
187
214
  const { text, imageBuffer, imageMimeType } = await incomingMediaService.process(resolved, pushName);
188
215
 
189
- logger.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${text}`);
216
+ // Format message header with group context when applicable
217
+ const messageHeader = isGroup
218
+ ? `Message from ${pushName} (${participant}) in group ${remoteJid}:`
219
+ : `Message from ${pushName} (${sender}):`;
220
+
221
+ logger.log(`[WhatsApp-Pi] ${messageHeader} ${text}`);
190
222
 
191
223
  // Use a standard delivery for ALL messages to ensure TUI consistency
192
224
  if (imageBuffer && imageMimeType) {
193
225
  pi.sendUserMessage([
194
- { type: "text", text: `Message from ${pushName} (${sender}): ${text}` },
226
+ { type: "text", text: `${messageHeader} ${text}` },
195
227
  { type: "image", data: imageBuffer.toString('base64'), mimeType: imageMimeType }
196
228
  ], { deliverAs: "followUp" });
197
229
  } else {
198
- pi.sendUserMessage(`Message from ${pushName} (${sender}): ${text}`, { deliverAs: "followUp" });
230
+ pi.sendUserMessage(`${messageHeader} ${text}`, { deliverAs: "followUp" });
199
231
  }
200
232
 
201
233
  // Handle commands
@@ -225,13 +257,24 @@ export default function (pi: ExtensionAPI) {
225
257
  pi.registerTool({
226
258
  name: "send_wa_message",
227
259
  label: "Send WhatsApp Message",
228
- description: "Send a WhatsApp message to a contact identified by their JID (e.g. 5511999998888@s.whatsapp.net). Returns a JSON result with success status and messageId or error.",
229
- promptSnippet: "send_wa_message(jid, message) - Send a WhatsApp message to a contact by JID",
260
+ description: "Send a WhatsApp message to a contact or group. The 'jid' parameter is the WhatsApp JID (e.g. 5511999998888@s.whatsapp.net for contacts, or 120363012345@g.us for groups). If omitted, replies to the last conversation.",
261
+ promptSnippet: "send_wa_message(jid, message) - Send a WhatsApp message. jid is required (e.g. 5511999998888@s.whatsapp.net or 120363012345@g.us). IMPORTANT: After calling this tool, do NOT generate any follow-up text or confirmation — the message is already delivered to WhatsApp. Your entire response to the user should be sent ONLY through this tool, not repeated in chat.",
230
262
  parameters: Type.Object({
231
- jid: Type.String({ minLength: 1, description: "WhatsApp JID of the recipient, e.g. 5511999998888@s.whatsapp.net" }),
263
+ jid: Type.Optional(Type.String({ description: "WhatsApp JID of the recipient" })),
264
+ recipient_jid: Type.Optional(Type.String({ description: "Alternative name for jid" })),
232
265
  message: Type.String({ minLength: 1, description: "Plain-text message content to send" })
233
266
  }),
234
267
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
268
+ // Resolve JID: jid > recipient_jid > lastRemoteJid
269
+ const resolvedJid = params.jid || params.recipient_jid || whatsappService.getLastRemoteJid();
270
+ if (!resolvedJid) {
271
+ return {
272
+ isError: true,
273
+ details: undefined,
274
+ content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: "No JID provided and no active conversation to reply to", attempts: 0 }) }]
275
+ };
276
+ }
277
+
235
278
  if (whatsappService.getStatus() !== 'connected') {
236
279
  return {
237
280
  isError: true,
@@ -247,31 +290,35 @@ export default function (pi: ExtensionAPI) {
247
290
 
248
291
  console.log([
249
292
  '[WhatsApp-Pi] Outgoing WhatsApp message',
250
- ` To: ${params.jid}`,
293
+ ` To: ${resolvedJid}`,
251
294
  ' Message:',
252
295
  formattedMessage
253
296
  ].join('\n'));
254
297
 
255
- const result = await whatsappService.sendMessage(params.jid, params.message);
298
+ const result = await whatsappService.sendMessage(resolvedJid, params.message);
256
299
 
257
300
  if (result.success) {
301
+ // Mark that tool already sent to this JID — prevents message_end from re-sending
302
+ toolSentToJid = resolvedJid;
303
+ const isGroupJid = resolvedJid.endsWith('@g.us');
304
+ const senderNumber = isGroupJid ? resolvedJid : `+${resolvedJid.split('@')[0]}`;
258
305
  await recentsService.recordMessage({
259
306
  messageId: result.messageId!,
260
- senderNumber: `+${params.jid.split('@')[0]}`,
307
+ senderNumber,
261
308
  text: params.message,
262
309
  direction: 'outgoing',
263
310
  timestamp: Date.now()
264
311
  });
265
312
  console.log([
266
313
  '[WhatsApp-Pi] Outgoing WhatsApp message result',
267
- ` To: ${params.jid}`,
314
+ ` To: ${resolvedJid}`,
268
315
  ' Status: sent',
269
316
  ` MessageId: ${result.messageId ?? 'unknown'}`
270
317
  ].join('\n'));
271
318
  } else {
272
319
  console.log([
273
320
  '[WhatsApp-Pi] Outgoing WhatsApp message result',
274
- ` To: ${params.jid}`,
321
+ ` To: ${resolvedJid}`,
275
322
  ' Status: failed',
276
323
  ` Error: ${result.error ?? 'unknown error'}`
277
324
  ].join('\n'));
@@ -285,6 +332,9 @@ export default function (pi: ExtensionAPI) {
285
332
  }
286
333
  });
287
334
 
335
+ // Suppress automatic message_end reply when tool already sent
336
+ // This is checked by the message_end handler below
337
+
288
338
  // Register commands
289
339
  pi.registerCommand("whatsapp", {
290
340
  description: "Manage WhatsApp integration",
@@ -318,6 +368,12 @@ export default function (pi: ExtensionAPI) {
318
368
  const lastJid = whatsappService.getLastRemoteJid();
319
369
  const text = message.content.filter(c => c.type === "text").map(c => c.text).join("\n");
320
370
 
371
+ // Skip if send_wa_message tool already sent a reply to this JID
372
+ if (toolSentToJid === lastJid) {
373
+ toolSentToJid = null;
374
+ return;
375
+ }
376
+
321
377
  if (lastJid && text) {
322
378
  try {
323
379
  const result = await whatsappService.sendMessage(lastJid, text);