whatsapp-pi 1.0.59 → 1.0.61
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 +37 -4
- package/package.json +1 -1
- package/src/i18n.ts +2 -38
- package/src/services/recents.service.ts +6 -6
- package/src/services/session.manager.ts +67 -36
- package/src/services/whatsapp.service.ts +48 -30
- package/src/ui/message-reply.view.ts +32 -21
- package/whatsapp-pi.ts +62 -31
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# WhatsApp-Pi
|
|
6
6
|
[](https://github.com/RaphaCastelloes/whatsapp-pi)
|
|
7
7
|
|
|
8
|
-
A WhatsApp integration extension for the **[Pi Coding Agent](https://
|
|
8
|
+
A WhatsApp integration extension for the **[Pi Coding Agent](https://pi.dev)**.
|
|
9
9
|
|
|
10
10
|
Pi is a powerful agentic AI coding assistant that operates in your terminal. This extension lets you chat and pair-program with your Pi agent through WhatsApp, with message filtering, allowed contacts/groups, recents/history browsing, message detail/reply, group-only binding, and reliable message delivery.
|
|
11
11
|
|
|
@@ -32,6 +32,34 @@ Pi is a powerful agentic AI coding assistant that operates in your terminal. Thi
|
|
|
32
32
|
|
|
33
33
|
## Prerequisites
|
|
34
34
|
|
|
35
|
+
### Pi Coding Agent
|
|
36
|
+
|
|
37
|
+
Install Pi from [pi.dev](https://pi.dev):
|
|
38
|
+
|
|
39
|
+
**Linux / macOS (recommended):**
|
|
40
|
+
```bash
|
|
41
|
+
curl -fsSL https://pi.dev/install.sh | sh
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Or via npm (requires Node.js 20+):**
|
|
45
|
+
```bash
|
|
46
|
+
npm install -g @earendil-works/pi-coding-agent
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then authenticate or set an API key before starting:
|
|
50
|
+
```bash
|
|
51
|
+
# Use /login inside Pi for subscription providers, or set an API key:
|
|
52
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
53
|
+
# OpenAI
|
|
54
|
+
export OPENAI_API_KEY=sk-...
|
|
55
|
+
# Google Gemini
|
|
56
|
+
export GEMINI_API_KEY=...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
See the [Pi documentation](https://pi.dev/docs/latest) for full setup, providers, and model configuration details.
|
|
60
|
+
|
|
61
|
+
### Audio Transcription
|
|
62
|
+
|
|
35
63
|
To enable audio transcription features:
|
|
36
64
|
```bash
|
|
37
65
|
python -m pip install -U openai-whisper
|
|
@@ -120,12 +148,12 @@ pi -e whatsapp-pi.ts --whatsapp-pi-online
|
|
|
120
148
|
|
|
121
149
|
### Allowed Contacts Management
|
|
122
150
|
- **Add Contact** - Add a new contact to the allowed contacts list (format: +5511999999999)
|
|
123
|
-
- **Select a contact** - Open a submenu with **History**, **Send Message**, **Print Contact**,
|
|
151
|
+
- **Select a contact** - Open a submenu with **History**, **Send Message**, **Print Contact**, **Add Alias**, **Remove Alias**, **Add Number**, **Remove Number**, **Remove Contact**, and **Back**
|
|
124
152
|
- **Back** - Return to main menu
|
|
125
153
|
|
|
126
154
|
### Allowed Groups Management
|
|
127
155
|
- **Add Group** - Add a WhatsApp group JID to the allowed groups list (format: 120363012345@g.us)
|
|
128
|
-
- **Select a group** - Open a submenu with **History**, **Send Message**, **Print Group JID**, **Reaction Mode**,
|
|
156
|
+
- **Select a group** - Open a submenu with **History**, **Send Message**, **Print Group JID**, **Reaction Mode**, **Add Alias**, **Remove Alias**, **Remove Group**, and **Back**
|
|
129
157
|
- **Reaction Mode** - Switch between **Active** and **Passive** behavior for that group
|
|
130
158
|
- **Back** - Return to main menu
|
|
131
159
|
|
|
@@ -137,6 +165,11 @@ pi -e whatsapp-pi.ts --whatsapp-pi-online
|
|
|
137
165
|
- **Remove Alias** - Clear saved alias for that sender
|
|
138
166
|
- **Back** - Return to main menu
|
|
139
167
|
|
|
168
|
+
### WhatsApp Chat Commands
|
|
169
|
+
Send these commands directly in WhatsApp to control the agent session:
|
|
170
|
+
- **`/compact`** - Compact the current Pi session context
|
|
171
|
+
- **`/abort`** - Abort the current Pi agent operation
|
|
172
|
+
|
|
140
173
|
## Project Structure
|
|
141
174
|
|
|
142
175
|
```
|
|
@@ -169,4 +202,4 @@ npm test
|
|
|
169
202
|
- **Session Handling**: Saved state, allow list, and startup reconnects are restored automatically when available.
|
|
170
203
|
- **Intelligent Message Filtering**: Messages ending with `π` are ignored to prevent bot loops.
|
|
171
204
|
- **Storage Management**: Persistent data lives under `.pi-data/` plus the recents store in the user home directory.
|
|
172
|
-
- **Improved Test Coverage (v1.0.
|
|
205
|
+
- **Improved Test Coverage (v1.0.59)**: Added unit tests for the `message_end` auto-reply handler, covering the happy path, disconnected guard, role guard, send failure, thrown exceptions, and the `send_wa_message` dedup flag. Fixed a Windows path separator bug in the recents service test suite.
|
package/package.json
CHANGED
package/src/i18n.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
|
|
3
1
|
type Locale = "pt-BR" | "es" | "fr";
|
|
4
2
|
type Key = keyof typeof fallback;
|
|
5
3
|
type Params = Record<string, string | number | undefined>;
|
|
6
4
|
|
|
7
5
|
let currentLocale: string | undefined;
|
|
8
6
|
|
|
9
|
-
const namespace = "whatsapp-pi";
|
|
10
|
-
|
|
11
7
|
const fallback = {
|
|
12
8
|
"tool.label": "Send WhatsApp Message",
|
|
13
9
|
"tool.description": "Send a WhatsApp message to a contact identified by their JID (e.g. 5511999998888@s.whatsapp.net). Returns a JSON result with success status and messageId or error.",
|
|
@@ -32,7 +28,7 @@ const fallback = {
|
|
|
32
28
|
"service.whatsapp.typeToConnect": "| WhatsApp: type /whatsapp to connect",
|
|
33
29
|
"service.whatsapp.connected": "| WhatsApp: Connected",
|
|
34
30
|
"service.whatsapp.qrConnected": "WhatsApp connected",
|
|
35
|
-
"service.whatsapp.qrWelcomeMessage": "
|
|
31
|
+
"service.whatsapp.qrWelcomeMessage": "😊 Hi! Send me a message, enter in '/whatsapp > Recents' to Allow Contact or Group with LID code. For Contact, consider adding the whatsapp number to the Allowed Contact list.",
|
|
36
32
|
"service.whatsapp.sessionErrorBadMac": "| WhatsApp: Session Error (Bad MAC)",
|
|
37
33
|
"service.whatsapp.loggedOut": "| WhatsApp: Logged out",
|
|
38
34
|
"service.whatsapp.conflict": "| WhatsApp: Conflict (Another Instance)",
|
|
@@ -710,41 +706,9 @@ export function resetI18n(): void {
|
|
|
710
706
|
currentLocale = undefined;
|
|
711
707
|
}
|
|
712
708
|
|
|
713
|
-
export function initI18n(
|
|
709
|
+
export function initI18n(_pi: unknown): void {
|
|
714
710
|
const forcedLocale = getLocaleOverride();
|
|
715
711
|
if (forcedLocale) {
|
|
716
712
|
currentLocale = forcedLocale;
|
|
717
713
|
}
|
|
718
|
-
|
|
719
|
-
pi.events?.emit?.("pi-core/i18n/registerBundle", {
|
|
720
|
-
namespace,
|
|
721
|
-
defaultLocale: "en",
|
|
722
|
-
fallback,
|
|
723
|
-
translations,
|
|
724
|
-
});
|
|
725
|
-
pi.events?.on?.("pi-core/i18n/localeChanged", (event: unknown) => {
|
|
726
|
-
if (forcedLocale) {
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
if (event && typeof event === "object" && "locale" in event) {
|
|
731
|
-
const locale = String((event as { locale?: unknown }).locale ?? "");
|
|
732
|
-
if (locale) {
|
|
733
|
-
currentLocale = locale;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
});
|
|
737
|
-
pi.events?.emit?.("pi-core/i18n/requestApi", {
|
|
738
|
-
namespace,
|
|
739
|
-
onApi(api: { getLocale?: () => string | undefined }) {
|
|
740
|
-
if (forcedLocale) {
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
const locale = api.getLocale?.();
|
|
745
|
-
if (locale) {
|
|
746
|
-
currentLocale = locale;
|
|
747
|
-
}
|
|
748
|
-
},
|
|
749
|
-
});
|
|
750
714
|
}
|
|
@@ -125,12 +125,12 @@ export class RecentsService {
|
|
|
125
125
|
});
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
private stripSpecialCharacters(text: string): string {
|
|
129
|
-
return text
|
|
130
|
-
.replace(
|
|
131
|
-
.replace(/\s+/g, ' ')
|
|
132
|
-
.trim();
|
|
133
|
-
}
|
|
128
|
+
private stripSpecialCharacters(text: string): string {
|
|
129
|
+
return text
|
|
130
|
+
.replace(/\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Regional_Indicator}|\u200D|\uFE0F/gu, '')
|
|
131
|
+
.replace(/\s+/g, ' ')
|
|
132
|
+
.trim();
|
|
133
|
+
}
|
|
134
134
|
|
|
135
135
|
private buildPreview(text: string): string {
|
|
136
136
|
const normalized = this.stripSpecialCharacters(text);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useMultiFileAuthState } from 'baileys';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
import { readFile, writeFile, mkdir, rm, rename } from 'fs/promises';
|
|
1
|
+
import { useMultiFileAuthState } from 'baileys';
|
|
2
|
+
import { basename, join } from 'path';
|
|
3
|
+
import { readFile, writeFile, mkdir, rm, rename, readdir } from 'fs/promises';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { SessionStatus } from '../models/whatsapp.types.js';
|
|
6
6
|
import { t } from '../i18n.js';
|
|
@@ -34,10 +34,11 @@ export class SessionManager {
|
|
|
34
34
|
private allowList: Contact[] = [];
|
|
35
35
|
private allowedGroups: Contact[] = [];
|
|
36
36
|
private ignoredNumbers: Contact[] = [];
|
|
37
|
-
private hasAuthState = false;
|
|
38
|
-
private openaiKey: string = '';
|
|
39
|
-
private visionModel: string = 'gpt-4o';
|
|
40
|
-
private operatorJid: string = '';
|
|
37
|
+
private hasAuthState = false;
|
|
38
|
+
private openaiKey: string = '';
|
|
39
|
+
private visionModel: string = 'gpt-4o';
|
|
40
|
+
private operatorJid: string = '';
|
|
41
|
+
private configLoaded = false;
|
|
41
42
|
|
|
42
43
|
constructor(baseDir = join(homedir(), '.pi', 'whatsapp-pi')) {
|
|
43
44
|
this.baseDir = baseDir;
|
|
@@ -45,20 +46,24 @@ export class SessionManager {
|
|
|
45
46
|
this.configPath = join(this.baseDir, 'config.json');
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
private async ensureStorageDirectories() {
|
|
49
|
-
await mkdir(this.baseDir, { recursive: true });
|
|
50
|
-
await mkdir(this.authStateDir, { recursive: true });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
public async ensureInitialized() {
|
|
54
|
-
try {
|
|
55
|
-
await this.ensureStorageDirectories();
|
|
56
|
-
await this.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
49
|
+
private async ensureStorageDirectories() {
|
|
50
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
51
|
+
await mkdir(this.authStateDir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public async ensureInitialized() {
|
|
55
|
+
try {
|
|
56
|
+
await this.ensureStorageDirectories();
|
|
57
|
+
await this.cleanupStaleConfigTempFiles();
|
|
58
|
+
if (!this.configLoaded) {
|
|
59
|
+
await this.loadConfig();
|
|
60
|
+
this.configLoaded = true;
|
|
61
|
+
}
|
|
62
|
+
await this.syncAuthStateFromDisk();
|
|
63
|
+
} catch {
|
|
64
|
+
// Initialization is best-effort; callers can continue with defaults.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
62
67
|
|
|
63
68
|
private async loadConfig() {
|
|
64
69
|
try {
|
|
@@ -172,21 +177,47 @@ export class SessionManager {
|
|
|
172
177
|
const serialized = JSON.stringify(config, null, 2);
|
|
173
178
|
await writeFile(tempPath, serialized);
|
|
174
179
|
try {
|
|
175
|
-
await rename(tempPath, this.configPath);
|
|
176
|
-
} catch {
|
|
177
|
-
// Windows EPERM: atomic rename failed (file locked). Fall back to direct write.
|
|
178
|
-
await writeFile(this.configPath, serialized);
|
|
179
|
-
await
|
|
180
|
-
}
|
|
181
|
-
} catch (error) {
|
|
182
|
-
await
|
|
183
|
-
console.error(t('session.manager.failedSaveConfig'), error);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
180
|
+
await rename(tempPath, this.configPath);
|
|
181
|
+
} catch {
|
|
182
|
+
// Windows EPERM: atomic rename failed (file locked). Fall back to direct write.
|
|
183
|
+
await writeFile(this.configPath, serialized);
|
|
184
|
+
await this.removeConfigTempFile(tempPath);
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
await this.removeConfigTempFile(tempPath);
|
|
188
|
+
console.error(t('session.manager.failedSaveConfig'), error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async cleanupStaleConfigTempFiles() {
|
|
193
|
+
const configFileName = basename(this.configPath);
|
|
194
|
+
let files: string[];
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
files = await readdir(this.baseDir);
|
|
198
|
+
} catch {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await Promise.all(files
|
|
203
|
+
.filter(fileName => fileName.startsWith(`${configFileName}.`) && fileName.endsWith('.tmp'))
|
|
204
|
+
.map(fileName => this.removeConfigTempFile(join(this.baseDir, fileName))));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async removeConfigTempFile(tempPath: string) {
|
|
208
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
209
|
+
try {
|
|
210
|
+
await rm(tempPath, { force: true });
|
|
211
|
+
return;
|
|
212
|
+
} catch {
|
|
213
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getAllowList(): Contact[] {
|
|
219
|
+
return this.allowList;
|
|
220
|
+
}
|
|
190
221
|
|
|
191
222
|
getAllowedContact(number: string): Contact | undefined {
|
|
192
223
|
return this.allowList.find(c => c.number === number);
|
|
@@ -185,11 +185,27 @@ export class WhatsAppService {
|
|
|
185
185
|
return value;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
private normalizeRecipientJid(jid: string): string {
|
|
189
|
-
if (jid.includes('@')) return jid;
|
|
190
|
-
const digits = jid.startsWith('+') ? jid.slice(1) : jid;
|
|
191
|
-
return `${digits}@s.whatsapp.net`;
|
|
192
|
-
}
|
|
188
|
+
private normalizeRecipientJid(jid: string): string {
|
|
189
|
+
if (jid.includes('@')) return jid;
|
|
190
|
+
const digits = jid.startsWith('+') ? jid.slice(1) : jid;
|
|
191
|
+
return `${digits}@s.whatsapp.net`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
public resolveOutboundRecipientJid(recipient: string): string {
|
|
195
|
+
if (SessionManager.isGroupJid(recipient)) {
|
|
196
|
+
return recipient;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const senderNumber = this.normalizeContactNumber(recipient.split('@')[0]);
|
|
200
|
+
const allowedContact = this.sessionManager.getAllowedContact(recipient)
|
|
201
|
+
?? this.sessionManager.getAllowedContact(senderNumber);
|
|
202
|
+
|
|
203
|
+
if (allowedContact?.sendNumber) {
|
|
204
|
+
return this.normalizeRecipientJid(allowedContact.sendNumber);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return this.normalizeRecipientJid(recipient);
|
|
208
|
+
}
|
|
193
209
|
|
|
194
210
|
private normalizeJidForComparison(jid: string): string {
|
|
195
211
|
const [localPart, domain = ''] = jid.split('@');
|
|
@@ -397,7 +413,7 @@ export class WhatsAppService {
|
|
|
397
413
|
}
|
|
398
414
|
|
|
399
415
|
private async handlePairingQr(qr: string) {
|
|
400
|
-
this.sessionManager.setStatus('pairing');
|
|
416
|
+
await this.sessionManager.setStatus('pairing');
|
|
401
417
|
this.onQRCode?.(qr);
|
|
402
418
|
this.onStatusUpdate?.(t('service.whatsapp.typeToConnect'));
|
|
403
419
|
this.qrWasShown = true;
|
|
@@ -413,7 +429,7 @@ export class WhatsAppService {
|
|
|
413
429
|
this.clearReconnectTimeout();
|
|
414
430
|
await this.saveCreds?.();
|
|
415
431
|
await this.sessionManager.markAuthStateAvailable();
|
|
416
|
-
this.sessionManager.setStatus('connected');
|
|
432
|
+
await this.sessionManager.setStatus('connected');
|
|
417
433
|
this.onStatusUpdate?.(t('service.whatsapp.connected'));
|
|
418
434
|
|
|
419
435
|
if (this.qrWasShown) {
|
|
@@ -513,7 +529,7 @@ export class WhatsAppService {
|
|
|
513
529
|
this.scheduleReconnect(options);
|
|
514
530
|
} else if (!shouldReconnect) {
|
|
515
531
|
this.reconnectAttempts = 0;
|
|
516
|
-
this.sessionManager.setStatus('logged-out');
|
|
532
|
+
await this.sessionManager.setStatus('logged-out');
|
|
517
533
|
this.onStatusUpdate?.(t('service.whatsapp.disconnected'));
|
|
518
534
|
}
|
|
519
535
|
}
|
|
@@ -596,7 +612,7 @@ export class WhatsAppService {
|
|
|
596
612
|
|
|
597
613
|
if (!this.sessionManager.isConversationAllowed(senderJid)) {
|
|
598
614
|
if (this.isVerbose()) {
|
|
599
|
-
console.log(
|
|
615
|
+
console.log(t('service.whatsapp.ignoredNotAllowed', { senderJid }));
|
|
600
616
|
}
|
|
601
617
|
await this.sessionManager.trackIgnoredNumber(senderJid, pushName);
|
|
602
618
|
return;
|
|
@@ -653,27 +669,29 @@ export class WhatsAppService {
|
|
|
653
669
|
}
|
|
654
670
|
}
|
|
655
671
|
|
|
656
|
-
async sendMessage(jid: string, text: string) {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
672
|
+
async sendMessage(jid: string, text: string) {
|
|
673
|
+
const recipientJid = this.resolveOutboundRecipientJid(jid);
|
|
674
|
+
|
|
675
|
+
// Ensure we show the typing indicator before sending
|
|
676
|
+
await this.sendPresence(recipientJid, 'composing');
|
|
677
|
+
|
|
678
|
+
const result = await this.messageSender.send({
|
|
679
|
+
recipientJid,
|
|
680
|
+
text: text
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// After sending, we can stop the typing indicator
|
|
684
|
+
await this.sendPresence(recipientJid, 'paused');
|
|
685
|
+
|
|
686
|
+
if (!result.success) {
|
|
687
|
+
console.error(t('service.whatsapp.failedSendMessage', { jid: recipientJid, error: result.error ?? t('message.sender.unknownError') }));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return result;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async sendMenuMessage(jid: string, text: string) {
|
|
694
|
+
const normalizedJid = this.resolveOutboundRecipientJid(jid);
|
|
677
695
|
const socket = this.getActiveSocket();
|
|
678
696
|
|
|
679
697
|
if (!socket) {
|
|
@@ -41,13 +41,21 @@ const buildReplyWidget = (selectedMessage: SelectedMessageContext): string[] =>
|
|
|
41
41
|
];
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
const buildReplyTitle = (selectedMessage: SelectedMessageContext): string => {
|
|
44
|
+
const buildReplyTitle = (selectedMessage: SelectedMessageContext): string => {
|
|
45
45
|
const sender = selectedMessage.senderName
|
|
46
46
|
? `${selectedMessage.senderName} (${selectedMessage.senderNumber})`
|
|
47
47
|
: selectedMessage.senderNumber;
|
|
48
48
|
|
|
49
|
-
return truncateToWidth(`${t('message.reply.title')} ${sender}`, 120);
|
|
50
|
-
};
|
|
49
|
+
return truncateToWidth(`${t('message.reply.title')} ${sender}`, 120);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const toRecentSenderNumber = (recipientJid: string): string => {
|
|
53
|
+
if (recipientJid.endsWith('@g.us')) {
|
|
54
|
+
return recipientJid;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return `+${recipientJid.split('@')[0]}`;
|
|
58
|
+
};
|
|
51
59
|
|
|
52
60
|
export async function showMessageReplyView(
|
|
53
61
|
ctx: MessageReplyContext,
|
|
@@ -70,24 +78,27 @@ export async function showMessageReplyView(
|
|
|
70
78
|
continue;
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
const draft: ReplyDraft = {
|
|
74
|
-
text,
|
|
75
|
-
targetMessageId: props.selectedMessage.messageId,
|
|
76
|
-
targetConversation: props.selectedMessage.senderNumber
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
81
|
+
const draft: ReplyDraft = {
|
|
82
|
+
text,
|
|
83
|
+
targetMessageId: props.selectedMessage.messageId,
|
|
84
|
+
targetConversation: props.selectedMessage.senderNumber
|
|
85
|
+
};
|
|
86
|
+
const recipientJid = props.whatsappService.resolveOutboundRecipientJid(
|
|
87
|
+
props.selectedMessage.senderNumber
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const result: ReplySendResult = await props.whatsappService.sendMenuMessage(
|
|
91
|
+
recipientJid,
|
|
92
|
+
draft.text
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (result.success) {
|
|
96
|
+
await props.recentsService.recordMessage({
|
|
97
|
+
messageId: result.messageId ?? `${Date.now()}`,
|
|
98
|
+
senderNumber: toRecentSenderNumber(recipientJid),
|
|
99
|
+
senderName: props.selectedMessage.senderName,
|
|
100
|
+
text: draft.text,
|
|
101
|
+
direction: 'outgoing',
|
|
91
102
|
timestamp: Date.now()
|
|
92
103
|
});
|
|
93
104
|
ctx.ui.notify(t('message.reply.sent', { preview: buildPreview(props.selectedMessage.text) }), 'info');
|
package/whatsapp-pi.ts
CHANGED
|
@@ -48,6 +48,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
48
48
|
const menuHandler = new MenuHandler(whatsappService, sessionManager, recentsService);
|
|
49
49
|
let _ctx: ExtensionContext | undefined;
|
|
50
50
|
|
|
51
|
+
const formatFooterStatus = (status: string) => {
|
|
52
|
+
if (status !== t("service.whatsapp.connected")) {
|
|
53
|
+
return status;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const allowedChats = sessionManager.getAllowList().length + sessionManager.getAllowedGroups().length;
|
|
57
|
+
if (allowedChats === 0) {
|
|
58
|
+
return `${status} - No chats`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return `${status} to ${allowedChats} chat${allowedChats === 1 ? '' : 's'}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const refreshFooterStatus = () => {
|
|
65
|
+
if (!_ctx) return;
|
|
66
|
+
_ctx.ui.setStatus('whatsapp', formatFooterStatus(whatsappService.getStatus() === 'connected'
|
|
67
|
+
? t("service.whatsapp.connected")
|
|
68
|
+
: t("service.whatsapp.disconnected")));
|
|
69
|
+
};
|
|
70
|
+
|
|
51
71
|
const installGracefulShutdownHandlers = () => {
|
|
52
72
|
shutdownState.__whatsappPiShutdown ??= { installed: false };
|
|
53
73
|
if (shutdownState.__whatsappPiShutdown.installed) {
|
|
@@ -84,7 +104,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
84
104
|
}
|
|
85
105
|
ctx.ui.setStatus('whatsapp', '| WhatsApp: Disconnected');
|
|
86
106
|
whatsappService.setStatusCallback((status) => {
|
|
87
|
-
ctx.ui.setStatus('whatsapp', status);
|
|
107
|
+
ctx.ui.setStatus('whatsapp', formatFooterStatus(status));
|
|
88
108
|
});
|
|
89
109
|
|
|
90
110
|
// Set up group binding if configured
|
|
@@ -185,8 +205,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
205
|
ctx.ui.notify('WhatsApp: Session reset via /new is now fully supported.', 'info');
|
|
186
206
|
});
|
|
187
207
|
|
|
188
|
-
// Track whether send_wa_message tool already sent a reply this turn
|
|
189
|
-
let toolSentToJid: string | null = null;
|
|
208
|
+
// Track whether send_wa_message tool already sent a reply this turn
|
|
209
|
+
let toolSentToJid: string | null = null;
|
|
210
|
+
|
|
211
|
+
const toRecentSenderNumber = (recipientJid: string): string => {
|
|
212
|
+
if (recipientJid.endsWith('@g.us')) {
|
|
213
|
+
return recipientJid;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return `+${recipientJid.split('@')[0]}`;
|
|
217
|
+
};
|
|
190
218
|
|
|
191
219
|
// Handle incoming messages by injecting them as user prompts
|
|
192
220
|
whatsappService.setMessageCallback(async (m) => {
|
|
@@ -298,16 +326,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
298
326
|
formattedMessage
|
|
299
327
|
].join('\n'));
|
|
300
328
|
|
|
301
|
-
const
|
|
329
|
+
const outboundJid = whatsappService.resolveOutboundRecipientJid(resolvedJid);
|
|
330
|
+
const result = await whatsappService.sendMessage(outboundJid, params.message);
|
|
302
331
|
|
|
303
332
|
if (result.success) {
|
|
304
333
|
// Mark that tool already sent to this JID — prevents message_end from re-sending
|
|
305
|
-
toolSentToJid =
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
messageId: result.messageId!,
|
|
310
|
-
senderNumber,
|
|
334
|
+
toolSentToJid = outboundJid;
|
|
335
|
+
await recentsService.recordMessage({
|
|
336
|
+
messageId: result.messageId!,
|
|
337
|
+
senderNumber: toRecentSenderNumber(outboundJid),
|
|
311
338
|
text: params.message,
|
|
312
339
|
direction: 'outgoing',
|
|
313
340
|
timestamp: Date.now()
|
|
@@ -351,16 +378,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
351
378
|
allowList: sessionManager.getAllowList(),
|
|
352
379
|
allowedGroups: sessionManager.getAllowedGroups()
|
|
353
380
|
});
|
|
381
|
+
refreshFooterStatus();
|
|
354
382
|
}
|
|
355
383
|
});
|
|
356
384
|
|
|
357
385
|
// Handle outgoing messages (Agent -> WhatsApp)
|
|
358
386
|
pi.on("agent_start", async (_event, _ctx) => {
|
|
359
387
|
if (sessionManager.getStatus() !== 'connected') return;
|
|
360
|
-
const lastJid = whatsappService.getLastRemoteJid();
|
|
361
|
-
if (lastJid) {
|
|
362
|
-
await whatsappService.sendPresence(lastJid, 'composing');
|
|
363
|
-
}
|
|
388
|
+
const lastJid = whatsappService.getLastRemoteJid();
|
|
389
|
+
if (lastJid) {
|
|
390
|
+
await whatsappService.sendPresence(whatsappService.resolveOutboundRecipientJid(lastJid), 'composing');
|
|
391
|
+
}
|
|
364
392
|
});
|
|
365
393
|
|
|
366
394
|
pi.on("message_end", async (event, ctx) => {
|
|
@@ -368,23 +396,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
368
396
|
|
|
369
397
|
const { message } = event;
|
|
370
398
|
// Only reply if it's the assistant and we have a valid target
|
|
371
|
-
if (message.role === "assistant") {
|
|
372
|
-
const lastJid = whatsappService.getLastRemoteJid();
|
|
373
|
-
const text = message.content.filter(c => c.type === "text").map(c => c.text).join("\n");
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
399
|
+
if (message.role === "assistant") {
|
|
400
|
+
const lastJid = whatsappService.getLastRemoteJid();
|
|
401
|
+
const text = message.content.filter(c => c.type === "text").map(c => c.text).join("\n");
|
|
402
|
+
const outboundJid = lastJid
|
|
403
|
+
? whatsappService.resolveOutboundRecipientJid(lastJid)
|
|
404
|
+
: null;
|
|
405
|
+
|
|
406
|
+
// Skip if send_wa_message tool already sent a reply to this JID
|
|
407
|
+
if (toolSentToJid === outboundJid) {
|
|
408
|
+
toolSentToJid = null;
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (outboundJid && text) {
|
|
413
|
+
try {
|
|
414
|
+
const result = await whatsappService.sendMessage(outboundJid, text);
|
|
415
|
+
if (result.success) {
|
|
416
|
+
await recentsService.recordMessage({
|
|
417
|
+
messageId: result.messageId ?? `${Date.now()}`,
|
|
418
|
+
senderNumber: toRecentSenderNumber(outboundJid),
|
|
388
419
|
text,
|
|
389
420
|
direction: 'outgoing',
|
|
390
421
|
timestamp: Date.now()
|