whatsapp-pi 1.0.36 → 1.0.38
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/package.json +3 -2
- package/src/services/baileys-console-filter.ts +66 -0
- package/src/services/incoming-media.service.ts +123 -0
- package/src/services/incoming-message.resolver.ts +129 -0
- package/src/services/recents.service.ts +0 -1
- package/src/services/whatsapp-pi.logger.ts +25 -0
- package/src/services/whatsapp.service.ts +61 -11
- package/src/ui/menu.handler.ts +83 -43
- package/whatsapp-pi.ts +26 -234
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whatsapp-pi",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.38",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "WhatsApp integration extension for Pi",
|
|
6
6
|
"main": "whatsapp-pi.ts",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@mariozechner/pi-coding-agent": "latest",
|
|
40
40
|
"@types/node": "^20.11.0",
|
|
41
41
|
"@types/qrcode-terminal": "^0.12.2",
|
|
42
|
+
"@vitest/coverage-v8": "^1.6.1",
|
|
42
43
|
"ts-node": "^10.9.2",
|
|
43
44
|
"tsx": "^4.7.0",
|
|
44
45
|
"typescript": "^5.3.0",
|
|
@@ -50,4 +51,4 @@
|
|
|
50
51
|
],
|
|
51
52
|
"image": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/WhatsApp.svg/512px-WhatsApp.svg.png"
|
|
52
53
|
}
|
|
53
|
-
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
type ConsoleMethod = 'log' | 'info' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
const noisyBaileysPatterns = [
|
|
4
|
+
'Failed to decrypt message with any known session',
|
|
5
|
+
'Session error:',
|
|
6
|
+
'Bad MAC',
|
|
7
|
+
'Closing open session in favor of incoming prekey bundle',
|
|
8
|
+
'Closing session:'
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const stringifyConsolePart = (part: unknown): string => {
|
|
12
|
+
if (part instanceof Error) {
|
|
13
|
+
return `${part.name}: ${part.message}\n${part.stack ?? ''}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (typeof part === 'string') {
|
|
17
|
+
return part;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
return JSON.stringify(part);
|
|
22
|
+
} catch {
|
|
23
|
+
return String(part);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const shouldSuppressBaileysConsoleMessage = (args: unknown[]): boolean => {
|
|
28
|
+
const message = args.map(stringifyConsolePart).join(' ');
|
|
29
|
+
return noisyBaileysPatterns.some(pattern => message.includes(pattern));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const installBaileysConsoleFilter = (verbose: boolean): (() => void) => {
|
|
33
|
+
if (verbose) {
|
|
34
|
+
return () => {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const methods: ConsoleMethod[] = ['log', 'info', 'warn', 'error'];
|
|
38
|
+
const originals = new Map<ConsoleMethod, (...args: any[]) => void>();
|
|
39
|
+
const patched = new Map<ConsoleMethod, (...args: any[]) => void>();
|
|
40
|
+
|
|
41
|
+
for (const method of methods) {
|
|
42
|
+
const original = console[method].bind(console);
|
|
43
|
+
originals.set(method, original);
|
|
44
|
+
|
|
45
|
+
const replacement = (...args: unknown[]) => {
|
|
46
|
+
if (shouldSuppressBaileysConsoleMessage(args)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
original(...args);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
patched.set(method, replacement);
|
|
54
|
+
console[method] = replacement as typeof console[typeof method];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
for (const method of methods) {
|
|
59
|
+
const replacement = patched.get(method);
|
|
60
|
+
const original = originals.get(method);
|
|
61
|
+
if (replacement && original && console[method] === replacement) {
|
|
62
|
+
console[method] = original as typeof console[typeof method];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { downloadContentFromMessage } from '@whiskeysockets/baileys';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { AudioService } from './audio.service.js';
|
|
5
|
+
import type { IncomingResolution } from './incoming-message.resolver.js';
|
|
6
|
+
import { WhatsAppPiLogger } from './whatsapp-pi.logger.js';
|
|
7
|
+
|
|
8
|
+
export interface ProcessedIncomingContent {
|
|
9
|
+
text: string;
|
|
10
|
+
imageBuffer?: Buffer;
|
|
11
|
+
imageMimeType?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class IncomingMediaService {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly audioService: AudioService,
|
|
17
|
+
private readonly logger = new WhatsAppPiLogger(false)
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
async process(resolved: IncomingResolution, pushName: string): Promise<ProcessedIncomingContent> {
|
|
21
|
+
if (resolved.kind === 'audio') {
|
|
22
|
+
return this.processAudio(resolved.audioMessage, pushName);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (resolved.kind === 'image') {
|
|
26
|
+
return this.processImage(resolved.imageMessage, resolved.text, pushName);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (resolved.kind === 'document') {
|
|
30
|
+
return this.processDocument(resolved.documentMessage, pushName);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { text: resolved.text };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async processAudio(audioMessage: any, pushName: string): Promise<ProcessedIncomingContent> {
|
|
37
|
+
this.logger.log(`[WhatsApp-Pi] Transcribing audio from ${pushName}...`);
|
|
38
|
+
const transcription = await this.audioService.transcribe(audioMessage);
|
|
39
|
+
return { text: `[Transcribed Audio]: ${transcription}` };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async processImage(imageMessage: any, fallbackText: string, pushName: string): Promise<ProcessedIncomingContent> {
|
|
43
|
+
this.logger.log(`[WhatsApp-Pi] Downloading image from ${pushName}...`);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const imageBuffer = await this.downloadMessage(imageMessage, 'image');
|
|
47
|
+
const rawMime = imageMessage.mimetype || 'image/jpeg';
|
|
48
|
+
let imageMimeType = rawMime.toLowerCase().split(';')[0].trim();
|
|
49
|
+
if (imageMimeType === 'image/jpg') imageMimeType = 'image/jpeg';
|
|
50
|
+
|
|
51
|
+
this.logger.log(`[WhatsApp-Pi] Image downloaded. MIME: ${imageMimeType} (original: ${rawMime}), Size: ${imageBuffer.length} bytes`);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
text: fallbackText || '[Image]',
|
|
55
|
+
imageBuffer,
|
|
56
|
+
imageMimeType
|
|
57
|
+
};
|
|
58
|
+
} catch (error) {
|
|
59
|
+
this.logger.error('[WhatsApp-Pi] Failed to download image:', error);
|
|
60
|
+
return { text: '[Image (download failed)]' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async processDocument(documentMessage: any, pushName: string): Promise<ProcessedIncomingContent> {
|
|
65
|
+
const fileName = documentMessage.fileName || 'unnamed_document';
|
|
66
|
+
const mimeType = documentMessage.mimetype || 'application/octet-stream';
|
|
67
|
+
const fileSize = documentMessage.fileLength ? Number(documentMessage.fileLength) : 0;
|
|
68
|
+
|
|
69
|
+
this.logger.log(`[WhatsApp-Pi] Downloading document from ${pushName}: ${fileName}...`);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const buffer = await this.downloadMessage(documentMessage, 'document');
|
|
73
|
+
const relativePath = await this.saveDocument(fileName, buffer);
|
|
74
|
+
|
|
75
|
+
this.logger.log(`[WhatsApp-Pi] Document saved to ${relativePath} (${buffer.length} bytes)`);
|
|
76
|
+
|
|
77
|
+
let text = `[Document Received: ${fileName}]\n`
|
|
78
|
+
+ `MIME Type: ${mimeType}\n`
|
|
79
|
+
+ `Size: ${this.formatFileSize(fileSize)}\n`
|
|
80
|
+
+ `Location: ${relativePath}`;
|
|
81
|
+
|
|
82
|
+
if (documentMessage.caption) {
|
|
83
|
+
text += `\n\nDescription: ${documentMessage.caption}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { text };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.logger.error('[WhatsApp-Pi] Failed to download document:', error);
|
|
89
|
+
return { text: `[Document: ${fileName} (download failed)]` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async downloadMessage(message: any, type: 'image' | 'document'): Promise<Buffer> {
|
|
94
|
+
const stream = await downloadContentFromMessage(message, type);
|
|
95
|
+
let buffer = Buffer.from([]);
|
|
96
|
+
|
|
97
|
+
for await (const chunk of stream) {
|
|
98
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return buffer;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async saveDocument(fileName: string, buffer: Buffer): Promise<string> {
|
|
105
|
+
const sanitized = fileName.replace(/[^a-z0-9\._-]/gi, '_');
|
|
106
|
+
const savedFileName = `${Date.now()}_${sanitized}`;
|
|
107
|
+
const documentDir = join(process.cwd(), '.pi-data', 'whatsapp', 'documents');
|
|
108
|
+
const absolutePath = join(documentDir, savedFileName);
|
|
109
|
+
|
|
110
|
+
await mkdir(documentDir, { recursive: true });
|
|
111
|
+
await writeFile(absolutePath, buffer);
|
|
112
|
+
|
|
113
|
+
return `./.pi-data/whatsapp/documents/${savedFileName}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private formatFileSize(fileSize: number): string {
|
|
117
|
+
if (fileSize > 1024 * 1024) {
|
|
118
|
+
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return `${(fileSize / 1024).toFixed(1)} KB`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { extractMessageContent } from '@whiskeysockets/baileys';
|
|
2
|
+
|
|
3
|
+
export type IncomingResolution =
|
|
4
|
+
| { kind: 'text'; text: string }
|
|
5
|
+
| { kind: 'audio'; text: string; audioMessage: any }
|
|
6
|
+
| { kind: 'image'; text: string; imageMessage: any }
|
|
7
|
+
| { kind: 'document'; text: string; documentMessage: any }
|
|
8
|
+
| { kind: 'contact'; text: string }
|
|
9
|
+
| { kind: 'location'; text: string }
|
|
10
|
+
| { kind: 'system'; text: string }
|
|
11
|
+
| { kind: 'unsupported'; text: string };
|
|
12
|
+
|
|
13
|
+
const protocolTypes: Record<number, string> = {
|
|
14
|
+
0: 'Message Deleted',
|
|
15
|
+
3: 'Disappearing Messages Updated',
|
|
16
|
+
4: 'Disappearing Message Sync Response',
|
|
17
|
+
5: 'History Sync Notification',
|
|
18
|
+
6: 'App State Sync Key Share',
|
|
19
|
+
7: 'App State Sync Key Request',
|
|
20
|
+
8: 'Message Backfill Request',
|
|
21
|
+
9: 'Security Notification Sync',
|
|
22
|
+
10: 'Fatal App State Sync Notification',
|
|
23
|
+
11: 'Phone Number Shared',
|
|
24
|
+
14: 'Message Edited',
|
|
25
|
+
16: 'Peer Data Request',
|
|
26
|
+
17: 'Peer Data Response',
|
|
27
|
+
18: 'Welcome Message Request',
|
|
28
|
+
19: 'Bot Feedback',
|
|
29
|
+
20: 'Media Notification'
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const unwrapMessageContent = (content: any): any => extractMessageContent(content) ?? content;
|
|
33
|
+
|
|
34
|
+
const getTypeName = (payload: any): string => {
|
|
35
|
+
if (!payload || typeof payload !== 'object') return 'unknown';
|
|
36
|
+
return Object.keys(payload)[0] || 'unknown';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const formatProtocolMessage = (protocolMessage: any): string => {
|
|
40
|
+
const typeLabel = protocolTypes[Number(protocolMessage?.type)] || 'System Update';
|
|
41
|
+
const editedText = protocolMessage?.editedMessage?.conversation
|
|
42
|
+
|| protocolMessage?.editedMessage?.extendedTextMessage?.text;
|
|
43
|
+
|
|
44
|
+
if (editedText) {
|
|
45
|
+
return `[${typeLabel}: ${editedText}]`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `[${typeLabel}]`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const extractIncomingText = (message: any): IncomingResolution => {
|
|
52
|
+
const content = unwrapMessageContent(message);
|
|
53
|
+
const inner = content?.ephemeralMessage?.message
|
|
54
|
+
|| content?.viewOnceMessage?.message
|
|
55
|
+
|| content?.viewOnceMessageV2?.message
|
|
56
|
+
|| content?.viewOnceMessageV2Extension?.message
|
|
57
|
+
|| content?.message;
|
|
58
|
+
|
|
59
|
+
const resolved = inner ? unwrapMessageContent(inner) : content;
|
|
60
|
+
const typeName = getTypeName(resolved);
|
|
61
|
+
const protocolMessage = resolved?.protocolMessage
|
|
62
|
+
|| (typeName === 'protocolMessage' ? resolved : undefined)
|
|
63
|
+
|| content?.protocolMessage;
|
|
64
|
+
|
|
65
|
+
if (protocolMessage) {
|
|
66
|
+
return { kind: 'system', text: formatProtocolMessage(protocolMessage) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (resolved?.conversation) {
|
|
70
|
+
return { kind: 'text', text: resolved.conversation };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (resolved?.extendedTextMessage?.text) {
|
|
74
|
+
return { kind: 'text', text: resolved.extendedTextMessage.text };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (resolved?.imageMessage) {
|
|
78
|
+
return {
|
|
79
|
+
kind: 'image',
|
|
80
|
+
text: resolved.imageMessage.caption || '[Image]',
|
|
81
|
+
imageMessage: resolved.imageMessage
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (resolved?.videoMessage) {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'text',
|
|
88
|
+
text: resolved.videoMessage.caption || '[Video]'
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (resolved?.audioMessage) {
|
|
93
|
+
return {
|
|
94
|
+
kind: 'audio',
|
|
95
|
+
text: '[Audio Message]',
|
|
96
|
+
audioMessage: resolved.audioMessage
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (resolved?.documentMessage) {
|
|
101
|
+
return {
|
|
102
|
+
kind: 'document',
|
|
103
|
+
text: resolved.documentMessage.caption || '[Document]',
|
|
104
|
+
documentMessage: resolved.documentMessage
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (resolved?.contactMessage || resolved?.contactsArrayMessage) {
|
|
109
|
+
return { kind: 'contact', text: '[Contact]' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (resolved?.locationMessage) {
|
|
113
|
+
return { kind: 'location', text: '[Location]' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (resolved?.buttonsResponseMessage?.selectedDisplayText) {
|
|
117
|
+
return { kind: 'text', text: resolved.buttonsResponseMessage.selectedDisplayText };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (resolved?.listResponseMessage?.title) {
|
|
121
|
+
return { kind: 'text', text: resolved.listResponseMessage.title };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (resolved?.templateButtonReplyMessage?.selectedDisplayText) {
|
|
125
|
+
return { kind: 'text', text: resolved.templateButtonReplyMessage.selectedDisplayText };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { kind: 'unsupported', text: `[Unsupported Message Type: ${typeName}]` };
|
|
129
|
+
};
|
|
@@ -191,7 +191,6 @@ export class RecentsService {
|
|
|
191
191
|
const normalizedNumber = this.normalizeNumber(senderNumber);
|
|
192
192
|
const messages = this.store.messagesBySender[normalizedNumber] ?? [];
|
|
193
193
|
return [...messages]
|
|
194
|
-
.map(message => ({ ...message, timestamp: this.normalizeTimestamp(message.timestamp) }))
|
|
195
194
|
.sort((left, right) => left.timestamp - right.timestamp)
|
|
196
195
|
.slice(-20);
|
|
197
196
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class WhatsAppPiLogger {
|
|
2
|
+
constructor(private verbose = false) {}
|
|
3
|
+
|
|
4
|
+
setVerbose(verbose: boolean) {
|
|
5
|
+
this.verbose = verbose;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
log(message: string, ...args: unknown[]) {
|
|
9
|
+
if (this.verbose) {
|
|
10
|
+
console.log(message, ...args);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
warn(message: string, ...args: unknown[]) {
|
|
15
|
+
if (this.verbose) {
|
|
16
|
+
console.warn(message, ...args);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
error(message: string, ...args: unknown[]) {
|
|
21
|
+
if (this.verbose) {
|
|
22
|
+
console.error(message, ...args);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -10,6 +10,11 @@ import { Boom } from '@hapi/boom';
|
|
|
10
10
|
import { SessionManager } from './session.manager.js';
|
|
11
11
|
import { IncomingMessage, WhatsAppSession, SessionStatus } from '../models/whatsapp.types.js';
|
|
12
12
|
import { MessageSender } from './message.sender.js';
|
|
13
|
+
import { installBaileysConsoleFilter } from './baileys-console-filter.js';
|
|
14
|
+
|
|
15
|
+
export interface WhatsAppStartOptions {
|
|
16
|
+
allowPairingOnAuthFailure?: boolean;
|
|
17
|
+
}
|
|
13
18
|
|
|
14
19
|
export class WhatsAppService {
|
|
15
20
|
private socket: any;
|
|
@@ -19,6 +24,7 @@ export class WhatsAppService {
|
|
|
19
24
|
private verboseMode = false;
|
|
20
25
|
private onIncomingMessageRecorded?: (message: IncomingMessage) => void | Promise<void>;
|
|
21
26
|
private saveCreds?: () => Promise<void>;
|
|
27
|
+
private restoreBaileysConsoleFilter?: () => void;
|
|
22
28
|
|
|
23
29
|
constructor(sessionManager: SessionManager) {
|
|
24
30
|
this.sessionManager = sessionManager;
|
|
@@ -29,6 +35,15 @@ export class WhatsAppService {
|
|
|
29
35
|
return this.sessionManager.getStatus();
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
public getEffectiveStatus(): SessionStatus {
|
|
39
|
+
const status = this.sessionManager.getStatus();
|
|
40
|
+
if (status === 'connected' && !this.socket) {
|
|
41
|
+
return 'disconnected';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return status;
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
public setIncomingMessageRecorder(callback: (message: IncomingMessage) => void | Promise<void>) {
|
|
33
48
|
this.onIncomingMessageRecorded = callback;
|
|
34
49
|
}
|
|
@@ -43,6 +58,10 @@ export class WhatsAppService {
|
|
|
43
58
|
|
|
44
59
|
public setVerboseMode(verbose: boolean) {
|
|
45
60
|
this.verboseMode = verbose;
|
|
61
|
+
if (verbose) {
|
|
62
|
+
this.restoreBaileysConsoleFilter?.();
|
|
63
|
+
this.restoreBaileysConsoleFilter = undefined;
|
|
64
|
+
}
|
|
46
65
|
}
|
|
47
66
|
|
|
48
67
|
private normalizeContactNumber(value: string): string {
|
|
@@ -57,7 +76,8 @@ export class WhatsAppService {
|
|
|
57
76
|
return value;
|
|
58
77
|
}
|
|
59
78
|
|
|
60
|
-
async start() {
|
|
79
|
+
async start(options: WhatsAppStartOptions = {}) {
|
|
80
|
+
const allowPairingOnAuthFailure = options.allowPairingOnAuthFailure ?? true;
|
|
61
81
|
if (this.isReconnecting) return;
|
|
62
82
|
this.onStatusUpdate?.('| WhatsApp: Connecting...');
|
|
63
83
|
|
|
@@ -67,6 +87,8 @@ export class WhatsAppService {
|
|
|
67
87
|
|
|
68
88
|
// Cleanup existing socket if any
|
|
69
89
|
if (this.socket) {
|
|
90
|
+
this.restoreBaileysConsoleFilter?.();
|
|
91
|
+
this.restoreBaileysConsoleFilter = undefined;
|
|
70
92
|
this.socket.ev.removeAllListeners('connection.update');
|
|
71
93
|
this.socket.ev.removeAllListeners('creds.update');
|
|
72
94
|
this.socket.ev.removeAllListeners('messages.upsert');
|
|
@@ -127,32 +149,57 @@ export class WhatsAppService {
|
|
|
127
149
|
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
|
128
150
|
const errorMessage = lastDisconnect?.error?.message || '';
|
|
129
151
|
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
|
130
|
-
const
|
|
152
|
+
const isBadMac = errorMessage.includes('Bad MAC');
|
|
153
|
+
const isAuthRejected =
|
|
131
154
|
errorMessage.includes('bad-request') ||
|
|
132
|
-
errorMessage.includes('Bad MAC') ||
|
|
133
155
|
statusCode === 400 ||
|
|
134
156
|
statusCode === 401 ||
|
|
135
157
|
statusCode === DisconnectReason.loggedOut ||
|
|
136
158
|
statusCode === DisconnectReason.badSession;
|
|
159
|
+
const shouldTreatAsLoggedOut = isBadMac || isAuthRejected;
|
|
137
160
|
|
|
138
|
-
|
|
161
|
+
if (this.verboseMode) {
|
|
162
|
+
console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
|
|
163
|
+
}
|
|
139
164
|
|
|
140
165
|
if (shouldTreatAsLoggedOut) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
if (isAuthRejected && !isBadMac && allowPairingOnAuthFailure) {
|
|
167
|
+
if (this.verboseMode) {
|
|
168
|
+
console.error(`Session rejected [${statusCode}] - clearing auth state and starting pairing`);
|
|
169
|
+
}
|
|
170
|
+
await this.sessionManager.deleteAuthState();
|
|
171
|
+
this.socket.ev.removeAllListeners('connection.update');
|
|
172
|
+
this.socket.ev.removeAllListeners('creds.update');
|
|
173
|
+
this.socket.ev.removeAllListeners('messages.upsert');
|
|
174
|
+
try {
|
|
175
|
+
this.socket.end(undefined);
|
|
176
|
+
} catch (e) {}
|
|
177
|
+
this.socket = undefined;
|
|
178
|
+
await this.start({ allowPairingOnAuthFailure: false });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (this.verboseMode) {
|
|
183
|
+
console.error(`Session invalid or logged out [${statusCode}] - preserving auth state and requiring re-auth`);
|
|
184
|
+
}
|
|
185
|
+
if (isBadMac) {
|
|
186
|
+
if (this.verboseMode) {
|
|
187
|
+
console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
|
|
188
|
+
console.error('[WhatsApp-Pi] Run /whatsapp-logout to clear auth state, then reconnect with /whatsapp-connect');
|
|
189
|
+
}
|
|
145
190
|
this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
|
|
146
191
|
}
|
|
147
192
|
this.sessionManager.setStatus('logged-out');
|
|
148
|
-
if (!
|
|
193
|
+
if (!isBadMac) {
|
|
149
194
|
this.onStatusUpdate?.('| WhatsApp: Logged out');
|
|
150
195
|
}
|
|
151
196
|
return;
|
|
152
197
|
}
|
|
153
198
|
|
|
154
199
|
if (statusCode === DisconnectReason.connectionReplaced) {
|
|
155
|
-
|
|
200
|
+
if (this.verboseMode) {
|
|
201
|
+
console.error('Connection replaced - another instance connected');
|
|
202
|
+
}
|
|
156
203
|
this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
|
|
157
204
|
return;
|
|
158
205
|
}
|
|
@@ -162,7 +209,7 @@ export class WhatsAppService {
|
|
|
162
209
|
this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
|
|
163
210
|
setTimeout(() => {
|
|
164
211
|
this.isReconnecting = false;
|
|
165
|
-
this.start();
|
|
212
|
+
this.start(options);
|
|
166
213
|
}, 3000);
|
|
167
214
|
} else if (!shouldReconnect) {
|
|
168
215
|
this.sessionManager.setStatus('logged-out');
|
|
@@ -187,6 +234,7 @@ export class WhatsAppService {
|
|
|
187
234
|
console.log = originalConsoleLog;
|
|
188
235
|
console.warn = originalConsoleWarn;
|
|
189
236
|
console.error = originalConsoleError;
|
|
237
|
+
this.restoreBaileysConsoleFilter = installBaileysConsoleFilter(this.verboseMode);
|
|
190
238
|
}
|
|
191
239
|
}
|
|
192
240
|
|
|
@@ -354,6 +402,8 @@ export class WhatsAppService {
|
|
|
354
402
|
}
|
|
355
403
|
|
|
356
404
|
if (this.socket) {
|
|
405
|
+
this.restoreBaileysConsoleFilter?.();
|
|
406
|
+
this.restoreBaileysConsoleFilter = undefined;
|
|
357
407
|
this.socket.ev.removeAllListeners('connection.update');
|
|
358
408
|
this.socket.ev.removeAllListeners('creds.update');
|
|
359
409
|
this.socket.ev.removeAllListeners('messages.upsert');
|
package/src/ui/menu.handler.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { SessionManager, type Contact } from '../services/session.manager.js';
|
|
|
3
3
|
import { validatePhoneNumber, type RecentConversationSummary } from '../models/whatsapp.types.js';
|
|
4
4
|
import { RecentsService } from '../services/recents.service.js';
|
|
5
5
|
import * as qrcode from 'qrcode-terminal';
|
|
6
|
-
import type
|
|
6
|
+
import { copyToClipboard, type ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
|
|
7
7
|
|
|
8
8
|
export class MenuHandler {
|
|
9
9
|
constructor(
|
|
@@ -13,7 +13,7 @@ export class MenuHandler {
|
|
|
13
13
|
) {}
|
|
14
14
|
|
|
15
15
|
async handleCommand(ctx: ExtensionCommandContext) {
|
|
16
|
-
const status = this.
|
|
16
|
+
const status = this.whatsappService.getEffectiveStatus();
|
|
17
17
|
const registered = await this.sessionManager.isRegistered();
|
|
18
18
|
const options: string[] = [];
|
|
19
19
|
|
|
@@ -107,7 +107,7 @@ export class MenuHandler {
|
|
|
107
107
|
|
|
108
108
|
private async manageAllowedContact(ctx: ExtensionCommandContext, contact: Contact) {
|
|
109
109
|
const displayName = this.formatAllowedContactOption(contact);
|
|
110
|
-
const options = ['Send Message', 'History'];
|
|
110
|
+
const options = ['Send Message', 'History', 'Copy Number'];
|
|
111
111
|
if (contact.name) {
|
|
112
112
|
options.push('Remove Alias');
|
|
113
113
|
} else {
|
|
@@ -129,6 +129,12 @@ export class MenuHandler {
|
|
|
129
129
|
return;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
if (choice === 'Copy Number') {
|
|
133
|
+
await this.copyAllowedNumber(ctx, contact);
|
|
134
|
+
await this.manageAllowedContact(ctx, contact);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
132
138
|
if (choice === 'Add Alias') {
|
|
133
139
|
const alias = await ctx.ui.input(`Enter alias for ${contact.number}:`);
|
|
134
140
|
const trimmedAlias = alias?.trim() || '';
|
|
@@ -165,6 +171,15 @@ export class MenuHandler {
|
|
|
165
171
|
await this.manageAllowList(ctx);
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
private async copyAllowedNumber(ctx: ExtensionCommandContext, contact: Contact) {
|
|
175
|
+
try {
|
|
176
|
+
await copyToClipboard(contact.number);
|
|
177
|
+
ctx.ui.notify(`Copied ${contact.number} to clipboard`, 'info');
|
|
178
|
+
} catch {
|
|
179
|
+
ctx.ui.notify(`Failed to copy ${contact.number} to clipboard`, 'error');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
168
183
|
private async manageBlockList(ctx: ExtensionCommandContext) {
|
|
169
184
|
const list = [...this.sessionManager.getIgnoredNumbers()].reverse();
|
|
170
185
|
|
|
@@ -178,12 +193,7 @@ export class MenuHandler {
|
|
|
178
193
|
const choice = await ctx.ui.select('Blocked Numbers (Select to Manage)', options);
|
|
179
194
|
|
|
180
195
|
if (choice && choice !== 'Back') {
|
|
181
|
-
|
|
182
|
-
if (num.includes('(')) {
|
|
183
|
-
const match = num.match(/\((.*?)\)/);
|
|
184
|
-
if (match) num = match[1];
|
|
185
|
-
}
|
|
186
|
-
await this.manageBlockedNumber(ctx, num);
|
|
196
|
+
await this.manageBlockedNumber(ctx, this.parseContactNumberOption(choice));
|
|
187
197
|
} else {
|
|
188
198
|
await this.handleCommand(ctx);
|
|
189
199
|
}
|
|
@@ -301,36 +311,33 @@ export class MenuHandler {
|
|
|
301
311
|
}
|
|
302
312
|
|
|
303
313
|
private async sendMessageFromRecents(ctx: ExtensionCommandContext, conversation: RecentConversationSummary) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
ctx.ui.notify('Please enter a message before sending.', 'error');
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const result = await this.whatsappService.sendMenuMessage(this.toJid(conversation.senderNumber), trimmed);
|
|
315
|
-
if (result.success) {
|
|
316
|
-
await this.recentsService.recordMessage({
|
|
317
|
-
messageId: result.messageId ?? `${Date.now()}`,
|
|
318
|
-
senderNumber: conversation.senderNumber,
|
|
319
|
-
senderName: conversation.senderName,
|
|
320
|
-
text: trimmed,
|
|
321
|
-
direction: 'outgoing',
|
|
322
|
-
timestamp: Date.now()
|
|
323
|
-
});
|
|
324
|
-
ctx.ui.notify(`Sent message to ${displayName}`, 'info');
|
|
325
|
-
} else {
|
|
326
|
-
ctx.ui.notify(`Failed to send message to ${displayName}: ${result.error ?? 'Unknown error'}`, 'error');
|
|
327
|
-
}
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
314
|
+
await this.sendPromptedMenuMessage(ctx, {
|
|
315
|
+
displayName: this.getConversationDisplayName(conversation),
|
|
316
|
+
senderNumber: conversation.senderNumber,
|
|
317
|
+
senderName: conversation.senderName,
|
|
318
|
+
appendPiSuffix: false
|
|
319
|
+
});
|
|
330
320
|
}
|
|
331
321
|
|
|
332
322
|
private async sendMessageToAllowedNumber(ctx: ExtensionCommandContext, contact: Contact) {
|
|
333
|
-
|
|
323
|
+
await this.sendPromptedMenuMessage(ctx, {
|
|
324
|
+
displayName: this.formatAllowedContactOption(contact),
|
|
325
|
+
senderNumber: contact.number,
|
|
326
|
+
senderName: contact.name,
|
|
327
|
+
appendPiSuffix: true
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async sendPromptedMenuMessage(
|
|
332
|
+
ctx: ExtensionCommandContext,
|
|
333
|
+
options: {
|
|
334
|
+
displayName: string;
|
|
335
|
+
senderNumber: string;
|
|
336
|
+
senderName?: string;
|
|
337
|
+
appendPiSuffix: boolean;
|
|
338
|
+
}
|
|
339
|
+
) {
|
|
340
|
+
const { displayName, senderNumber, senderName, appendPiSuffix } = options;
|
|
334
341
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
335
342
|
const inputText = (await ctx.ui.input(`Send a message to ${displayName}:`))?.trim() || '';
|
|
336
343
|
|
|
@@ -339,15 +346,14 @@ export class MenuHandler {
|
|
|
339
346
|
continue;
|
|
340
347
|
}
|
|
341
348
|
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
const result = await this.whatsappService.sendMenuMessage(this.toJid(contact.number), inputTextWithPiSuffix);
|
|
349
|
+
const messageText = appendPiSuffix ? `${inputText} π` : inputText;
|
|
350
|
+
const result = await this.whatsappService.sendMenuMessage(this.toJid(senderNumber), messageText);
|
|
345
351
|
if (result.success) {
|
|
346
352
|
await this.recentsService.recordMessage({
|
|
347
353
|
messageId: result.messageId ?? `${Date.now()}`,
|
|
348
|
-
senderNumber
|
|
349
|
-
senderName
|
|
350
|
-
text:
|
|
354
|
+
senderNumber,
|
|
355
|
+
senderName,
|
|
356
|
+
text: messageText,
|
|
351
357
|
direction: 'outgoing',
|
|
352
358
|
timestamp: Date.now()
|
|
353
359
|
});
|
|
@@ -372,7 +378,8 @@ export class MenuHandler {
|
|
|
372
378
|
}
|
|
373
379
|
|
|
374
380
|
const options = [
|
|
375
|
-
...
|
|
381
|
+
...this.sortHistoryByMostRecent(history)
|
|
382
|
+
.map(message => this.formatHistoryOption(message.timestamp, message.direction, message.text)),
|
|
376
383
|
'Back'
|
|
377
384
|
];
|
|
378
385
|
|
|
@@ -404,6 +411,39 @@ export class MenuHandler {
|
|
|
404
411
|
return contact.name ? `${contact.name} ${contact.number}` : contact.number;
|
|
405
412
|
}
|
|
406
413
|
|
|
414
|
+
private parseContactNumberOption(choice: string): string {
|
|
415
|
+
if (!choice.includes('(')) {
|
|
416
|
+
return choice;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const match = choice.match(/\((.*?)\)/);
|
|
420
|
+
return match?.[1] ?? choice;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private sortHistoryByMostRecent<T extends { timestamp: number }>(history: T[]): T[] {
|
|
424
|
+
return [...history].sort((left, right) => {
|
|
425
|
+
const dayComparison = this.getDayStart(right.timestamp) - this.getDayStart(left.timestamp);
|
|
426
|
+
if (dayComparison !== 0) {
|
|
427
|
+
return dayComparison;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return this.getTimeOfDay(right.timestamp) - this.getTimeOfDay(left.timestamp);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private getTimeOfDay(timestamp: number): number {
|
|
435
|
+
const date = new Date(timestamp);
|
|
436
|
+
return date.getHours() * 60 * 60 * 1000
|
|
437
|
+
+ date.getMinutes() * 60 * 1000
|
|
438
|
+
+ date.getSeconds() * 1000
|
|
439
|
+
+ date.getMilliseconds();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private getDayStart(timestamp: number): number {
|
|
443
|
+
const date = new Date(timestamp);
|
|
444
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
445
|
+
}
|
|
446
|
+
|
|
407
447
|
private formatHistoryOption(timestamp: number, direction: string, text: string): string {
|
|
408
448
|
const marker = direction === 'outgoing' ? 'Sent' : 'Received';
|
|
409
449
|
const displayText = this.truncate(text, 60) || '[No text]';
|
package/whatsapp-pi.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import type { ExtensionAPI,
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
|
-
import { extractMessageContent } from '@whiskeysockets/baileys';
|
|
4
3
|
import { SessionManager } from './src/services/session.manager.js';
|
|
5
4
|
import { WhatsAppService } from './src/services/whatsapp.service.js';
|
|
6
5
|
import { MenuHandler } from './src/ui/menu.handler.js';
|
|
7
6
|
import { RecentsService } from './src/services/recents.service.js';
|
|
8
7
|
import { AudioService } from './src/services/audio.service.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
console.log("[WhatsApp-Pi] Extension file loaded by Pi...");
|
|
8
|
+
import { extractIncomingText } from './src/services/incoming-message.resolver.js';
|
|
9
|
+
import { IncomingMediaService } from './src/services/incoming-media.service.js';
|
|
10
|
+
import { WhatsAppPiLogger } from './src/services/whatsapp-pi.logger.js';
|
|
13
11
|
|
|
14
12
|
const shutdownState = globalThis as typeof globalThis & {
|
|
15
13
|
__whatsappPiShutdown?: {
|
|
@@ -36,6 +34,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
36
34
|
const whatsappService = new WhatsAppService(sessionManager);
|
|
37
35
|
const recentsService = new RecentsService(sessionManager);
|
|
38
36
|
const audioService = new AudioService();
|
|
37
|
+
const logger = new WhatsAppPiLogger(false);
|
|
38
|
+
const incomingMediaService = new IncomingMediaService(audioService, logger);
|
|
39
39
|
const menuHandler = new MenuHandler(whatsappService, sessionManager, recentsService);
|
|
40
40
|
let _ctx: ExtensionContext | undefined;
|
|
41
41
|
|
|
@@ -46,11 +46,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
shutdownState.__whatsappPiShutdown.installed = true;
|
|
49
|
+
|
|
49
50
|
const shutdown = async (reason: string) => {
|
|
50
51
|
try {
|
|
51
52
|
await shutdownState.__whatsappPiShutdown?.stop?.();
|
|
52
53
|
} catch (error) {
|
|
53
|
-
|
|
54
|
+
logger.error(`[WhatsApp-Pi] Graceful shutdown failed during ${reason}:`, error);
|
|
54
55
|
}
|
|
55
56
|
};
|
|
56
57
|
|
|
@@ -67,9 +68,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
67
68
|
const isVerbose = isVerboseFlagSet;
|
|
68
69
|
|
|
69
70
|
whatsappService.setVerboseMode(isVerbose);
|
|
71
|
+
logger.setVerbose(isVerbose);
|
|
70
72
|
|
|
71
73
|
if (isVerbose) {
|
|
72
|
-
|
|
74
|
+
logger.log('[WhatsApp-Pi] Verbose mode enabled - Baileys trace logs will be shown');
|
|
73
75
|
}
|
|
74
76
|
ctx.ui.setStatus('whatsapp', '| WhatsApp: Disconnected');
|
|
75
77
|
whatsappService.setStatusCallback((status) => {
|
|
@@ -98,10 +100,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
98
100
|
const savedStateEntry = [...ctx.sessionManager.getEntries()]
|
|
99
101
|
.reverse()
|
|
100
102
|
.find(entry => entry.type === "custom" && entry.customType === "whatsapp-state");
|
|
103
|
+
const isWhatsappPiOn = event.reason === "startup" && pi.getFlag("whatsapp-pi-online") === true;
|
|
101
104
|
|
|
102
105
|
if (savedStateEntry) {
|
|
103
106
|
const data = (savedStateEntry as { data?: any }).data;
|
|
104
|
-
if (data.status)
|
|
107
|
+
if (data.status) {
|
|
108
|
+
const restoredStatus = data.status === 'connected' && !isWhatsappPiOn
|
|
109
|
+
? 'disconnected'
|
|
110
|
+
: data.status;
|
|
111
|
+
await sessionManager.setStatus(restoredStatus);
|
|
112
|
+
}
|
|
105
113
|
if (Array.isArray(data.allowList)) {
|
|
106
114
|
for (const n of data.allowList) {
|
|
107
115
|
const num = typeof n === "string" ? n : n.number;
|
|
@@ -112,7 +120,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
// Check whatsapp flag — only auto-connect on initial startup, not reloads/new sessions
|
|
115
|
-
const isWhatsappPiOn = event.reason === "startup" && pi.getFlag("whatsapp-pi-online") === true;
|
|
116
123
|
const registered = await sessionManager.isRegistered();
|
|
117
124
|
|
|
118
125
|
if (isWhatsappPiOn && registered) {
|
|
@@ -125,7 +132,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
125
132
|
const tryConnect = async () => {
|
|
126
133
|
attempts++;
|
|
127
134
|
try {
|
|
128
|
-
await whatsappService.start();
|
|
135
|
+
await whatsappService.start({ allowPairingOnAuthFailure: false });
|
|
129
136
|
} catch (error) {
|
|
130
137
|
if (attempts < maxAttempts) {
|
|
131
138
|
ctx.ui.notify(`WhatsApp: Connection attempt ${attempts} failed. Retrying...`, 'warning');
|
|
@@ -152,141 +159,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
152
159
|
}
|
|
153
160
|
} catch (e) {
|
|
154
161
|
ctx.ui.notify('WhatsApp: pdftotext not found. PDF document support will be limited to storage only.', 'warning');
|
|
155
|
-
|
|
162
|
+
logger.warn('[WhatsApp-Pi] Warning: pdftotext not found in system PATH.');
|
|
156
163
|
}
|
|
157
164
|
});
|
|
158
165
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
type IncomingResolution =
|
|
162
|
-
| { kind: 'text'; text: string }
|
|
163
|
-
| { kind: 'audio'; text: string; audioMessage: any }
|
|
164
|
-
| { kind: 'image'; text: string; imageMessage: any }
|
|
165
|
-
| { kind: 'document'; text: string; documentMessage: any }
|
|
166
|
-
| { kind: 'contact'; text: string }
|
|
167
|
-
| { kind: 'location'; text: string }
|
|
168
|
-
| { kind: 'system'; text: string }
|
|
169
|
-
| { kind: 'unsupported'; text: string };
|
|
170
|
-
|
|
171
|
-
const extractIncomingText = (message: any, pushName: string): IncomingResolution => {
|
|
172
|
-
const unwrap = (content: any): any => extractMessageContent(content) ?? content;
|
|
173
|
-
const content = unwrap(message);
|
|
174
|
-
|
|
175
|
-
const getTypeName = (payload: any): string => {
|
|
176
|
-
if (!payload || typeof payload !== 'object') return 'unknown';
|
|
177
|
-
const keys = Object.keys(payload);
|
|
178
|
-
return keys[0] || 'unknown';
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
const formatProtocolMessage = (protocolMessage: any): string => {
|
|
182
|
-
const protocolTypes: Record<number, string> = {
|
|
183
|
-
0: 'Message Deleted',
|
|
184
|
-
3: 'Disappearing Messages Updated',
|
|
185
|
-
4: 'Disappearing Message Sync Response',
|
|
186
|
-
5: 'History Sync Notification',
|
|
187
|
-
6: 'App State Sync Key Share',
|
|
188
|
-
7: 'App State Sync Key Request',
|
|
189
|
-
8: 'Message Backfill Request',
|
|
190
|
-
9: 'Security Notification Sync',
|
|
191
|
-
10: 'Fatal App State Sync Notification',
|
|
192
|
-
11: 'Phone Number Shared',
|
|
193
|
-
14: 'Message Edited',
|
|
194
|
-
16: 'Peer Data Request',
|
|
195
|
-
17: 'Peer Data Response',
|
|
196
|
-
18: 'Welcome Message Request',
|
|
197
|
-
19: 'Bot Feedback',
|
|
198
|
-
20: 'Media Notification'
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const typeLabel = protocolTypes[Number(protocolMessage?.type)] || 'System Update';
|
|
202
|
-
if (protocolMessage?.editedMessage?.conversation || protocolMessage?.editedMessage?.extendedTextMessage?.text) {
|
|
203
|
-
const editedText = protocolMessage.editedMessage.conversation
|
|
204
|
-
|| protocolMessage.editedMessage.extendedTextMessage?.text
|
|
205
|
-
|| '[Edited message]';
|
|
206
|
-
return `[${typeLabel}: ${editedText}]`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return `[${typeLabel}]`;
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const inner = content?.ephemeralMessage?.message
|
|
213
|
-
|| content?.viewOnceMessage?.message
|
|
214
|
-
|| content?.viewOnceMessageV2?.message
|
|
215
|
-
|| content?.viewOnceMessageV2Extension?.message
|
|
216
|
-
|| content?.message;
|
|
217
|
-
|
|
218
|
-
const resolved = inner ? unwrap(inner) : content;
|
|
219
|
-
const typeName = getTypeName(resolved);
|
|
220
|
-
const protocolMessage = resolved?.protocolMessage
|
|
221
|
-
|| (typeName === 'protocolMessage' ? resolved : undefined)
|
|
222
|
-
|| content?.protocolMessage;
|
|
223
|
-
|
|
224
|
-
if (protocolMessage) {
|
|
225
|
-
return { kind: 'system', text: formatProtocolMessage(protocolMessage) };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (resolved?.conversation) {
|
|
229
|
-
return { kind: 'text', text: resolved.conversation };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (resolved?.extendedTextMessage?.text) {
|
|
233
|
-
return { kind: 'text', text: resolved.extendedTextMessage.text };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (resolved?.imageMessage) {
|
|
237
|
-
return {
|
|
238
|
-
kind: 'image',
|
|
239
|
-
text: resolved.imageMessage.caption || '[Image]',
|
|
240
|
-
imageMessage: resolved.imageMessage
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (resolved?.videoMessage) {
|
|
245
|
-
return {
|
|
246
|
-
kind: 'text',
|
|
247
|
-
text: resolved.videoMessage.caption || '[Video]'
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (resolved?.audioMessage) {
|
|
252
|
-
return {
|
|
253
|
-
kind: 'audio',
|
|
254
|
-
text: '[Audio Message]',
|
|
255
|
-
audioMessage: resolved.audioMessage
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (resolved?.documentMessage) {
|
|
260
|
-
return {
|
|
261
|
-
kind: 'document',
|
|
262
|
-
text: resolved.documentMessage.caption || '[Document]',
|
|
263
|
-
documentMessage: resolved.documentMessage
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (resolved?.contactMessage || resolved?.contactsArrayMessage) {
|
|
268
|
-
return { kind: 'contact', text: '[Contact]' };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (resolved?.locationMessage) {
|
|
272
|
-
return { kind: 'location', text: '[Location]' };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (resolved?.buttonsResponseMessage?.selectedDisplayText) {
|
|
276
|
-
return { kind: 'text', text: resolved.buttonsResponseMessage.selectedDisplayText };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (resolved?.listResponseMessage?.title) {
|
|
280
|
-
return { kind: 'text', text: resolved.listResponseMessage.title };
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (resolved?.templateButtonReplyMessage?.selectedDisplayText) {
|
|
284
|
-
return { kind: 'text', text: resolved.templateButtonReplyMessage.selectedDisplayText };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return { kind: 'unsupported', text: `[Unsupported Message Type: ${typeName}]` };
|
|
288
|
-
};
|
|
289
|
-
|
|
290
166
|
// Handle incoming messages by injecting them as user prompts
|
|
291
167
|
whatsappService.setMessageCallback(async (m) => {
|
|
292
168
|
const msg = m.messages[0];
|
|
@@ -302,99 +178,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
178
|
whatsappService.sendPresence(remoteJid, 'composing');
|
|
303
179
|
}
|
|
304
180
|
|
|
305
|
-
const resolved = extractIncomingText(msg.message
|
|
181
|
+
const resolved = extractIncomingText(msg.message);
|
|
306
182
|
if (resolved.kind === 'system') {
|
|
307
|
-
|
|
183
|
+
logger.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${resolved.text}`);
|
|
308
184
|
return;
|
|
309
185
|
}
|
|
310
186
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// Handle media types
|
|
314
|
-
let imageBuffer: Buffer | undefined;
|
|
315
|
-
let imageMimeType: string | undefined;
|
|
316
|
-
|
|
317
|
-
if (resolved.kind === 'audio') {
|
|
318
|
-
console.log(`[WhatsApp-Pi] Transcribing audio from ${pushName}...`);
|
|
319
|
-
const transcription = await audioService.transcribe(resolved.audioMessage);
|
|
320
|
-
text = `[Transcribed Audio]: ${transcription}`;
|
|
321
|
-
} else if (resolved.kind === 'image') {
|
|
322
|
-
console.log(`[WhatsApp-Pi] Downloading image from ${pushName}...`);
|
|
323
|
-
try {
|
|
324
|
-
const { downloadContentFromMessage } = await import('@whiskeysockets/baileys');
|
|
325
|
-
const stream = await downloadContentFromMessage(resolved.imageMessage, 'image');
|
|
326
|
-
let buffer = Buffer.from([]);
|
|
327
|
-
for await (const chunk of stream) {
|
|
328
|
-
buffer = Buffer.concat([buffer, chunk]);
|
|
329
|
-
}
|
|
330
|
-
imageBuffer = buffer;
|
|
331
|
-
|
|
332
|
-
// Normalize mime type for Cloud Code Assist / Gemini
|
|
333
|
-
let rawMime = resolved.imageMessage.mimetype || 'image/jpeg';
|
|
334
|
-
imageMimeType = rawMime.toLowerCase().split(';')[0].trim();
|
|
335
|
-
if (imageMimeType === 'image/jpg') imageMimeType = 'image/jpeg';
|
|
336
|
-
|
|
337
|
-
console.log(`[WhatsApp-Pi] Image downloaded. MIME: ${imageMimeType} (original: ${rawMime}), Size: ${imageBuffer.length} bytes`);
|
|
338
|
-
|
|
339
|
-
text = resolved.text || "[Image]";
|
|
340
|
-
} catch (e) {
|
|
341
|
-
console.error(`[WhatsApp-Pi] Failed to download image:`, e);
|
|
342
|
-
text = "[Image (download failed)]";
|
|
343
|
-
}
|
|
344
|
-
} else if (resolved.kind === 'document') {
|
|
345
|
-
const doc = resolved.documentMessage;
|
|
346
|
-
const fileName = doc.fileName || 'unnamed_document';
|
|
347
|
-
const mimeType = doc.mimetype || 'application/octet-stream';
|
|
348
|
-
const fileSize = doc.fileLength ? Number(doc.fileLength) : 0;
|
|
349
|
-
|
|
350
|
-
console.log(`[WhatsApp-Pi] Downloading document from ${pushName}: ${fileName}...`);
|
|
351
|
-
|
|
352
|
-
try {
|
|
353
|
-
const { downloadContentFromMessage } = await import('@whiskeysockets/baileys');
|
|
354
|
-
const stream = await downloadContentFromMessage(doc, 'document');
|
|
355
|
-
let buffer = Buffer.from([]);
|
|
356
|
-
for await (const chunk of stream) {
|
|
357
|
-
buffer = Buffer.concat([buffer, chunk]);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Sanitize filename
|
|
361
|
-
const sanitized = fileName.replace(/[^a-z0-9\._-]/gi, '_');
|
|
362
|
-
const savedFileName = `${Date.now()}_${sanitized}`;
|
|
363
|
-
const relativePath = `./.pi-data/whatsapp/documents/${savedFileName}`;
|
|
364
|
-
const absolutePath = join(process.cwd(), '.pi-data', 'whatsapp', 'documents', savedFileName);
|
|
365
|
-
|
|
366
|
-
// Ensure directory exists (T001 handles it at startup, but let's be safe)
|
|
367
|
-
await mkdir(join(process.cwd(), '.pi-data', 'whatsapp', 'documents'), { recursive: true });
|
|
368
|
-
await writeFile(absolutePath, buffer);
|
|
369
|
-
|
|
370
|
-
console.log(`[WhatsApp-Pi] Document saved to ${relativePath} (${buffer.length} bytes)`);
|
|
371
|
-
|
|
372
|
-
const sizeFormatted = fileSize > 1024 * 1024
|
|
373
|
-
? `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
|
|
374
|
-
: `${(fileSize / 1024).toFixed(1)} KB`;
|
|
375
|
-
|
|
376
|
-
text = `[Document Received: ${fileName}]\n` +
|
|
377
|
-
`MIME Type: ${mimeType}\n` +
|
|
378
|
-
`Size: ${sizeFormatted}\n` +
|
|
379
|
-
`Location: ${relativePath}`;
|
|
380
|
-
|
|
381
|
-
if (doc.caption) {
|
|
382
|
-
text += `\n\nDescription: ${doc.caption}`;
|
|
383
|
-
}
|
|
384
|
-
} catch (e) {
|
|
385
|
-
console.error(`[WhatsApp-Pi] Failed to download document:`, e);
|
|
386
|
-
text = `[Document: ${fileName} (download failed)]`;
|
|
387
|
-
}
|
|
388
|
-
} else if (resolved.kind === 'contact') {
|
|
389
|
-
text = resolved.text;
|
|
390
|
-
} else if (resolved.kind === 'location') {
|
|
391
|
-
text = resolved.text;
|
|
392
|
-
} else if (resolved.kind === 'unsupported') {
|
|
393
|
-
text = resolved.text;
|
|
394
|
-
}
|
|
187
|
+
const { text, imageBuffer, imageMimeType } = await incomingMediaService.process(resolved, pushName);
|
|
395
188
|
|
|
396
|
-
|
|
397
|
-
console.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${text}`);
|
|
189
|
+
logger.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${text}`);
|
|
398
190
|
|
|
399
191
|
// Use a standard delivery for ALL messages to ensure TUI consistency
|
|
400
192
|
if (imageBuffer && imageMimeType) {
|
|
@@ -408,7 +200,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
408
200
|
|
|
409
201
|
// Handle commands
|
|
410
202
|
if (text.trim().toLowerCase().startsWith('/compact')) {
|
|
411
|
-
|
|
203
|
+
logger.log(`[WhatsApp-Pi] Session compact requested by ${pushName}.`);
|
|
412
204
|
|
|
413
205
|
if (_ctx) {
|
|
414
206
|
_ctx.compact();
|
|
@@ -418,7 +210,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
418
210
|
}
|
|
419
211
|
|
|
420
212
|
if (text.trim().toLowerCase().startsWith('/abort')) {
|
|
421
|
-
|
|
213
|
+
logger.log(`[WhatsApp-Pi] Abort requested by ${pushName}.`);
|
|
422
214
|
if (_ctx) {
|
|
423
215
|
_ctx.abort();
|
|
424
216
|
await whatsappService.sendMessage(remoteJid!, "Aborted! ✅");
|
|
@@ -524,7 +316,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
524
316
|
});
|
|
525
317
|
|
|
526
318
|
pi.on("session_shutdown", async () => {
|
|
527
|
-
|
|
319
|
+
logger.log("[WhatsApp-Pi] Session shutdown detected. Stopping WhatsApp service...");
|
|
528
320
|
await whatsappService.stop();
|
|
529
321
|
});
|
|
530
322
|
}
|