whatsapp-pi 1.0.46 → 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,10 +50,10 @@ pi install npm:whatsapp-pi
46
50
  pi
47
51
  ```
48
52
 
49
- After connecting WhatsApp once from the menu and scanning the QR code, you can start Pi with auto-connect enabled:
50
- ```bash
51
- pi --whatsapp-pi-online
52
- ```
53
+ After connecting WhatsApp once from the menu and scanning the QR code, you can start Pi with auto-connect enabled:
54
+ ```bash
55
+ pi --whatsapp-pi-online
56
+ ```
53
57
 
54
58
  3. Use the menu to connect WhatsApp and manage allowed/blocked numbers
55
59
 
@@ -69,15 +73,15 @@ npm install
69
73
  pi -e whatsapp-pi.ts
70
74
  ```
71
75
 
72
- For verbose mode (shows Baileys trace logs for debugging):
73
- ```bash
74
- pi -e whatsapp-pi.ts --verbose
75
- ```
76
-
77
- To test startup auto-connect locally after you have already paired WhatsApp:
78
- ```bash
79
- pi -e whatsapp-pi.ts --whatsapp-pi-online
80
- ```
76
+ For verbose mode (shows Baileys trace logs for debugging):
77
+ ```bash
78
+ pi -e whatsapp-pi.ts --verbose
79
+ ```
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
+ ```
81
85
 
82
86
  ## Commands
83
87
 
@@ -87,12 +91,21 @@ pi -e whatsapp-pi.ts --whatsapp-pi-online
87
91
  - **Connect / Reconnect WhatsApp** - Start WhatsApp connection using saved credentials when available; QR code appears only if pairing is required
88
92
  - **Disconnect WhatsApp** - Stop WhatsApp connection
89
93
  - **Logoff (Delete Session)** - Remove all credentials and session data
94
+ - **Recents** - Open recent conversations, view history, and reply
90
95
  - **Allowed Numbers** - Manage contacts that can interact with Pi
91
96
  - **Blocked Numbers** - View ignored numbers and manage them
92
97
 
93
98
  ### Allowed Numbers Management
94
99
  - **Add Number** - Add a new contact to the allow list (format: +5511999999999)
95
- - **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
96
109
  - **Back** - Return to main menu
97
110
 
98
111
  ### Blocked Numbers Management
@@ -106,11 +119,11 @@ pi -e whatsapp-pi.ts --whatsapp-pi-online
106
119
  ```
107
120
  src/
108
121
  ├── models/ # Type definitions
109
- ├── services/ # Core services (WhatsApp, Session, MessageSender)
110
- └── ui/ # Menu handlers
122
+ ├── services/ # Core services (WhatsApp, Session, Recents, Media)
123
+ └── ui/ # Menu handlers and TUI views
111
124
 
