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 +2 -3
- package/package.json +1 -1
- package/src/models/whatsapp.types.ts +26 -0
- package/src/services/recents.service.ts +203 -0
- package/src/services/session.manager.ts +80 -15
- package/src/services/whatsapp.service.ts +110 -28
- package/src/ui/menu.handler.ts +335 -38
- package/whatsapp-pi.ts +334 -109
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
|
|
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
|
-
- **
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
246
|
+
async deleteAuthState() {
|
|
184
247
|
try {
|
|
185
|
-
await rm(this.
|
|
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
|
|
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
|
-
|
|
221
|
-
return this.
|
|
285
|
+
getAuthStateDir(): string {
|
|
286
|
+
return this.authStateDir;
|
|
222
287
|
}
|
|
223
288
|
}
|