whatsapp-pi 1.0.22 → 1.0.24

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
@@ -18,14 +18,22 @@ Pi is a powerful agentic AI coding assistant that operates in your terminal. Thi
18
18
  - View ignored numbers (not in allow list) and add them when needed
19
19
  - **Reliable Messaging**: Queue-based message sending with retry logic
20
20
  - **TUI Integration**: Menu-driven interface for managing connections and contacts
21
+ - **Media Support**:
22
+ - **Vision Analysis**: Automatically forwards WhatsApp images to Pi for analysis.
23
+ - **Document Handling**: Downloads and stores documents (PDF, text) for agent access.
21
24
 
22
25
  ## Prerequisites
23
26
 
24
- To enable audio features, you need to install OpenAI Whisper:
27
+ To enable audio transcription features:
25
28
  ```bash
26
29
  python -m pip install -U openai-whisper
27
30
  ```
28
31
 
32
+ To enable PDF reading capabilities (required for the agent to process documents):
33
+ - **Linux**: `sudo apt-get install poppler-utils`
34
+ - **macOS**: `brew install poppler`
35
+ - **Windows**: Install `poppler` (e.g., via Scoop) and add to PATH.
36
+
29
37
  ## Quick Start
30
38
 
31
39
  1. Install the extension:
@@ -71,7 +79,7 @@ pi -e whatsapp-pi.ts --verbose
71
79
  - `/whatsapp` - Open the WhatsApp management menu
72
80
 
73
81
  ### Main Menu Options
74
- - **Connect WhatsApp** - Start WhatsApp connection (shows QR code for first-time setup)
82
+ - **Connect / Reconnect WhatsApp** - Start WhatsApp connection using saved credentials when available; QR code appears only if pairing is required
75
83
  - **Disconnect WhatsApp** - Stop WhatsApp connection
76
84
  - **Logoff (Delete Session)** - Remove all credentials and session data
77
85
  - **Allowed Numbers** - Manage contacts that can interact with Pi
@@ -79,8 +87,7 @@ pi -e whatsapp-pi.ts --verbose
79
87
 
80
88
  ### Allowed Numbers Management
81
89
  - **Add Number** - Add a new contact to the allow list (format: +5511999999999)
82
- - **Remove [Number]** - Remove a specific contact from the allow list
83
- - **Clear All** - Remove all allowed numbers
90
+ - **Select a contact** - Open a submenu with **Send Message**, **Remove Number**, and **Back**
84
91
  - **Back** - Return to main menu
85
92
 
86
93
  ### Blocked Numbers Management
@@ -108,6 +115,11 @@ See `specs/` directory for detailed feature documentation:
108
115
  - `002-manual-whatsapp-connection/` - Connection management
109
116
  - `003-whatsapp-messaging-refactor/` - Reliable messaging
110
117
  - `004-blocked-numbers-management/` - Block list feature
118
+ - `005-verbose-mode-support/` - Logging and tracing
119
+ - `006-auto-connect-flag/` - Automatic connection support
120
+ - `007-image-recognition/` - Vision analysis integration
121
+ - `008-document-message-support/` - Document handling and storage
122
+ - `009-localize-system-messages/` - US English localization
111
123
 
112
124
  ## Development
113
125
 
@@ -116,7 +128,17 @@ Run tests:
116
128
  npm test
