whatsapp-pi 1.0.0

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 ADDED
@@ -0,0 +1,97 @@
1
+ # WhatsApp-Pi
2
+
3
+ WhatsApp integration for Pi coding agent with message filtering, blocking, and reliable message delivery.
4
+
5
+ ## Features
6
+
7
+ - **Manual WhatsApp Connection**: QR code-based authentication with session persistence
8
+ - **Allow List**: Control which numbers can interact with Pi
9
+ - Add contacts with optional names for easy identification
10
+ - View ignored numbers (not in allow list) and add them when needed
11
+ - **Reliable Messaging**: Queue-based message sending with retry logic
12
+ - **TUI Integration**: Menu-driven interface for managing connections and contacts
13
+
14
+ ## Quick Start
15
+
16
+ 1. Install dependencies:
17
+ ```bash
18
+ npm install
19
+ ```
20
+
21
+ 2. Run the extension:
22
+ ```bash
23
+ pi -e whatsapp-pi.ts
24
+ ```
25
+
26
+ For verbose mode (shows Baileys trace logs for debugging):
27
+ ```bash
28
+ pi -e whatsapp-pi.ts -v
29
+ # or
30
+ pi -e whatsapp-pi.ts --verbose
31
+ ```
32
+
33
+ 3. Use the menu to connect WhatsApp and manage allowed/blocked numbers
34
+
35
+ ## Commands
36
+
37
+ - `/whatsapp` - Open the WhatsApp management menu
38
+ - **Allow Numbers**: Manage contacts that can interact with Pi
39
+ - **Blocked Numbers**: View ignored numbers (not in allow list) and add them to allow list
40
+
41
+ ## Important Configuration
42
+
43
+ ### Using Your Own WhatsApp Number
44
+
45
+ If you're using your **own WhatsApp number** (not a separate bot number), you need to modify the message filtering in `src/services/whatsapp.service.ts`:
46
+
47
+ **Remove this line from `handleIncomingMessages()`:**
48
+ ```typescript
49
+ // Ignore messages sent by the bot itself
50
+ if (msg.key.fromMe) return;
51
+ ```
52
+
53
+ **Why?** When using your own number:
54
+ - Pi sends messages from your account (marked with `π` symbol)
55
+ - The `fromMe` filter blocks ALL your outgoing messages, including Pi's responses
56
+ - The `π` symbol check is sufficient to prevent message loops
57
+
58
+ **Keep this check:**
59
+ ```typescript
60
+ // Ignore messages sent by Pi (marked with π)
61
+ const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || "";
62
+ if (text.endsWith('π')) return;
63
+ ```
64
+
65
+ This ensures Pi doesn't process its own sent messages while still receiving messages from others.
66
+
67
+ ## Project Structure
68
+
69
+ ```
70
+ src/
71
+ ├── models/ # Type definitions
72
+ ├── services/ # Core services (WhatsApp, Session, MessageSender)
73
+ └── ui/ # Menu handlers
74
+
75
+ specs/ # Feature specifications
76
+ tests/ # Unit and integration tests
77
+ ```
78
+
79
+ ## Documentation
80
+
81
+ See `specs/` directory for detailed feature documentation:
82
+ - `001-whatsapp-tui-integration/` - TUI menu system
83
+ - `002-manual-whatsapp-connection/` - Connection management
84
+ - `003-whatsapp-messaging-refactor/` - Reliable messaging
85
+ - `004-blocked-numbers-management/` - Block list feature
86
+
87
+ ## Development
88
+
89
+ Run tests:
90
+ ```bash
91
+ npm test
92
+ ```
93
+
94
+ Lint:
95
+ ```bash
96
+ npm run lint
97
+ ```
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "whatsapp-pi",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "WhatsApp integration extension for Pi",
6
+ "main": "whatsapp-pi.ts",
7
+ "files": [
8
+ "whatsapp-pi.ts",
9
+ "src"
10
+ ],
11
+ "keywords": [
12
+ "pi",
13
+ "pi-extension",
14
+ "whatsapp",
15
+ "baileys",
16
+ "agent"
17
+ ],
18
+ "author": "Rapha",
19
+ "license": "MIT",
20
+ "scripts": {
21
+ "test": "vitest run",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "@whiskeysockets/baileys": "^6.11.0",
26
+ "pino": "^10.3.1",
27
+ "qrcode-terminal": "^0.12.0"
28
+ },
29
+ "devDependencies": {
30
+ "@mariozechner/pi-coding-agent": "latest",
31
+ "@types/node": "^20.11.0",
32
+ "@types/qrcode-terminal": "^0.12.2",
33
+ "ts-node": "^10.9.2",
34
+ "tsx": "^4.7.0",
35
+ "typescript": "^5.3.0",
36
+ "vitest": "^1.2.0"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./whatsapp-pi.ts"
41
+ ]
42
+ }
43
+ }
@@ -0,0 +1,46 @@
1
+ export type SessionStatus = 'logged-out' | 'pairing' | 'connected' | 'disconnected';
2
+
3
+ export interface WhatsAppSession {
4
+ id: string;
5
+ status: SessionStatus;
6
+ credentialsPath: string;
7
+ }
8
+
9
+ export interface AllowList {
10
+ numbers: string[];
11
+ }
12
+
13
+ export interface IncomingMessage {
14
+ id: string;
15
+ remoteJid: string;
16
+ pushName?: string;
17
+ text?: string;
18
+ timestamp: number;
19
+ }
20
+
21
+ export interface MessageRequest {
22
+ recipientJid: string;
23
+ text: string;
24
+ options?: {
25
+ maxRetries?: number;
26
+ priority?: 'high' | 'normal';
27
+ };
28
+ }
29
+
30
+ export interface MessageResult {
31
+ success: boolean;
32
+ messageId?: string;
33
+ error?: string;
34
+ attempts: number;
35
+ }
36
+
37
+ export class WhatsAppError extends Error {
38
+ constructor(public code: string, message: string) {
39
+ super(message);
40
+ this.name = 'WhatsAppError';
41
+ }
42
+ }
43
+
44
+ export function validatePhoneNumber(number: string): boolean {
45
+ return /^\+[1-9]\d{1,14}$/.test(number);
46
+ }
@@ -0,0 +1,53 @@
1
+ import { downloadContentFromMessage } from '@whiskeysockets/baileys';
2
+ import { exec } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { writeFile, mkdir } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+
8
+ const execAsync = promisify(exec);
9
+
10
+ export class AudioService {
11
+ private readonly mediaDir = '/home/opc/.pi/whatsapp-medias';
12
+ private readonly whisperPath = '/home/opc/.local/bin/whisper';
13
+
14
+ constructor() {
15
+ if (!existsSync(this.mediaDir)) {
16
+ mkdir(this.mediaDir, { recursive: true }).catch(() => {});
17
+ }
18
+ }
19
+
20
+ async transcribe(audioMessage: any): Promise<string> {
21
+ try {
22
+ const filename = `audio_${Date.now()}`;
23
+ const inputPath = join(this.mediaDir, `${filename}.ogg`);
24
+
25
+ // Download audio content
26
+ const stream = await downloadContentFromMessage(audioMessage, 'audio');
27
+ let buffer = Buffer.from([]);
28
+ for await (const chunk of stream) {
29
+ buffer = Buffer.concat([buffer, chunk]);
30
+ }
31
+
32
+ await writeFile(inputPath, buffer);
33
+
34
+ // Transcribe using Whisper
35
+ // Using small model for better accuracy
36
+ const command = `${this.whisperPath} "${inputPath}" --model small --language pt --output_format txt --output_dir "${this.mediaDir}" --fp16 False`;
37
+
38
+ await execAsync(command);
39
+
40
+ const txtPath = join(this.mediaDir, `${filename}.txt`);
41
+ if (existsSync(txtPath)) {
42
+ const fs = await import('node:fs/promises');
43
+ const text = await fs.readFile(txtPath, 'utf8');
44
+ return text.trim();
45
+ }
46
+
47
+ return '[Transcrição vazia]';
48
+ } catch (error) {
49
+ console.error('[AudioService] Transcription error:', error);
50
+ return `[Erro na transcrição: ${error instanceof Error ? error.message : String(error)}]`;
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,93 @@
1
+ import { WhatsAppService } from './whatsapp.service.js';
2
+ import { MessageRequest, MessageResult, WhatsAppError } from '../models/whatsapp.types.js';
3
+
4
+ export class MessageSender {
5
+ private whatsappService: WhatsAppService;
6
+
7
+ constructor(whatsappService: WhatsAppService) {
8
+ this.whatsappService = whatsappService;
9
+ }
10
+
11
+ /**
12
+ * Pauses execution for the specified time.
13
+ * @param ms Milliseconds to sleep.
14
+ */
15
+ private async sleep(ms: number) {
16
+ return new Promise(resolve => setTimeout(resolve, ms));
17
+ }
18
+
19
+ /**
20
+ * Waits for the WhatsApp connection to be active.
21
+ * @param timeoutMs Maximum time to wait in milliseconds.
22
+ * @throws {WhatsAppError} If connection is not established within timeout.
23
+ */
24
+ private async waitIfOffline(timeoutMs: number = 30000): Promise<void> {
25
+ const start = Date.now();
26
+ while (this.whatsappService.getStatus() !== 'connected') {
27
+ if (Date.now() - start > timeoutMs) {
28
+ throw new WhatsAppError('TIMEOUT', 'Timed out waiting for WhatsApp connection');
29
+ }
30
+ await this.sleep(1000);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Sends a message with retry logic and connection awareness.
36
+ * @param request The message recipient and content.
37
+ * @returns Promise resolving to a result object indicating success or failure.
38
+ */
39
+ public async send(request: MessageRequest): Promise<MessageResult> {
40
+ const maxRetries = request.options?.maxRetries ?? 3;
41
+ let attempts = 0;
42
+ let lastError: any = null;
43
+
44
+ while (attempts < maxRetries) {
45
+ attempts++;
46
+ try {
47
+ // 1. Ensure we are online
48
+ await this.waitIfOffline();
49
+
50
+ // 2. Get active socket
51
+ const socket = this.whatsappService.getSocket();
52
+ if (!socket) {
53
+ throw new WhatsAppError('SOCKET_NOT_INIT', 'WhatsApp socket not initialized');
54
+ }
55
+
56
+ // 3. Send the message
57
+ // Note: Branding π is applied here to ensure consistency
58
+ const response = await socket.sendMessage(request.recipientJid, {
59
+ text: `${request.text} π`
60
+ });
61
+
62
+ return {
63
+ success: true,
64
+ messageId: response.key.id,
65
+ attempts
66
+ };
67
+ } catch (error: any) {
68
+ lastError = error;
69
+ console.error(`[MessageSender] Attempt ${attempts} failed for ${request.recipientJid}: ${error.message}`);
70
+
71
+ // Specific handling for non-retryable errors
72
+ if (error.code === 'TIMEOUT') {
73
+ break;
74
+ }
75
+
76
+ // 4. Backoff before retry
77
+ 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
+ }
82
+ await this.sleep(backoff);
83
+ }
84
+ }
85
+ }
86
+
87
+ return {
88
+ success: false,
89
+ error: lastError?.message || 'Unknown error',
90
+ attempts
91
+ };
92
+ }
93
+ }
@@ -0,0 +1,191 @@
1
+ import { useMultiFileAuthState } from '@whiskeysockets/baileys';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { rm, readFile, writeFile, mkdir } from 'fs/promises';
5
+ import { SessionStatus } from '../models/whatsapp.types.js';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ export interface Contact {
10
+ number: string;
11
+ name?: string;
12
+ }
13
+
14
+ export class SessionManager {
15
+ // Data is stored in a fixed folder inside the extension project
16
+ private readonly baseDir = join(__dirname, '..', '..', '.pi-data');
17
+ private readonly authDir = join(this.baseDir, 'auth');
18
+ private readonly configPath = join(this.baseDir, 'config.json');
19
+
20
+ private status: SessionStatus = 'logged-out';
21
+ private allowList: Contact[] = [];
22
+ private blockList: Contact[] = [];
23
+ private ignoredNumbers: Contact[] = [];
24
+
25
+ public async ensureInitialized() {
26
+ try {
27
+ await mkdir(this.baseDir, { recursive: true });
28
+ await mkdir(this.authDir, { recursive: true });
29
+ await this.loadConfig();
30
+ } catch (error) {}
31
+ }
32
+
33
+ private async loadConfig() {
34
+ try {
35
+ const data = await readFile(this.configPath, 'utf-8');
36
+ const config = JSON.parse(data);
37
+
38
+ const cleanContact = (item: any): Contact | null => {
39
+ if (typeof item === 'string') return { number: item };
40
+ if (item && typeof item === 'object') {
41
+ let num = item.number;
42
+ // Unroll nested objects if any
43
+ while (num && typeof num === 'object' && num.number) {
44
+ num = num.number;
45
+ }
46
+ if (typeof num === 'string') {
47
+ return { number: num, name: item.name };
48
+ }
49
+ }
50
+ return null;
51
+ };
52
+
53
+ this.allowList = (config.allowList || []).map(cleanContact).filter(Boolean) as Contact[];
54
+ this.blockList = (config.blockList || []).map(cleanContact).filter(Boolean) as Contact[];
55
+ this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
56
+ this.status = config.status || 'logged-out';
57
+ } catch (error) {
58
+ // File not found is fine
59
+ }
60
+ }
61
+
62
+ public async saveConfig() {
63
+ try {
64
+ const config = {
65
+ allowList: this.allowList,
66
+ blockList: this.blockList,
67
+ ignoredNumbers: this.ignoredNumbers,
68
+ status: this.status
69
+ };
70
+ await writeFile(this.configPath, JSON.stringify(config, null, 2));
71
+ } catch (error) {
72
+ console.error('Failed to save config:', error);
73
+ }
74
+ }
75
+
76
+ getAllowList(): Contact[] {
77
+ return this.allowList;
78
+ }
79
+
80
+ getBlockList(): Contact[] {
81
+ return this.blockList;
82
+ }
83
+
84
+ getIgnoredNumbers(): Contact[] {
85
+ return this.ignoredNumbers;
86
+ }
87
+
88
+ async addNumber(number: any, name?: string) {
89
+ // Handle potential nested objects from legacy bugs
90
+ let cleanNumber = number;
91
+ while (cleanNumber && typeof cleanNumber === 'object' && cleanNumber.number) {
92
+ cleanNumber = cleanNumber.number;
93
+ }
94
+
95
+ if (typeof cleanNumber !== 'string') {
96
+ console.warn('[SessionManager] Attempted to add invalid number:', cleanNumber);
97
+ return;
98
+ }
99
+
100
+ if (!this.allowList.find(c => c.number === cleanNumber)) {
101
+ this.allowList.push({ number: cleanNumber, name });
102
+ // Remove from blockList and ignoredNumbers if it was there
103
+ this.blockList = this.blockList.filter(c => c.number !== cleanNumber);
104
+ this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== cleanNumber);
105
+ await this.saveConfig();
106
+ }
107
+ }
108
+
109
+ async removeNumber(number: string) {
110
+ this.allowList = this.allowList.filter(c => c.number !== number);
111
+ await this.saveConfig();
112
+ }
113
+
114
+ async blockNumber(number: string, name?: string) {
115
+ if (!this.blockList.find(c => c.number === number)) {
116
+ this.blockList.push({ number, name });
117
+ // Remove from allowList if it was there
118
+ this.allowList = this.allowList.filter(c => c.number !== number);
119
+ await this.saveConfig();
120
+ }
121
+ }
122
+
123
+ async unblockNumber(number: string) {
124
+ this.blockList = this.blockList.filter(c => c.number !== number);
125
+ await this.saveConfig();
126
+ }
127
+
128
+ async unblockAndAllow(number: string) {
129
+ const blocked = this.blockList.find(c => c.number === number);
130
+ this.blockList = this.blockList.filter(c => c.number !== number);
131
+ if (!this.allowList.find(c => c.number === number)) {
132
+ this.allowList.push({ number, name: blocked?.name });
133
+ }
134
+ await this.saveConfig();
135
+ }
136
+
137
+ isAllowed(number: string): boolean {
138
+ return this.allowList.some(c => c.number === number);
139
+ }
140
+
141
+ isBlocked(number: string): boolean {
142
+ return this.blockList.some(c => c.number === number);
143
+ }
144
+
145
+ async trackIgnoredNumber(number: string, name?: string) {
146
+ // Only track if not already in allow list, block list, or ignored list
147
+ if (!this.allowList.find(c => c.number === number) &&
148
+ !this.blockList.find(c => c.number === number) &&
149
+ !this.ignoredNumbers.find(c => c.number === number)) {
150
+ this.ignoredNumbers.push({ number, name });
151
+ await this.saveConfig();
152
+ }
153
+ }
154
+
155
+ public async isRegistered(): Promise<boolean> {
156
+ try {
157
+ const credsPah = join(this.authDir, 'creds.json');
158
+ await readFile(credsPah);
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ async getAuthState() {
166
+ return await useMultiFileAuthState(this.authDir);
167
+ }
168
+
169
+ async clearSession() {
170
+ try {
171
+ await rm(this.authDir, { recursive: true, force: true });
172
+ this.status = 'logged-out';
173
+ await this.saveConfig();
174
+ } catch (error) {
175
+ console.error('Failed to clear session:', error);
176
+ }
177
+ }
178
+
179
+ getStatus(): SessionStatus {
180
+ return this.status;
181
+ }
182
+
183
+ async setStatus(status: SessionStatus) {
184
+ this.status = status;
185
+ await this.saveConfig();
186
+ }
187
+
188
+ getAuthDir(): string {
189
+ return this.authDir;
190
+ }
191
+ }
@@ -0,0 +1,245 @@
1
+ import {
2
+ makeWASocket,
3
+ DisconnectReason,
4
+ useMultiFileAuthState,
5
+ fetchLatestBaileysVersion,
6
+ makeCacheableSignalKeyStore
7
+ } from '@whiskeysockets/baileys';
8
+ import P from 'pino';
9
+ import { Boom } from '@hapi/boom';
10
+ import { SessionManager } from './session.manager.js';
11
+ import { WhatsAppSession, SessionStatus } from '../models/whatsapp.types.js';
12
+ import { MessageSender } from './message.sender.js';
13
+
14
+ export class WhatsAppService {
15
+ private socket: any;
16
+ private sessionManager: SessionManager;
17
+ private messageSender: MessageSender;
18
+ private isReconnecting = false;
19
+ private verboseMode = false;
20
+
21
+ constructor(sessionManager: SessionManager) {
22
+ this.sessionManager = sessionManager;
23
+ this.messageSender = new MessageSender(this);
24
+ }
25
+
26
+ public getStatus(): SessionStatus {
27
+ return this.sessionManager.getStatus();
28
+ }
29
+
30
+ public getSocket(): any {
31
+ return this.socket;
32
+ }
33
+
34
+ public isVerbose(): boolean {
35
+ return this.verboseMode;
36
+ }
37
+
38
+ public setVerboseMode(verbose: boolean) {
39
+ this.verboseMode = verbose;
40
+ }
41
+
42
+ async start() {
43
+ if (this.isReconnecting) return;
44
+ this.onStatusUpdate?.('| WhatsApp: Connecting...');
45
+
46
+ const { state, saveCreds } = await this.sessionManager.getAuthState();
47
+ const { version } = await fetchLatestBaileysVersion();
48
+
49
+ // Cleanup existing socket if any
50
+ if (this.socket) {
51
+ this.socket.ev.removeAllListeners('connection.update');
52
+ this.socket.ev.removeAllListeners('creds.update');
53
+ this.socket.ev.removeAllListeners('messages.upsert');
54
+ try {
55
+ this.socket.end(undefined);
56
+ } catch (e) {}
57
+ }
58
+
59
+ const logger = P({ level: this.verboseMode ? 'trace' : 'silent' });
60
+
61
+ this.socket = makeWASocket({
62
+ version,
63
+ printQRInTerminal: false,
64
+ auth: {
65
+ creds: state.creds,
66
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
67
+ },
68
+ logger,
69
+ });
70
+
71
+ this.socket.ev.on('creds.update', saveCreds);
72
+
73
+ this.socket.ev.on('connection.update', async (update: any) => {
74
+ const { connection, lastDisconnect, qr } = update;
75
+
76
+ if (qr) {
77
+ this.sessionManager.setStatus('pairing');
78
+ this.onQRCode?.(qr);
79
+ this.onStatusUpdate?.('| WhatsApp: Pairing...');
80
+ }
81
+
82
+ if (connection === 'close') {
83
+ const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
84
+ const errorMessage = lastDisconnect?.error?.message || '';
85
+ const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
86
+
87
+ console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
88
+
89
+ if (errorMessage.includes('bad-request') || statusCode === 400) {
90
+ console.error('Bad request error detected - clearing session and forcing re-auth');
91
+ await this.sessionManager.clearSession();
92
+ this.sessionManager.setStatus('logged-out');
93
+ this.onStatusUpdate?.('| WhatsApp: Logged out');
94
+ return;
95
+ }
96
+
97
+ if (statusCode === DisconnectReason.connectionReplaced) {
98
+ console.error('Connection replaced - another instance connected');
99
+ this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
100
+ return;
101
+ }
102
+
103
+ if (shouldReconnect && !this.isReconnecting) {
104
+ this.isReconnecting = true;
105
+ this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
106
+ setTimeout(() => {
107
+ this.isReconnecting = false;
108
+ this.start();
109
+ }, 3000);
110
+ } else if (!shouldReconnect) {
111
+ this.sessionManager.setStatus('logged-out');
112
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
113
+ }
114
+ } else if (connection === 'open') {
115
+ if (this.verboseMode) {
116
+ console.log('WhatsApp connection successfully opened');
117
+ }
118
+ this.isReconnecting = false;
119
+ this.sessionManager.setStatus('connected');
120
+ this.onStatusUpdate?.('| WhatsApp: Connected');
121
+ }
122
+ });
123
+
124
+ this.socket.ev.on('messages.upsert', (m: any) => this.handleIncomingMessages(m));
125
+ }
126
+
127
+ public async handleIncomingMessages(m: any) {
128
+ if (this.sessionManager.getStatus() !== 'connected') return;
129
+ const msg = m.messages[0];
130
+ if (!msg || !msg.key.remoteJid) return;
131
+
132
+ // KEEP IT COMMENTED
133
+ // if (msg.key.fromMe) return;
134
+
135
+ // Ignore messages sent by Pi (marked with π)
136
+ const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || "";
137
+ if (text.endsWith('π')) return;
138
+
139
+
140
+ const sender = msg.key.remoteJid.split('@')[0];
141
+ const fullNumber = '+' + sender;
142
+
143
+ if (this.sessionManager.isBlocked(fullNumber)) {
144
+ if (this.isVerbose()) {
145
+ console.log(`Ignoring message from ${fullNumber} (explicitly blocked)`);
146
+ }
147
+ return;
148
+ }
149
+
150
+ if (!this.sessionManager.isAllowed(fullNumber)) {
151
+ if (this.isVerbose()) {
152
+ console.log(`Ignoring message from ${fullNumber} (not in allow list)`);
153
+ }
154
+ // Track this number as ignored so user can allow it later
155
+ const pushName = msg.pushName || undefined;
156
+ await this.sessionManager.trackIgnoredNumber(fullNumber, pushName);
157
+ return;
158
+ }
159
+
160
+ this.lastRemoteJid = msg.key.remoteJid;
161
+ this.onMessage?.(m);
162
+ }
163
+
164
+ private onQRCode?: (qr: string) => void;
165
+ private onMessage?: (m: any) => void;
166
+ private onStatusUpdate?: (status: string) => void;
167
+ private lastRemoteJid: string | null = null;
168
+
169
+ setQRCodeCallback(callback: (qr: string) => void) {
170
+ this.onQRCode = callback;
171
+ }
172
+
173
+ setMessageCallback(callback: (m: any) => void) {
174
+ this.onMessage = callback;
175
+ }
176
+
177
+ setStatusCallback(callback: (status: string) => void) {
178
+ this.onStatusUpdate = callback;
179
+ }
180
+
181
+ public getLastRemoteJid(): string | null {
182
+ return this.lastRemoteJid;
183
+ }
184
+
185
+ async sendMessage(jid: string, text: string) {
186
+ // Ensure we show the typing indicator before sending
187
+ await this.sendPresence(jid, 'composing');
188
+
189
+ const result = await this.messageSender.send({
190
+ recipientJid: jid,
191
+ text: text
192
+ });
193
+
194
+ // After sending, we can stop the typing indicator
195
+ await this.sendPresence(jid, 'paused');
196
+
197
+ if (!result.success) {
198
+ console.error(`Failed to send message to ${jid}: ${result.error}`);
199
+ }
200
+
201
+ return result;
202
+ }
203
+
204
+ async sendPresence(jid: string, presence: 'composing' | 'recording' | 'paused') {
205
+ if (!this.socket || this.getStatus() !== 'connected') return;
206
+ try {
207
+ await this.socket.sendPresenceUpdate(presence, jid);
208
+ } catch (error) {
209
+ if (this.verboseMode) {
210
+ console.error(`Failed to send presence update to ${jid}:`, error);
211
+ }
212
+ }
213
+ }
214
+
215
+ async markRead(jid: string, messageId: string, fromMe: boolean = false) {
216
+ if (!this.socket || this.getStatus() !== 'connected') return;
217
+ try {
218
+ await this.socket.readMessages([{ remoteJid: jid, id: messageId, fromMe }]);
219
+ } catch (error) {
220
+ if (this.verboseMode) {
221
+ console.error(`Failed to mark message as read:`, error);
222
+ }
223
+ }
224
+ }
225
+
226
+ async logout() {
227
+ await this.socket?.logout();
228
+ await this.sessionManager.clearSession();
229
+ }
230
+
231
+ async stop() {
232
+ if (this.socket) {
233
+ this.socket.ev.removeAllListeners('connection.update');
234
+ this.socket.ev.removeAllListeners('creds.update');
235
+ this.socket.ev.removeAllListeners('messages.upsert');
236
+ try {
237
+ this.socket.end(undefined);
238
+ } catch (e) {}
239
+ this.socket = undefined;
240
+ this.isReconnecting = false;
241
+ }
242
+ await this.sessionManager.setStatus('disconnected');
243
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
244
+ }
245
+ }
@@ -0,0 +1,138 @@
1
+ import { WhatsAppService } from '../services/whatsapp.service.js';
2
+ import { SessionManager } from '../services/session.manager.js';
3
+ import { validatePhoneNumber } from '../models/whatsapp.types.js';
4
+ import * as qrcode from 'qrcode-terminal';
5
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
6
+
7
+ export class MenuHandler {
8
+ private whatsappService: WhatsAppService;
9
+ private sessionManager: SessionManager;
10
+
11
+ constructor(whatsappService: WhatsAppService, sessionManager: SessionManager) {
12
+ this.whatsappService = whatsappService;
13
+ this.sessionManager = sessionManager;
14
+ }
15
+
16
+ async handleCommand(ctx: ExtensionCommandContext) {
17
+ const status = this.sessionManager.getStatus();
18
+ const registered = await this.sessionManager.isRegistered();
19
+ const options: string[] = [];
20
+
21
+ if (status === 'connected') {
22
+ options.push('Disconnect WhatsApp');
23
+ } else {
24
+ options.push('Connect WhatsApp');
25
+ }
26
+
27
+ if (registered) options.push('Logoff (Delete Session)');
28
+
29
+ options.push('Allow Numbers');
30
+ options.push('Blocked Numbers');
31
+ options.push('Back');
32
+
33
+ const choice = await ctx.ui.select(`WhatsApp (Status: ${status})`, options);
34
+
35
+ switch (choice) {
36
+ case 'Connect WhatsApp':
37
+ this.whatsappService.setQRCodeCallback((qr) => {
38
+ ctx.ui.notify('Scan the QR code in the terminal', 'info');
39
+ qrcode.generate(qr, { small: true });
40
+ });
41
+ await this.whatsappService.start();
42
+ ctx.ui.notify('WhatsApp Connection Started', 'info');
43
+ break;
44
+ case 'Disconnect WhatsApp':
45
+ await this.whatsappService.stop();
46
+ ctx.ui.notify('WhatsApp Agent Disconnected', 'warning');
47
+ break;
48
+ case 'Logoff (Delete Session)':
49
+ const confirm = await ctx.ui.confirm('Logoff', 'Delete all credentials?');
50
+ if (confirm) {
51
+ await this.whatsappService.logout();
52
+ ctx.ui.notify('Logged off and credentials deleted', 'info');
53
+ }
54
+ break;
55
+ case 'Allow Numbers':
56
+ await this.manageAllowList(ctx);
57
+ break;
58
+ case 'Blocked Numbers':
59
+ await this.manageBlockList(ctx);
60
+ break;
61
+ }
62
+ }
63
+
64
+ private async manageAllowList(ctx: ExtensionCommandContext) {
65
+ const list = this.sessionManager.getAllowList();
66
+ // Exibe o nome se existir, senão apenas o número
67
+ const options = [...list.map(c => `Remove ${c.name ? c.name + ' (' + c.number + ')' : c.number}`), 'Add Number', 'Back'];
68
+
69
+ const choice = await ctx.ui.select('Allowed Numbers', options);
70
+
71
+ if (choice === 'Add Number') {
72
+ const num = await ctx.ui.input('Enter number (e.g. +5511999999999):');
73
+ if (num && validatePhoneNumber(num)) {
74
+ await this.sessionManager.addNumber(num);
75
+ ctx.ui.notify(`Added ${num}`, 'info');
76
+ } else {
77
+ ctx.ui.notify('Invalid number format', 'error');
78
+ }
79
+ await this.manageAllowList(ctx);
80
+ } else if (choice?.startsWith('Remove ')) {
81
+ // Extrai o número entre parênteses ou o que sobrar depois de "Remove "
82
+ let num = choice.replace('Remove ', '');
83
+ if (num.includes('(')) {
84
+ const match = num.match(/\((.*?)\)/);
85
+ if (match) num = match[1];
86
+ }
87
+ await this.sessionManager.removeNumber(num);
88
+ ctx.ui.notify(`Removed ${num}`, 'info');
89
+ await this.manageAllowList(ctx);
90
+ }
91
+ }
92
+
93
+ private async manageBlockList(ctx: ExtensionCommandContext) {
94
+ const list = this.sessionManager.getBlockList();
95
+
96
+ if (list.length === 0) {
97
+ ctx.ui.notify('No blocked numbers', 'info');
98
+ await this.handleCommand(ctx);
99
+ return;
100
+ }
101
+
102
+ const options = [...list.map(c => c.name ? `${c.name} (${c.number})` : c.number), 'Back'];
103
+ const choice = await ctx.ui.select('Blocked Numbers (Select to Manage)', options);
104
+
105
+ if (choice && choice !== 'Back') {
106
+ let num = choice;
107
+ if (num.includes('(')) {
108
+ const match = num.match(/\((.*?)\)/);
109
+ if (match) num = match[1];
110
+ }
111
+ await this.manageBlockedNumber(ctx, num);
112
+ } else {
113
+ await this.handleCommand(ctx);
114
+ }
115
+ }
116
+
117
+ private async manageBlockedNumber(ctx: ExtensionCommandContext, number: string) {
118
+ const action = await ctx.ui.select(`Manage ${number}`, ['Unblock and Allow', 'Delete', 'Back']);
119
+
120
+ if (action === 'Unblock and Allow') {
121
+ const ok = await ctx.ui.confirm('Unblock', `Move ${number} to Allowed Numbers?`);
122
+ if (ok) {
123
+ await this.sessionManager.unblockAndAllow(number);
124
+ ctx.ui.notify(`${number} moved to Allowed List`, 'info');
125
+ }
126
+ await this.manageBlockList(ctx);
127
+ } else if (action === 'Delete') {
128
+ const ok = await ctx.ui.confirm('Delete', `Remove ${number} from Block List?`);
129
+ if (ok) {
130
+ await this.sessionManager.unblockNumber(number);
131
+ ctx.ui.notify(`${number} removed from Block List`, 'info');
132
+ }
133
+ await this.manageBlockList(ctx);
134
+ } else {
135
+ await this.manageBlockList(ctx);
136
+ }
137
+ }
138
+ }
package/whatsapp-pi.ts ADDED
@@ -0,0 +1,211 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { SessionManager } from './src/services/session.manager.js';
3
+ import { WhatsAppService } from './src/services/whatsapp.service.js';
4
+ import { MenuHandler } from './src/ui/menu.handler.js';
5
+ import { AudioService } from './src/services/audio.service.js';
6
+
7
+ console.log("[WhatsApp-Pi] Extension file loaded by Pi...");
8
+ export default function(pi: ExtensionAPI) {
9
+ // Register verbose flag
10
+ pi.registerFlag("v", {
11
+ description: "Enable verbose mode (show Baileys trace logs)",
12
+ type: "boolean",
13
+ default: false
14
+ });
15
+ pi.registerFlag("verbose", {
16
+ description: "Enable verbose mode (show Baileys trace logs)",
17
+ type: "boolean",
18
+ default: false
19
+ });
20
+
21
+ // Register whatsapp flags
22
+ pi.registerFlag("w", {
23
+ description: "Auto-connect to WhatsApp on startup",
24
+ type: "boolean",
25
+ default: false
26
+ });
27
+ pi.registerFlag("whatsapp", {
28
+ description: "Auto-connect to WhatsApp on startup",
29
+ type: "boolean",
30
+ default: false
31
+ });
32
+
33
+ const sessionManager = new SessionManager();
34
+ const whatsappService = new WhatsAppService(sessionManager);
35
+ const audioService = new AudioService();
36
+ const menuHandler = new MenuHandler(whatsappService, sessionManager);
37
+
38
+ // Initial status setup
39
+ pi.on("session_start", async (_event, ctx) => {
40
+ // Check verbose mode
41
+ const isVerboseFlagSet = pi.getFlag("v") === true || pi.getFlag("verbose") === true || process.argv.includes("-v") || process.argv.includes("--verbose");
42
+
43
+ const isVerbose = isVerboseFlagSet;
44
+
45
+ whatsappService.setVerboseMode(isVerbose);
46
+
47
+ if (isVerbose) {
48
+ console.log('[WhatsApp-Pi] Verbose mode enabled - Baileys trace logs will be shown');
49
+ }
50
+ ctx.ui.setStatus('whatsapp', '| WhatsApp: Disconnected');
51
+ whatsappService.setStatusCallback((status) => {
52
+ ctx.ui.setStatus('whatsapp', status);
53
+ });
54
+ await sessionManager.ensureInitialized();
55
+
56
+ for (const entry of ctx.sessionManager.getEntries()) {
57
+ if (entry.type === "custom" && entry.customType === "whatsapp-state") {
58
+ const data = entry.data as any;
59
+ if (data.status) await sessionManager.setStatus(data.status);
60
+ if (data.allowList) {
61
+ for (const n of data.allowList) {
62
+ const num = typeof n === "string" ? n : n.number;
63
+ const name = typeof n === "string" ? undefined : n.name;
64
+ await sessionManager.addNumber(num, name);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ // Check whatsapp flag
71
+ const isConnectFlagSet = pi.getFlag("w") === true || pi.getFlag("whatsapp") === true || process.argv.includes("-w") || process.argv.includes("--whatsapp");
72
+
73
+ // Auto-connect removed to avoid socket conflicts
74
+ if (await sessionManager.isRegistered()) {
75
+ const shouldConnect = isConnectFlagSet;
76
+
77
+ if (shouldConnect) {
78
+ ctx.ui.setStatus('whatsapp', '| WhatsApp: Auto-connecting...');
79
+
80
+ // Retry logic (max 3 attempts, 3s delay)
81
+ let attempts = 0;
82
+ const maxAttempts = 4; // Initial + 3 retries
83
+
84
+ const tryConnect = async () => {
85
+ attempts++;
86
+ try {
87
+ await whatsappService.start();
88
+ } catch (error) {
89
+ if (attempts < maxAttempts) {
90
+ ctx.ui.notify(`WhatsApp: Connection attempt ${attempts} failed. Retrying...`, 'warning');
91
+ setTimeout(tryConnect, 3000);
92
+ } else {
93
+ ctx.ui.notify('WhatsApp: Auto-connect failed after multiple attempts.', 'error');
94
+ ctx.ui.setStatus('whatsapp', '| WhatsApp: Connection Failed');
95
+ }
96
+ }
97
+ };
98
+
99
+ await tryConnect();
100
+ } else {
101
+ // We just ensure state is loaded, but do NOT call whatsappService.start()
102
+ await sessionManager.setStatus('disconnected');
103
+ }
104
+ } else if (isConnectFlagSet) {
105
+ ctx.ui.notify('WhatsApp: Auto-connect skipped. Manual login required.', 'info');
106
+ }
107
+ });
108
+
109
+ // Handle incoming messages by injecting them as user prompts
110
+ whatsappService.setMessageCallback(async (m) => {
111
+ const msg = m.messages[0];
112
+ if (!msg.message) return;
113
+
114
+ let text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || "";
115
+
116
+ const sender = msg.key.remoteJid?.split('@')[0] || "unknown";
117
+ const pushName = msg.pushName || "WhatsApp User";
118
+
119
+ // Mark as read and start typing indicator immediately
120
+ const remoteJid = msg.key.remoteJid;
121
+ if (remoteJid && msg.key.id) {
122
+ whatsappService.markRead(remoteJid, msg.key.id, msg.key.fromMe);
123
+ whatsappService.sendPresence(remoteJid, 'composing');
124
+ }
125
+
126
+ // Handle media types
127
+ if (msg.message.audioMessage) {
128
+ console.log(`[WhatsApp-Pi] Transcribing audio from ${pushName}...`);
129
+ const transcription = await audioService.transcribe(msg.message.audioMessage);
130
+ text = `[Áudio Transcrito]: ${transcription}`;
131
+ } else if (!text) {
132
+ if (msg.message.imageMessage) text = "[Image]";
133
+ else if (msg.message.videoMessage) text = "[Video]";
134
+ else if (msg.message.stickerMessage) text = "[Sticker]";
135
+ else if (msg.message.documentMessage) text = "[Document]";
136
+ else if (msg.message.contactMessage || msg.message.contactsArrayMessage) text = "[Contact]";
137
+ else if (msg.message.locationMessage) text = "[Location]";
138
+ else text = "[Unsupported Message Type]";
139
+ }
140
+
141
+ // Always log to console so it appears in the TUI log pane
142
+ console.log(`[WhatsApp-Pi] ${pushName} (+${sender}): ${text}`);
143
+
144
+ // Handle commands
145
+ const cmd = text.trim().toLowerCase();
146
+ if (cmd === '/new') {
147
+ console.log(`[WhatsApp-Pi] Session reset requested by ${pushName}. Terminating process to clear context...`);
148
+
149
+ await whatsappService.sendMessage(remoteJid!, "Iniciando nova sessão... 🆕\nO contexto anterior foi limpo e o serviço será reiniciado.");
150
+
151
+ // Give time for the message to be sent
152
+ setTimeout(() => {
153
+ // Exit process. The OS/Service Manager should restart it.
154
+ // When it restarts, it starts a new session by default
155
+ process.exit(0);
156
+ }, 2000);
157
+ return;
158
+ }
159
+
160
+ // Use a standard delivery to see if it improves TUI visibility
161
+ pi.sendUserMessage(`Mensagem de ${pushName} (+${sender}): ${text}`);
162
+ });
163
+
164
+ // Register commands
165
+ pi.registerCommand("whatsapp", {
166
+ description: "Manage WhatsApp integration",
167
+ handler: async (args, ctx) => {
168
+ await menuHandler.handleCommand(ctx);
169
+
170
+ // Persist state after changes
171
+ pi.appendEntry("whatsapp-state", {
172
+ status: sessionManager.getStatus(),
173
+ allowList: sessionManager.getAllowList()
174
+ });
175
+ }
176
+ });
177
+
178
+ // Handle outgoing messages (Agent -> WhatsApp)
179
+ pi.on("agent_start", async (_event, _ctx) => {
180
+ if (sessionManager.getStatus() !== 'connected') return;
181
+ const lastJid = whatsappService.getLastRemoteJid();
182
+ if (lastJid) {
183
+ await whatsappService.sendPresence(lastJid, 'composing');
184
+ }
185
+ });
186
+
187
+ pi.on("message_end", async (event, ctx) => {
188
+ if (sessionManager.getStatus() !== 'connected') return;
189
+
190
+ const { message } = event;
191
+ // Only reply if it's the assistant and we have a valid target
192
+ if (message.role === "assistant") {
193
+ const lastJid = whatsappService.getLastRemoteJid();
194
+ const text = message.content.filter(c => c.type === "text").map(c => c.text).join("\n");
195
+
196
+ if (lastJid && text) {
197
+ try {
198
+ await whatsappService.sendMessage(lastJid, text);
199
+ ctx.ui.notify(`Sent reply to WhatsApp contact`, 'info');
200
+ } catch (error) {
201
+ ctx.ui.notify(`Failed to send WhatsApp reply`, 'error');
202
+ }
203
+ }
204
+ }
205
+ });
206
+
207
+ pi.on("session_shutdown", async () => {
208
+ console.log("[WhatsApp-Pi] Session shutdown detected. Stopping WhatsApp service...");
209
+ await whatsappService.stop();
210
+ });
211
+ }