whatsapp-pi 1.0.23 → 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 +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 +72 -28
- package/src/ui/menu.handler.ts +335 -38
- package/whatsapp-pi.ts +294 -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
|
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import P from 'pino';
|
|
9
9
|
import { Boom } from '@hapi/boom';
|
|
10
10
|
import { SessionManager } from './session.manager.js';
|
|
11
|
-
import { WhatsAppSession, SessionStatus } from '../models/whatsapp.types.js';
|
|
11
|
+
import { IncomingMessage, WhatsAppSession, SessionStatus } from '../models/whatsapp.types.js';
|
|
12
12
|
import { MessageSender } from './message.sender.js';
|
|
13
13
|
|
|
14
14
|
export class WhatsAppService {
|
|
@@ -17,6 +17,8 @@ export class WhatsAppService {
|
|
|
17
17
|
private messageSender: MessageSender;
|
|
18
18
|
private isReconnecting = false;
|
|
19
19
|
private verboseMode = false;
|
|
20
|
+
private onIncomingMessageRecorded?: (message: IncomingMessage) => void | Promise<void>;
|
|
21
|
+
private saveCreds?: () => Promise<void>;
|
|
20
22
|
|
|
21
23
|
constructor(sessionManager: SessionManager) {
|
|
22
24
|
this.sessionManager = sessionManager;
|
|
@@ -27,6 +29,10 @@ export class WhatsAppService {
|
|
|
27
29
|
return this.sessionManager.getStatus();
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
public setIncomingMessageRecorder(callback: (message: IncomingMessage) => void | Promise<void>) {
|
|
33
|
+
this.onIncomingMessageRecorded = callback;
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
public getSocket(): any {
|
|
31
37
|
return this.socket;
|
|
32
38
|
}
|
|
@@ -39,11 +45,24 @@ export class WhatsAppService {
|
|
|
39
45
|
this.verboseMode = verbose;
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
private normalizeContactNumber(value: string): string {
|
|
49
|
+
if (value.startsWith('+')) {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (/^\d+$/.test(value)) {
|
|
54
|
+
return `+${value}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
async start() {
|
|
43
61
|
if (this.isReconnecting) return;
|
|
44
|
-
this.onStatusUpdate?.('WhatsApp: Connecting...');
|
|
62
|
+
this.onStatusUpdate?.('| WhatsApp: Connecting...');
|
|
45
63
|
|
|
46
64
|
const { state, saveCreds } = await this.sessionManager.getAuthState();
|
|
65
|
+
this.saveCreds = saveCreds;
|
|
47
66
|
const { version } = await fetchLatestBaileysVersion();
|
|
48
67
|
|
|
49
68
|
// Cleanup existing socket if any
|
|
@@ -68,7 +87,10 @@ export class WhatsAppService {
|
|
|
68
87
|
logger,
|
|
69
88
|
});
|
|
70
89
|
|
|
71
|
-
this.socket.ev.on('creds.update',
|
|
90
|
+
this.socket.ev.on('creds.update', async () => {
|
|
91
|
+
await saveCreds();
|
|
92
|
+
await this.sessionManager.markAuthStateAvailable();
|
|
93
|
+
});
|
|
72
94
|
|
|
73
95
|
this.socket.ev.on('connection.update', async (update: any) => {
|
|
74
96
|
const { connection, lastDisconnect, qr } = update;
|
|
@@ -76,54 +98,55 @@ export class WhatsAppService {
|
|
|
76
98
|
if (qr) {
|
|
77
99
|
this.sessionManager.setStatus('pairing');
|
|
78
100
|
this.onQRCode?.(qr);
|
|
79
|
-
this.onStatusUpdate?.('WhatsApp: Pairing...');
|
|
101
|
+
this.onStatusUpdate?.('| WhatsApp: Pairing...');
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
if (connection === 'close') {
|
|
83
105
|
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
|
84
106
|
const errorMessage = lastDisconnect?.error?.message || '';
|
|
85
107
|
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
|
108
|
+
const shouldTreatAsLoggedOut =
|
|
109
|
+
errorMessage.includes('bad-request') ||
|
|
110
|
+
statusCode === 400 ||
|
|
111
|
+
statusCode === 401 ||
|
|
112
|
+
statusCode === DisconnectReason.loggedOut ||
|
|
113
|
+
statusCode === DisconnectReason.badSession;
|
|
86
114
|
|
|
87
115
|
console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
|
|
88
116
|
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
statusCode === 400 ||
|
|
92
|
-
statusCode === 401 ||
|
|
93
|
-
statusCode === DisconnectReason.loggedOut ||
|
|
94
|
-
statusCode === DisconnectReason.badSession
|
|
95
|
-
) {
|
|
96
|
-
console.error(`Session invalid or logged out [${statusCode}] - clearing session and forcing re-auth`);
|
|
97
|
-
await this.sessionManager.clearSession();
|
|
117
|
+
if (shouldTreatAsLoggedOut) {
|
|
118
|
+
console.error(`Session invalid or logged out [${statusCode}] - preserving auth state and requiring re-auth`);
|
|
98
119
|
this.sessionManager.setStatus('logged-out');
|
|
99
|
-
this.onStatusUpdate?.('WhatsApp: Logged out');
|
|
120
|
+
this.onStatusUpdate?.('| WhatsApp: Logged out');
|
|
100
121
|
return;
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
if (statusCode === DisconnectReason.connectionReplaced) {
|
|
104
125
|
console.error('Connection replaced - another instance connected');
|
|
105
|
-
this.onStatusUpdate?.('WhatsApp: Conflict (Another Instance)');
|
|
126
|
+
this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
|
|
106
127
|
return;
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
if (shouldReconnect && !this.isReconnecting) {
|
|
110
131
|
this.isReconnecting = true;
|
|
111
|
-
this.onStatusUpdate?.('WhatsApp: Reconnecting...');
|
|
132
|
+
this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
|
|
112
133
|
setTimeout(() => {
|
|
113
134
|
this.isReconnecting = false;
|
|
114
135
|
this.start();
|
|
115
136
|
}, 3000);
|
|
116
137
|
} else if (!shouldReconnect) {
|
|
117
138
|
this.sessionManager.setStatus('logged-out');
|
|
118
|
-
this.onStatusUpdate?.('WhatsApp: Disconnected');
|
|
139
|
+
this.onStatusUpdate?.('| WhatsApp: Disconnected');
|
|
119
140
|
}
|
|
120
141
|
} else if (connection === 'open') {
|
|
121
142
|
if (this.verboseMode) {
|
|
122
143
|
console.log('WhatsApp connection successfully opened');
|
|
123
144
|
}
|
|
124
145
|
this.isReconnecting = false;
|
|
146
|
+
await this.saveCreds?.();
|
|
147
|
+
await this.sessionManager.markAuthStateAvailable();
|
|
125
148
|
this.sessionManager.setStatus('connected');
|
|
126
|
-
this.onStatusUpdate?.('WhatsApp: Connected');
|
|
149
|
+
this.onStatusUpdate?.('| WhatsApp: Connected');
|
|
127
150
|
}
|
|
128
151
|
});
|
|
129
152
|
|
|
@@ -140,25 +163,38 @@ export class WhatsAppService {
|
|
|
140
163
|
// Ignore messages sent by Pi (marked with π)
|
|
141
164
|
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || "";
|
|
142
165
|
if (text.endsWith('π')) return;
|
|
143
|
-
|
|
144
166
|
|
|
145
|
-
const
|
|
146
|
-
|
|
167
|
+
const remoteJid = msg.key.remoteJid;
|
|
168
|
+
if (remoteJid.endsWith('@g.us')) return;
|
|
169
|
+
|
|
170
|
+
const senderJid = this.normalizeContactNumber(remoteJid.split('@')[0]);
|
|
171
|
+
|
|
172
|
+
void Promise.resolve(this.onIncomingMessageRecorded?.({
|
|
173
|
+
id: msg.key.id,
|
|
174
|
+
remoteJid,
|
|
175
|
+
pushName: msg.pushName || undefined,
|
|
176
|
+
text,
|
|
177
|
+
timestamp: typeof msg.messageTimestamp === 'number' ? Number(msg.messageTimestamp) : Date.now()
|
|
178
|
+
})).catch(error => {
|
|
179
|
+
if (this.verboseMode) {
|
|
180
|
+
console.error('Failed to record recent message:', error);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
147
183
|
|
|
148
|
-
if (this.sessionManager.isBlocked(
|
|
184
|
+
if (this.sessionManager.isBlocked(senderJid)) {
|
|
149
185
|
if (this.isVerbose()) {
|
|
150
|
-
console.log(`Ignoring message from ${
|
|
186
|
+
console.log(`Ignoring message from ${senderJid} (explicitly blocked)`);
|
|
151
187
|
}
|
|
152
188
|
return;
|
|
153
189
|
}
|
|
154
190
|
|
|
155
|
-
if (!this.sessionManager.isAllowed(
|
|
191
|
+
if (!this.sessionManager.isAllowed(senderJid)) {
|
|
156
192
|
if (this.isVerbose()) {
|
|
157
|
-
console.log(`Ignoring message from ${
|
|
193
|
+
console.log(`Ignoring message from ${senderJid} (not in allow list)`);
|
|
158
194
|
}
|
|
159
195
|
// Track this number as ignored so user can allow it later
|
|
160
196
|
const pushName = msg.pushName || undefined;
|
|
161
|
-
await this.sessionManager.trackIgnoredNumber(
|
|
197
|
+
await this.sessionManager.trackIgnoredNumber(senderJid, pushName);
|
|
162
198
|
return;
|
|
163
199
|
}
|
|
164
200
|
|
|
@@ -230,10 +266,18 @@ export class WhatsAppService {
|
|
|
230
266
|
|
|
231
267
|
async logout() {
|
|
232
268
|
await this.socket?.logout();
|
|
233
|
-
await this.sessionManager.
|
|
269
|
+
await this.sessionManager.deleteAuthState();
|
|
234
270
|
}
|
|
235
271
|
|
|
236
272
|
async stop() {
|
|
273
|
+
try {
|
|
274
|
+
await this.saveCreds?.();
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (this.verboseMode) {
|
|
277
|
+
console.error('Failed to persist auth state during stop:', error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
237
281
|
if (this.socket) {
|
|
238
282
|
this.socket.ev.removeAllListeners('connection.update');
|
|
239
283
|
this.socket.ev.removeAllListeners('creds.update');
|
|
@@ -245,6 +289,6 @@ export class WhatsAppService {
|
|
|
245
289
|
this.isReconnecting = false;
|
|
246
290
|
}
|
|
247
291
|
await this.sessionManager.setStatus('disconnected');
|
|
248
|
-
this.onStatusUpdate?.('WhatsApp: Disconnected');
|
|
292
|
+
this.onStatusUpdate?.('| WhatsApp: Disconnected');
|
|
249
293
|
}
|
|
250
294
|
}
|