117
129
  ```
118
130
 
119
- Lint:
120
- ```bash
121
- npm run lint
122
- ```
131
+ ## Implementation Notes
132
+
133
+ ### Recent Feature Updates (2026-04)
134
+
135
+ - **Auto-Connect Support**: Use the `--whatsapp-pi-online` flag to automatically connect to WhatsApp on startup if you have a valid active session.
136
+ - **Vision Analysis**: Images sent via WhatsApp are automatically downloaded and forwarded to the Pi agent as base64, enabling vision-based interactions.
137
+ - **Document Message Support**:
138
+ - WhatsApp documents (PDFs, text files, etc.) are downloaded and saved to `./.pi-data/whatsapp/documents/`.
139
+ - The Pi agent receives a notification with the file path and metadata.
140
+ - **Full US-English Localization**: All user-facing menus, TUI notifications, console logs, and agent communication headers have been localized to `en-US` for a consistent experience.
141
+ - **Intelligent Message Filtering**:
142
+ - **Loop Prevention**: The bot automatically ignores any message ending with the `π` symbol, preventing infinite loops between instances.
143
+ - **Manual Interaction**: Users can now interact with the bot from their own WhatsApp account; the bot will process `fromMe` messages as long as they don't contain the bot's signature.
144
+ - **Storage Management**: All persistent data (auth state, documents, config) is centralized in the `.pi-data/` directory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -44,3 +44,37 @@ export class WhatsAppError extends Error {
44
44
  export function validatePhoneNumber(number: string): boolean {
45
45
  return /^\+[1-9]\d{1,14}$/.test(number);
46
46
  }
47
+
48
+ export interface DocumentMetadata {
49
+ filename: string;
50
+ mimetype: string;
51
+ size: number;
52
+ savedPath: string;
53
+ timestamp: number;
54
+ }
55
+
56
+ export type MessageDirection = 'incoming' | 'outgoing';
57
+
58
+ export interface RecentConversationMessage {
59
+ messageId: string;
60
+ senderNumber: string;
61
+ text: string;
62
+ direction: MessageDirection;
63
+ timestamp: number;
64
+ }
65
+
66
+ export interface RecentConversationSummary {
67
+ senderNumber: string;
68
+ senderName?: string;
69
+ lastMessagePreview: string;
70
+ lastMessageTime: number;
71
+ lastMessageDirection: MessageDirection;
72
+ messageCount: number;
73
+ isAllowed: boolean;
74
+ }
75
+
76
+ export interface RecentsStore {
77
+ conversations: RecentConversationSummary[];
78
+ messagesBySender: Record<string, RecentConversationMessage[]>;
79
+ updatedAt: number;
80
+ }
@@ -45,10 +45,10 @@ export class AudioService {
45
45
  return text.trim();
46
46
  }
47
47
 
48
- return '[Transcrição vazia]';
48
+ return '[Empty transcription]';
49
49
  } catch (error) {
50
50
  console.error('[AudioService] Transcription error:', error);
51
- return `[Erro na transcrição: ${error instanceof Error ? error.message : String(error)}]`;
51
+ return `[Transcription error: ${error instanceof Error ? error.message : String(error)}]`;
52
52
  }
53
53
  }
54
54
  }
@@ -0,0 +1,203 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { mkdir, readFile, writeFile } from 'fs/promises';
4
+ import type {
5
+ MessageDirection,
6
+ RecentConversationMessage,
7
+ RecentConversationSummary,
8
+ RecentsStore
9
+ } from '../models/whatsapp.types.js';
10
+ import { SessionManager } from './session.manager.js';
11
+
12
+ export interface RecentsMessageInput {
13
+ messageId: string;
14
+ senderNumber: string;
15
+ senderName?: string;
16
+ text: string;
17
+ direction: MessageDirection;
18
+ timestamp: number;
19
+ }
20
+
21
+ export class RecentsService {
22
+ private readonly baseDir = join(homedir(), '.pi', 'whatsapp-pi');
23
+ private readonly dataDir = join(this.baseDir, 'recents');
24
+ private readonly storePath = join(this.dataDir, 'recents.json');
25
+ private store: RecentsStore = {
26
+ conversations: [],
27
+ messagesBySender: {},
28
+ updatedAt: Date.now()
29
+ };
30
+
31
+ constructor(private readonly sessionManager: SessionManager) {}
32
+
33
+ async ensureInitialized() {
34
+ await mkdir(this.dataDir, { recursive: true });
35
+ await this.loadStore();
36
+ }
37
+
38
+ private async loadStore() {
39
+ try {
40
+ const content = await readFile(this.storePath, 'utf-8');
41
+ const parsed = JSON.parse(content) as Partial<RecentsStore>;
42
+
43
+ this.store = {
44
+ conversations: Array.isArray(parsed.conversations) ? parsed.conversations.slice(0, 20) : [],
45
+ messagesBySender: parsed.messagesBySender && typeof parsed.messagesBySender === 'object'
46
+ ? this.normalizeMessagesMap(parsed.messagesBySender)
47
+ : {},
48
+ updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now()
49
+ };
50
+
51
+ this.rebuildConversationState();
52
+ } catch {
53
+ this.store = {
54
+ conversations: [],
55
+ messagesBySender: {},
56
+ updatedAt: Date.now()
57
+ };
58
+ }
59
+ }
60
+
61
+ private normalizeMessagesMap(messagesBySender: RecentsStore['messagesBySender']): RecentsStore['messagesBySender'] {
62
+ const normalized: RecentsStore['messagesBySender'] = {};
63
+
64
+ for (const [senderNumber, messages] of Object.entries(messagesBySender)) {
65
+ if (!Array.isArray(messages)) continue;
66
+ normalized[senderNumber] = messages
67
+ .filter((message): message is RecentConversationMessage => this.isValidMessage(message))
68
+ .map(message => ({ ...message, timestamp: this.normalizeTimestamp(message.timestamp) }))
69
+ .sort((left, right) => left.timestamp - right.timestamp)
70
+ .slice(-20);
71
+ }
72
+
73
+ return normalized;
74
+ }
75
+
76
+ private isValidMessage(message: unknown): message is RecentConversationMessage {
77
+ return Boolean(
78
+ message &&
79
+ typeof message === 'object' &&
80
+ typeof (message as RecentConversationMessage).messageId === 'string' &&
81
+ typeof (message as RecentConversationMessage).senderNumber === 'string' &&
82
+ typeof (message as RecentConversationMessage).text === 'string' &&
83
+ (message as RecentConversationMessage).text.trim().length > 0 &&
84
+ ((message as RecentConversationMessage).direction === 'incoming' || (message as RecentConversationMessage).direction === 'outgoing') &&
85
+ typeof (message as RecentConversationMessage).timestamp === 'number'
86
+ );
87
+ }
88
+
89
+ private rebuildConversationState() {
90
+ const previousNames = new Map(
91
+ this.store.conversations.map(conversation => [conversation.senderNumber, conversation.senderName] as const)
92
+ );
93
+ const summaries = new Map<string, RecentConversationSummary>();
94
+
95
+ for (const [senderNumber, messages] of Object.entries(this.store.messagesBySender)) {
96
+ if (messages.length === 0) continue;
97
+ const lastMessage = messages[messages.length - 1];
98
+ summaries.set(senderNumber, {
99
+ senderNumber,
100
+ senderName: previousNames.get(senderNumber),
101
+ lastMessagePreview: this.buildPreview(lastMessage.text),
102
+ lastMessageTime: lastMessage.timestamp,
103
+ lastMessageDirection: lastMessage.direction,
104
+ messageCount: messages.length,
105
+ isAllowed: this.sessionManager.isAllowed(senderNumber)
106
+ });
107
+ }
108
+
109
+ this.store.conversations = Array.from(summaries.values())
110
+ .sort((left, right) => right.lastMessageTime - left.lastMessageTime)
111
+ .slice(0, 20);
112
+ }
113
+
114
+ private buildPreview(text: string): string {
115
+ const normalized = text.trim().replace(/\s+/g, ' ');
116
+ if (normalized.length <= 80) return normalized;
117
+ return `${normalized.slice(0, 77)}...`;
118
+ }
119
+
120
+ private async persistStore() {
121
+ this.store.updatedAt = Date.now();
122
+ await writeFile(this.storePath, JSON.stringify(this.store, null, 2));
123
+ }
124
+
125
+ private normalizeNumber(input: string): string {
126
+ const cleaned = input.replace(/@s\.whatsapp\.net$/, '');
127
+ if (cleaned.startsWith('+')) {
128
+ return cleaned;
129
+ }
130
+ if (/^\d+$/.test(cleaned)) {
131
+ return `+${cleaned}`;
132
+ }
133
+ return cleaned;
134
+ }
135
+
136
+ private normalizeTimestamp(timestamp: number): number {
137
+ return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp;
138
+ }
139
+
140
+ async recordMessage(input: RecentsMessageInput) {
141
+ const senderNumber = this.normalizeNumber(input.senderNumber);
142
+ if (!senderNumber) return;
143
+
144
+ const normalizedTimestamp = this.normalizeTimestamp(input.timestamp);
145
+ const normalizedText = input.text.trim().replace(/\s+/g, ' ');
146
+ if (!normalizedText) return;
147
+
148
+ const existing = this.store.messagesBySender[senderNumber] ?? [];
149
+ const nextMessage: RecentConversationMessage = {
150
+ messageId: input.messageId,
151
+ senderNumber,
152
+ text: normalizedText,
153
+ direction: input.direction,
154
+ timestamp: normalizedTimestamp
155
+ };
156
+
157
+ const filtered = existing.filter(message => message.messageId !== nextMessage.messageId);
158
+ filtered.push(nextMessage);
159
+
160
+ this.store.messagesBySender[senderNumber] = filtered
161
+ .sort((left, right) => left.timestamp - right.timestamp)
162
+ .slice(-20);
163
+
164
+ const existingConversation = this.store.conversations.find(conversation => conversation.senderNumber === senderNumber);
165
+ const summary: RecentConversationSummary = {
166
+ senderNumber,
167
+ senderName: input.senderName ?? existingConversation?.senderName,
168
+ lastMessagePreview: this.buildPreview(input.text),
169
+ lastMessageTime: normalizedTimestamp,
170
+ lastMessageDirection: input.direction,
171
+ messageCount: this.store.messagesBySender[senderNumber].length,
172
+ isAllowed: this.sessionManager.isAllowed(senderNumber)
173
+ };
174
+
175
+ this.store.conversations = [
176
+ summary,
177
+ ...this.store.conversations.filter(item => item.senderNumber !== senderNumber)
178
+ ]
179
+ .sort((left, right) => right.lastMessageTime - left.lastMessageTime)
180
+ .slice(0, 20);
181
+
182
+ await this.persistStore();
183
+ }
184
+
185
+ async getRecentConversations(): Promise<RecentConversationSummary[]> {
186
+ this.rebuildConversationState();
187
+ return [...this.store.conversations];
188
+ }
189
+
190
+ async getConversationHistory(senderNumber: string): Promise<RecentConversationMessage[]> {
191
+ const normalizedNumber = this.normalizeNumber(senderNumber);
192
+ const messages = this.store.messagesBySender[normalizedNumber] ?? [];
193
+ return [...messages]
194
+ .map(message => ({ ...message, timestamp: this.normalizeTimestamp(message.timestamp) }))
195
+ .sort((left, right) => left.timestamp - right.timestamp)
196
+ .slice(-20);
197
+ }
198
+
199
+ async hasRecentConversations(): Promise<boolean> {
200
+ const conversations = await this.getRecentConversations();
201
+ return conversations.length > 0;
202
+ }
203
+ }
@@ -1,6 +1,6 @@
1
1
  import { useMultiFileAuthState } from '@whiskeysockets/baileys';
2
2
  import { join } from 'path';
3
- import { readFile, writeFile, mkdir, rm } from 'fs/promises';
3
+ import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises';
4
4
  import { homedir } from 'os';
5
5
  import { SessionStatus } from '../models/whatsapp.types.js';
6
6
 
@@ -12,21 +12,27 @@ export interface Contact {
12
12
  export class SessionManager {
13
13
  // Data is stored in the user's home directory to persist across updates
14
14
  private readonly baseDir = join(homedir(), '.pi', 'whatsapp-pi');
15
- private readonly authDir = join(this.baseDir, 'auth');
15
+ private readonly authStateDir = join(this.baseDir, 'auth');
16
16
  private readonly configPath = join(this.baseDir, 'config.json');
17
17
 
18
18
  private status: SessionStatus = 'logged-out';
19
19
  private allowList: Contact[] = [];
20
20
  private blockList: Contact[] = [];
21
21
  private ignoredNumbers: Contact[] = [];
22
+ private hasAuthState = false;
22
23
  private openaiKey: string = '';
23
24
  private visionModel: string = 'gpt-4o';
24
25
 
26
+ private async ensureStorageDirectories() {
27
+ await mkdir(this.baseDir, { recursive: true });
28
+ await mkdir(this.authStateDir, { recursive: true });
29
+ }
30
+
25
31
  public async ensureInitialized() {
26
32
  try {
27
- await mkdir(this.baseDir, { recursive: true });
28
- await mkdir(this.authDir, { recursive: true });
33
+ await this.ensureStorageDirectories();
29
34
  await this.loadConfig();
35
+ await this.syncAuthStateFromDisk();
30
36
  } catch (error) {}
31
37
  }
32
38
 
@@ -54,6 +60,7 @@ export class SessionManager {
54
60
  this.blockList = (config.blockList || []).map(cleanContact).filter(Boolean) as Contact[];
55
61
  this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
56
62
  this.status = config.status || 'logged-out';
63
+ this.hasAuthState = Boolean(config.hasAuthState);
57
64
  this.openaiKey = config.openaiKey || '';
58
65
  this.visionModel = config.visionModel || 'gpt-4o';
59
66
  } catch (error) {
@@ -68,6 +75,7 @@ export class SessionManager {
68
75
  blockList: this.blockList,
69
76
  ignoredNumbers: this.ignoredNumbers,
70
77
  status: this.status,
78
+ hasAuthState: this.hasAuthState,
71
79
  openaiKey: this.openaiKey,
72
80
  visionModel: this.visionModel
73
81
  };
@@ -81,6 +89,10 @@ export class SessionManager {
81
89
  return this.allowList;
82
90
  }
83
91
 
92
+ getAllowedContact(number: string): Contact | undefined {
93
+ return this.allowList.find(c => c.number === number);
94
+ }
95
+
84
96
  getBlockList(): Contact[] {
85
97
  return this.blockList;
86
98
  }
@@ -106,12 +118,19 @@ export class SessionManager {
106
118
  return;
107
119
  }
108
120
 
109
- if (!this.allowList.find(c => c.number === cleanNumber)) {
121
+ const existing = this.allowList.find(c => c.number === cleanNumber);
122
+ if (!existing) {
110
123
  this.allowList.push({ number: cleanNumber, name });
111
124
  // Remove from blockList and ignoredNumbers if it was there
112
125
  this.blockList = this.blockList.filter(c => c.number !== cleanNumber);
113
126
  this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== cleanNumber);
114
127
  await this.saveConfig();
128
+ return;
129
+ }
130
+
131
+ if (name && !existing.name) {
132
+ existing.name = name;
133
+ await this.saveConfig();
115
134
  }
116
135
  }
117
136
 
@@ -120,8 +139,28 @@ export class SessionManager {
120
139
  await this.saveConfig();
121
140
  }
122
141
 
123
- async clearAllowList() {
124
- this.allowList = [];
142
+ async setAllowedContactAlias(number: string, alias: string) {
143
+ const trimmedAlias = alias.trim();
144
+ if (!trimmedAlias) {
145
+ return;
146
+ }
147
+
148
+ const contact = this.getAllowedContact(number);
149
+ if (!contact) {
150
+ return;
151
+ }
152
+
153
+ contact.name = trimmedAlias;
154
+ await this.saveConfig();
155
+ }
156
+
157
+ async removeAllowedContactAlias(number: string) {
158
+ const contact = this.getAllowedContact(number);
159
+ if (!contact || !contact.name) {
160
+ return;
161
+ }
162
+
163
+ delete contact.name;
125
164
  await this.saveConfig();
126
165
  }
127
166
 
@@ -168,25 +207,51 @@ export class SessionManager {
168
207
 
169
208
  public async isRegistered(): Promise<boolean> {
170
209
  try {
171
- const credsPah = join(this.authDir, 'creds.json');
210
+ const credsPah = join(this.authStateDir, 'creds.json');
172
211
  await readFile(credsPah);
212
+ this.hasAuthState = true;
173
213
  return true;
174
214
  } catch {
175
- return false;
215
+ await this.syncAuthStateFromDisk();
216
+ return this.hasAuthState;
217
+ }
218
+ }
219
+
220
+ async markAuthStateAvailable() {
221
+ if (!this.hasAuthState) {
222
+ this.hasAuthState = true;
223
+ await this.saveConfig();
176
224
  }
177
225
  }
178
226
 
179
227
  async getAuthState() {
180
- return await useMultiFileAuthState(this.authDir);
228
+ await this.ensureStorageDirectories();
229
+ return await useMultiFileAuthState(this.authStateDir);
230
+ }
231
+
232
+ private async syncAuthStateFromDisk() {
233
+ try {
234
+ const entries = await readdir(this.authStateDir);
235
+ if (entries.length > 0) {
236
+ if (!this.hasAuthState) {
237
+ this.hasAuthState = true;
238
+ await this.saveConfig();
239
+ }
240
+ }
241
+ } catch {
242
+ // Ignore missing directory / empty auth state
243
+ }
181
244
  }
182
245
 
183
- async clearSession() {
246
+ async deleteAuthState() {
184
247
  try {
185
- await rm(this.authDir, { recursive: true, force: true });
248
+ await rm(this.authStateDir, { recursive: true, force: true });
249
+ await mkdir(this.authStateDir, { recursive: true });
186
250
  this.status = 'logged-out';
251
+ this.hasAuthState = false;
187
252
  await this.saveConfig();
188
253
  } catch (error) {
189
- console.error('Failed to clear session:', error);
254
+ console.error('Failed to delete auth state:', error);
190
255
  }
191
256
  }
192
257
 
@@ -217,7 +282,7 @@ export class SessionManager {
217
282
  await this.saveConfig();
218
283
  }
219
284
 
220
- getAuthDir(): string {
221
- return this.authDir;
285
+ getAuthStateDir(): string {
286
+ return this.authStateDir;
222
287
  }
223
288
  }