whatsapp-pi 1.0.59 → 1.0.60

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 CHANGED
@@ -5,7 +5,7 @@
5
5
  # WhatsApp-Pi
6
6
  [![GitHub](https://img.shields.io/badge/github-repo-black.svg?style=flat-square&logo=github)](https://github.com/RaphaCastelloes/whatsapp-pi)
7
7
 
8
- A WhatsApp integration extension for the **[Pi Coding Agent](https://github.com/mariozechner/pi-coding-agent)**.
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**, alias actions, **Remove Contact**, and **Back**
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**, alias actions, **Remove Group**, and **Back**
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.56)**: 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.59",
3
+ "version": "1.0.60",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
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": "👋 To get started, send a message, enter in /whatsapp > Recents to Allow Contact with LID code, then add the whatsapp number in the Allowed Contact list.",
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(pi: ExtensionAPI): void {
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(/[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}\u200D\uFE0F]/gu, '')
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.loadConfig();
57
- await this.syncAuthStateFromDisk();
58
- } catch {
59
- // Initialization is best-effort; callers can continue with defaults.
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 rm(tempPath, { force: true }).catch(() => {});
180
- }
181
- } catch (error) {
182
- await rm(tempPath, { force: true }).catch(() => {});
183
- console.error(t('session.manager.failedSaveConfig'), error);
184
- }
185
- }
186
-
187
- getAllowList(): Contact[] {
188
- return this.allowList;
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);
@@ -397,7 +397,7 @@ export class WhatsAppService {
397
397
  }
398
398
 
399
399
  private async handlePairingQr(qr: string) {
400
- this.sessionManager.setStatus('pairing');
400
+ await this.sessionManager.setStatus('pairing');
401
401
  this.onQRCode?.(qr);
402
402
  this.onStatusUpdate?.(t('service.whatsapp.typeToConnect'));
403
403
  this.qrWasShown = true;
@@ -413,7 +413,7 @@ export class WhatsAppService {
413
413
  this.clearReconnectTimeout();
414
414
  await this.saveCreds?.();
415
415
  await this.sessionManager.markAuthStateAvailable();
416
- this.sessionManager.setStatus('connected');
416
+ await this.sessionManager.setStatus('connected');
417
417
  this.onStatusUpdate?.(t('service.whatsapp.connected'));
418
418
 
419
419
  if (this.qrWasShown) {
@@ -513,7 +513,7 @@ export class WhatsAppService {
513
513
  this.scheduleReconnect(options);
514
514
  } else if (!shouldReconnect) {
515
515
  this.reconnectAttempts = 0;
516
- this.sessionManager.setStatus('logged-out');
516
+ await this.sessionManager.setStatus('logged-out');
517
517
  this.onStatusUpdate?.(t('service.whatsapp.disconnected'));
518
518
  }
519
519
  }
@@ -596,7 +596,7 @@ export class WhatsAppService {
596
596
 
597
597
  if (!this.sessionManager.isConversationAllowed(senderJid)) {
598
598
  if (this.isVerbose()) {
599
- console.log(`Ignoring message from ${senderJid} (not in allow list)`);
599
+ console.log(t('service.whatsapp.ignoredNotAllowed', { senderJid }));
600
600
  }
601
601
  await this.sessionManager.trackIgnoredNumber(senderJid, pushName);
602
602
  return;
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
@@ -351,6 +371,7 @@ export default function (pi: ExtensionAPI) {
351
371
  allowList: sessionManager.getAllowList(),
352
372
  allowedGroups: sessionManager.getAllowedGroups()
353
373
  });
374
+ refreshFooterStatus();
354
375
  }
355
376
  });
356
377