112
- specs/ # Feature specifications
113
- tests/ # Unit and integration tests
125
+ tests/
126
+ └── unit/ # Unit tests
114
127
  ```
115
128
 
116
129
  ## Development
@@ -122,15 +135,13 @@ npm test
122
135
 
123
136
  ## Implementation Notes
124
137
 
125
- ### Recent Feature Updates (2026-04)
126
-
127
- - **Auto-Connect Support**: Use the `--whatsapp-pi-online` flag to automatically connect to WhatsApp on startup if you have a valid active session.
128
- - **Vision Analysis**: Images sent via WhatsApp are automatically downloaded and forwarded to the Pi agent as base64, enabling vision-based interactions.
129
- - **Document Message Support**:
130
- - WhatsApp documents (PDFs, text files, etc.) are downloaded and saved to `./.pi-data/whatsapp/documents/`.
131
- - The Pi agent receives a notification with the file path and metadata.
132
- - **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.
133
- - **Intelligent Message Filtering**:
134
- - **Loop Prevention**: The bot automatically ignores any message ending with the `π` symbol, preventing infinite loops between instances.
135
- - **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.
136
- - **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.46",
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
  },
@@ -18,12 +18,9 @@ export interface IncomingMessage {
18
18
  timestamp: number;
19
19
  }
20
20
 
21
- export type MessageOrigin = 'agent' | 'menu';
22
-
23
21
  export interface MessageRequest {
24
22
  recipientJid: string;
25
23
  text: string;
26
- origin?: MessageOrigin;
27
24
  options?: {
28
25
  maxRetries?: number;
29
26
  priority?: 'high' | 'normal';
@@ -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
57
- const shouldAppendPi = request.origin !== 'menu';
58
- const response = await socket.sendMessage(request.recipientJid, {
59
- text: shouldAppendPi ? `${request.text} π` : request.text
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
73
+ // Note: Branding π is applied here to ensure consistency
74
+ const response = await socket.sendMessage(request.recipientJid, {
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 {
@@ -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
  }
@@ -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;
@@ -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,14 +539,36 @@ export class WhatsAppService {
497
539
  return this.socket;
498
540
  }
499
541
 
500
- async sendMessage(jid: string, text: string, origin: 'agent' | 'menu' = 'agent') {
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
+
565
+ async sendMessage(jid: string, text: string) {
501
566
  // Ensure we show the typing indicator before sending
502
567
  await this.sendPresence(jid, 'composing');
503
568
 
504
569
  const result = await this.messageSender.send({
505
570
  recipientJid: jid,
506
- text,
507
- origin
571
+ text: text
508
572
  });
509
573
 
510
574
  // After sending, we can stop the typing indicator
@@ -519,7 +583,35 @@ export class WhatsAppService {
519
583
 
520
584
  async sendMenuMessage(jid: string, text: string) {
521
585
  const normalizedJid = this.normalizeRecipientJid(jid);
522
- return this.sendMessage(normalizedJid, text, 'menu');
586
+ const socket = this.getActiveSocket();
587
+
588
+ if (!socket) {
589
+ return {
590
+ success: false,
591
+ error: 'WhatsApp is not connected',
592
+ attempts: 0
593
+ };
594
+ }
595
+
596
+ try {
597
+ await this.sendPresence(normalizedJid, 'composing');
598
+ const response = await socket.sendMessage(normalizedJid, { text });
599
+ await this.sendPresence(normalizedJid, 'paused');
600
+
601
+ return {
602
+ success: true,
603
+ messageId: response?.key?.id,
604
+ attempts: 1
605
+ };
606
+ } catch (error: unknown) {
607
+ await this.sendPresence(normalizedJid, 'paused');
608
+ console.error(`Failed to send menu message to ${normalizedJid}:`, error);
609
+ return {
610
+ success: false,
611
+ error: error instanceof Error ? error.message : 'Unknown error',
612
+ attempts: 1
613
+ };
614
+ }
523
615
  }
524
616
 
525
617
  async sendPresence(jid: string, presence: 'composing' | 'recording' | 'paused') {
@@ -325,7 +325,8 @@ export class MenuHandler {
325
325
  await this.sendPromptedMenuMessage(ctx, {
326
326
  displayName: this.getConversationDisplayName(conversation),
327
327
  senderNumber: conversation.senderNumber,
328
- senderName: conversation.senderName
328
+ senderName: conversation.senderName,
329
+ appendPiSuffix: false
329
330
  });
330
331
  }
331
332
 
@@ -333,7 +334,8 @@ export class MenuHandler {
333
334
  await this.sendPromptedMenuMessage(ctx, {
334
335
  displayName: this.formatAllowedContactOption(contact),
335
336
  senderNumber: contact.number,
336
- senderName: contact.name
337
+ senderName: contact.name,
338
+ appendPiSuffix: true
337
339
  });
338
340
  }
339
341
 
@@ -343,9 +345,10 @@ export class MenuHandler {
343
345
  displayName: string;
344
346
  senderNumber: string;
345
347
  senderName?: string;
348
+ appendPiSuffix: boolean;
346
349
  }
347
350
  ) {
348
- const { displayName, senderNumber, senderName } = options;
351
+ const { displayName, senderNumber, senderName, appendPiSuffix } = options;
349
352
  for (let attempt = 0; attempt < 2; attempt++) {
350
353
  const inputText = (await ctx.ui.input(`Send a message to ${displayName}:`))?.trim() || '';
351
354
 
@@ -354,13 +357,14 @@ export class MenuHandler {
354
357
  continue;
355
358
  }
356
359
 
357
- const result = await this.whatsappService.sendMenuMessage(this.toJid(senderNumber), inputText);
360
+ const messageText = appendPiSuffix ? `${inputText} π` : inputText;
361
+ const result = await this.whatsappService.sendMenuMessage(this.toJid(senderNumber), messageText);
358
362
  if (result.success) {
359
363
  await this.recentsService.recordMessage({
360
364
  messageId: result.messageId ?? `${Date.now()}`,
361
365
  senderNumber,
362
366
  senderName,
363
- text: inputText,
367
+ text: messageText,
364
368
  direction: 'outgoing',
365
369
  timestamp: Date.now()
366
370
  });
@@ -453,7 +457,9 @@ export class MenuHandler {
453
457
  }
454
458
 
455
459
  private formatAllowedContactOption(contact: Contact): string {
456
- 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}`;
457
463
  }
458
464
 
459
465
  private sortContactsAlphabetically(contacts: Contact[]): Contact[] {
@@ -510,7 +516,9 @@ export class MenuHandler {
510
516
  private getConversationDisplayName(conversation: RecentConversationSummary): string {
511
517
  const allowedContact = this.sessionManager.getAllowedContact(conversation.senderNumber);
512
518
  const displayName = allowedContact?.name || conversation.senderName;
513
- 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}`;
514
522
  }
515
523
 
516
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);