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 +97 -0
- package/package.json +43 -0
- package/src/models/whatsapp.types.ts +46 -0
- package/src/services/audio.service.ts +53 -0
- package/src/services/message.sender.ts +93 -0
- package/src/services/session.manager.ts +191 -0
- package/src/services/whatsapp.service.ts +245 -0
- package/src/ui/menu.handler.ts +138 -0
- package/whatsapp-pi.ts +211 -0
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
|
+
}
|