whatsapp-pi 1.0.23 → 1.0.25

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
@@ -79,7 +79,7 @@ pi -e whatsapp-pi.ts --verbose
79
79
  - `/whatsapp` - Open the WhatsApp management menu
80
80
 
81
81
  ### Main Menu Options
82
- - **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
83
83
  - **Disconnect WhatsApp** - Stop WhatsApp connection
84
84
  - **Logoff (Delete Session)** - Remove all credentials and session data
85
85
  - **Allowed Numbers** - Manage contacts that can interact with Pi
@@ -87,8 +87,7 @@ pi -e whatsapp-pi.ts --verbose
87
87
 
88
88
  ### Allowed Numbers Management
89
89
  - **Add Number** - Add a new contact to the allow list (format: +5511999999999)
90
- - **Remove [Number]** - Remove a specific contact from the allow list
91
- - **Clear All** - Remove all allowed numbers
90
+ - **Select a contact** - Open a submenu with **Send Message**, **Remove Number**, and **Back**
92
91
  - **Back** - Return to main menu
93
92
 
94
93
  ### Blocked Numbers Management
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -52,3 +52,29 @@ export interface DocumentMetadata {
52
52
  savedPath: string;
53
53
  timestamp: number;
54
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
+ }
@@ -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
  }