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 +43 -32
- package/package.json +2 -2
- package/src/models/whatsapp.types.ts +0 -3
- package/src/services/audio.service.ts +1 -1
- package/src/services/incoming-media.service.ts +2 -2
- package/src/services/incoming-message.resolver.ts +1 -1
- package/src/services/message.sender.ts +33 -11
- package/src/services/recents.service.ts +2 -0
- package/src/services/session.manager.ts +152 -139
- package/src/services/whatsapp-pi.logger.ts +22 -1
- package/src/services/whatsapp.service.ts +111 -19
- package/src/ui/menu.handler.ts +15 -7
- package/src/ui/message-detail.view.ts +4 -4
- package/whatsapp-pi.ts +70 -14
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ A WhatsApp integration extension for the **[Pi Coding Agent](https://github.com/
|
|
|
8
8
|
|
|
9
9
|
[](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
|
|
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,
|
|
110
|
-
└── ui/ # Menu handlers
|
|
122
|
+
├── services/ # Core services (WhatsApp, Session, Recents, Media)
|
|
123
|
+
└── ui/ # Menu handlers and TUI views
|
|
111
124
|
|
|
112
|
-
|
|
113
|
-
|
|
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-
|
|
126
|
-
|
|
127
|
-
- **Auto-Connect Support**: Use the `--whatsapp-pi-online` flag to
|
|
128
|
-
- **
|
|
129
|
-
- **
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
- **
|
|
133
|
-
- **Intelligent Message Filtering**:
|
|
134
|
-
|
|
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.
|
|
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
|
-
"
|
|
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 '
|
|
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,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
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
95
|
+
// 5. Backoff before retry
|
|
77
96
|
if (attempts < maxRetries) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 '
|
|
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
|
|
15
|
-
private
|
|
16
|
-
private readonly configPath
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
477
|
+
const isGroup = remoteJid.endsWith('@g.us');
|
|
452
478
|
|
|
453
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|
package/src/ui/menu.handler.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
229
|
-
promptSnippet: "send_wa_message(jid, message) - Send a WhatsApp message to
|
|
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({
|
|
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: ${
|
|
293
|
+
` To: ${resolvedJid}`,
|
|
251
294
|
' Message:',
|
|
252
295
|
formattedMessage
|
|
253
296
|
].join('\n'));
|
|
254
297
|
|
|
255
|
-
const result = await whatsappService.sendMessage(
|
|
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
|
|
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: ${
|
|
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: ${
|
|
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);
|