whatsapp-pi 1.0.40 → 1.0.42
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/package.json +1 -1
- package/src/models/whatsapp.types.ts +22 -0
- package/src/services/message.sender.ts +6 -6
- package/src/services/recents.service.ts +23 -11
- package/src/services/whatsapp-pi.logger.ts +4 -0
- package/src/services/whatsapp.service.ts +347 -183
- package/src/ui/menu.handler.ts +79 -17
- package/src/ui/message-detail.view.ts +119 -0
- package/src/ui/message-reply.view.ts +105 -0
- package/whatsapp-pi.ts +25 -0
package/package.json
CHANGED
|
@@ -73,6 +73,28 @@ export interface RecentConversationSummary {
|
|
|
73
73
|
isAllowed: boolean;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
export interface SelectedMessageContext {
|
|
77
|
+
messageId: string;
|
|
78
|
+
senderNumber: string;
|
|
79
|
+
senderName?: string;
|
|
80
|
+
text: string;
|
|
81
|
+
direction: MessageDirection;
|
|
82
|
+
timestamp: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ReplyDraft {
|
|
86
|
+
text: string;
|
|
87
|
+
targetMessageId: string;
|
|
88
|
+
targetConversation: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ReplySendResult {
|
|
92
|
+
success: boolean;
|
|
93
|
+
messageId?: string;
|
|
94
|
+
error?: string;
|
|
95
|
+
attempts: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
export interface RecentsStore {
|
|
77
99
|
conversations: RecentConversationSummary[];
|
|
78
100
|
messagesBySender: Record<string, RecentConversationMessage[]>;
|
|
@@ -39,7 +39,7 @@ export class MessageSender {
|
|
|
39
39
|
public async send(request: MessageRequest): Promise<MessageResult> {
|
|
40
40
|
const maxRetries = request.options?.maxRetries ?? 3;
|
|
41
41
|
let attempts = 0;
|
|
42
|
-
let lastError:
|
|
42
|
+
let lastError: unknown = null;
|
|
43
43
|
|
|
44
44
|
while (attempts < maxRetries) {
|
|
45
45
|
attempts++;
|
|
@@ -61,15 +61,15 @@ export class MessageSender {
|
|
|
61
61
|
|
|
62
62
|
return {
|
|
63
63
|
success: true,
|
|
64
|
-
messageId: response
|
|
64
|
+
messageId: response?.key?.id,
|
|
65
65
|
attempts
|
|
66
66
|
};
|
|
67
|
-
} catch (error:
|
|
67
|
+
} catch (error: unknown) {
|
|
68
68
|
lastError = error;
|
|
69
|
-
console.error(`[MessageSender] Attempt ${attempts} failed for ${request.recipientJid}: ${error.message}`);
|
|
69
|
+
console.error(`[MessageSender] Attempt ${attempts} failed for ${request.recipientJid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
70
|
|
|
71
71
|
// Specific handling for non-retryable errors
|
|
72
|
-
if (error.code === 'TIMEOUT') {
|
|
72
|
+
if (error instanceof WhatsAppError && error.code === 'TIMEOUT') {
|
|
73
73
|
break;
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -86,7 +86,7 @@ export class MessageSender {
|
|
|
86
86
|
|
|
87
87
|
return {
|
|
88
88
|
success: false,
|
|
89
|
-
error: lastError
|
|
89
|
+
error: lastError instanceof Error ? lastError.message : 'Unknown error',
|
|
90
90
|
attempts
|
|
91
91
|
};
|
|
92
92
|
}
|
|
@@ -93,24 +93,38 @@ export class RecentsService {
|
|
|
93
93
|
const summaries = new Map<string, RecentConversationSummary>();
|
|
94
94
|
|
|
95
95
|
for (const [senderNumber, messages] of Object.entries(this.store.messagesBySender)) {
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
const latestMessage = this.getLatestConversationMessage(messages);
|
|
97
|
+
if (!latestMessage) continue;
|
|
98
|
+
|
|
98
99
|
summaries.set(senderNumber, {
|
|
99
100
|
senderNumber,
|
|
100
101
|
senderName: previousNames.get(senderNumber),
|
|
101
|
-
lastMessagePreview: this.buildPreview(
|
|
102
|
-
lastMessageTime:
|
|
103
|
-
lastMessageDirection:
|
|
102
|
+
lastMessagePreview: this.buildPreview(latestMessage.text),
|
|
103
|
+
lastMessageTime: latestMessage.timestamp,
|
|
104
|
+
lastMessageDirection: latestMessage.direction,
|
|
104
105
|
messageCount: messages.length,
|
|
105
106
|
isAllowed: this.sessionManager.isAllowed(senderNumber)
|
|
106
107
|
});
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
this.store.conversations = Array.from(summaries.values())
|
|
110
|
-
.sort((left, right) => right.lastMessageTime - left.lastMessageTime)
|
|
110
|
+
this.store.conversations = this.sortConversationsByLatestMessage(Array.from(summaries.values()))
|
|
111
111
|
.slice(0, 20);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
private getLatestConversationMessage(messages: RecentConversationMessage[]): RecentConversationMessage | undefined {
|
|
115
|
+
return messages[messages.length - 1];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private sortConversationsByLatestMessage(conversations: RecentConversationSummary[]): RecentConversationSummary[] {
|
|
119
|
+
return [...conversations].sort((left, right) => {
|
|
120
|
+
if (right.lastMessageTime !== left.lastMessageTime) {
|
|
121
|
+
return right.lastMessageTime - left.lastMessageTime;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return left.senderNumber.localeCompare(right.senderNumber);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
private buildPreview(text: string): string {
|
|
115
129
|
const normalized = text.trim().replace(/\s+/g, ' ');
|
|
116
130
|
if (normalized.length <= 80) return normalized;
|
|
@@ -172,12 +186,10 @@ export class RecentsService {
|
|
|
172
186
|
isAllowed: this.sessionManager.isAllowed(senderNumber)
|
|
173
187
|
};
|
|
174
188
|
|
|
175
|
-
this.store.conversations = [
|
|
189
|
+
this.store.conversations = this.sortConversationsByLatestMessage([
|
|
176
190
|
summary,
|
|
177
191
|
...this.store.conversations.filter(item => item.senderNumber !== senderNumber)
|
|
178
|
-
]
|
|
179
|
-
.sort((left, right) => right.lastMessageTime - left.lastMessageTime)
|
|
180
|
-
.slice(0, 20);
|
|
192
|
+
]).slice(0, 20);
|
|
181
193
|
|
|
182
194
|
await this.persistStore();
|
|
183
195
|
}
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
2
|
makeWASocket,
|
|
3
|
-
DisconnectReason,
|
|
4
|
-
useMultiFileAuthState,
|
|
3
|
+
DisconnectReason,
|
|
5
4
|
fetchLatestBaileysVersion,
|
|
6
5
|
makeCacheableSignalKeyStore
|
|
7
6
|
} from '@whiskeysockets/baileys';
|
|
8
7
|
import P from 'pino';
|
|
9
|
-
import { Boom } from '@hapi/boom';
|
|
10
8
|
import { SessionManager } from './session.manager.js';
|
|
11
|
-
import { IncomingMessage,
|
|
9
|
+
import { IncomingMessage, SessionStatus } from '../models/whatsapp.types.js';
|
|
12
10
|
import { MessageSender } from './message.sender.js';
|
|
13
11
|
import { installBaileysConsoleFilter } from './baileys-console-filter.js';
|
|
14
12
|
|
|
@@ -16,8 +14,65 @@ export interface WhatsAppStartOptions {
|
|
|
16
14
|
allowPairingOnAuthFailure?: boolean;
|
|
17
15
|
}
|
|
18
16
|
|
|
17
|
+
interface DisconnectPayload {
|
|
18
|
+
error?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ConnectionUpdateEvent {
|
|
22
|
+
connection?: 'close' | 'open' | string;
|
|
23
|
+
lastDisconnect?: DisconnectPayload;
|
|
24
|
+
qr?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface IncomingMessageKey {
|
|
28
|
+
id?: string;
|
|
29
|
+
remoteJid?: string;
|
|
30
|
+
fromMe?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface IncomingMessageContent {
|
|
34
|
+
conversation?: string;
|
|
35
|
+
extendedTextMessage?: { text?: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface IncomingMessageLike {
|
|
39
|
+
key: IncomingMessageKey;
|
|
40
|
+
message?: IncomingMessageContent;
|
|
41
|
+
pushName?: string;
|
|
42
|
+
messageTimestamp?: number | string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface MessagesUpsertEvent {
|
|
46
|
+
messages?: IncomingMessageLike[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface WhatsAppSocketLike {
|
|
50
|
+
ev: {
|
|
51
|
+
on(event: 'connection.update', handler: (update: ConnectionUpdateEvent) => void | Promise<void>): void;
|
|
52
|
+
on(event: 'creds.update', handler: () => void | Promise<void>): void;
|
|
53
|
+
on(event: 'messages.upsert', handler: (payload: MessagesUpsertEvent) => void | Promise<void>): void;
|
|
54
|
+
removeAllListeners(event: 'connection.update' | 'creds.update' | 'messages.upsert'): void;
|
|
55
|
+
};
|
|
56
|
+
end(reason?: unknown): void;
|
|
57
|
+
logout(): Promise<void>;
|
|
58
|
+
sendMessage(jid: string, content: { text: string }): Promise<{ key?: { id?: string } } | undefined>;
|
|
59
|
+
sendPresenceUpdate(presence: 'composing' | 'recording' | 'paused', jid: string): Promise<void>;
|
|
60
|
+
readMessages(messages: Array<{ remoteJid: string; id: string; fromMe: boolean }>): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface LastDisconnectLike {
|
|
64
|
+
error?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface BoomLikeError {
|
|
68
|
+
output?: {
|
|
69
|
+
statusCode?: number;
|
|
70
|
+
};
|
|
71
|
+
message?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
19
74
|
export class WhatsAppService {
|
|
20
|
-
private socket
|
|
75
|
+
private socket?: WhatsAppSocketLike;
|
|
21
76
|
private sessionManager: SessionManager;
|
|
22
77
|
private messageSender: MessageSender;
|
|
23
78
|
private isReconnecting = false;
|
|
@@ -25,6 +80,11 @@ export class WhatsAppService {
|
|
|
25
80
|
private onIncomingMessageRecorded?: (message: IncomingMessage) => void | Promise<void>;
|
|
26
81
|
private saveCreds?: () => Promise<void>;
|
|
27
82
|
private restoreBaileysConsoleFilter?: () => void;
|
|
83
|
+
private reconnectTimeout?: ReturnType<typeof setTimeout>;
|
|
84
|
+
private onQRCode?: (qr: string) => void;
|
|
85
|
+
private onMessage?: (m: unknown) => void;
|
|
86
|
+
private onStatusUpdate?: (status: string) => void;
|
|
87
|
+
private lastRemoteJid: string | null = null;
|
|
28
88
|
|
|
29
89
|
constructor(sessionManager: SessionManager) {
|
|
30
90
|
this.sessionManager = sessionManager;
|
|
@@ -48,7 +108,7 @@ export class WhatsAppService {
|
|
|
48
108
|
this.onIncomingMessageRecorded = callback;
|
|
49
109
|
}
|
|
50
110
|
|
|
51
|
-
public getSocket():
|
|
111
|
+
public getSocket(): WhatsAppSocketLike | undefined {
|
|
52
112
|
return this.socket;
|
|
53
113
|
}
|
|
54
114
|
|
|
@@ -76,196 +136,311 @@ export class WhatsAppService {
|
|
|
76
136
|
return value;
|
|
77
137
|
}
|
|
78
138
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
139
|
+
private normalizeRecipientJid(jid: string): string {
|
|
140
|
+
if (jid.includes('@')) return jid;
|
|
141
|
+
const digits = jid.startsWith('+') ? jid.slice(1) : jid;
|
|
142
|
+
return `${digits}@s.whatsapp.net`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private getDisconnectStatusCode(error: unknown): number | undefined {
|
|
146
|
+
if (!error || typeof error !== 'object') {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
83
149
|
|
|
150
|
+
const candidate = error as BoomLikeError;
|
|
151
|
+
return candidate.output?.statusCode;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private getErrorMessage(error: unknown): string {
|
|
155
|
+
if (error instanceof Error) {
|
|
156
|
+
return error.message;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
160
|
+
const candidate = error as { message?: unknown };
|
|
161
|
+
return typeof candidate.message === 'string' ? candidate.message : '';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private clearReconnectTimeout() {
|
|
168
|
+
if (this.reconnectTimeout) {
|
|
169
|
+
clearTimeout(this.reconnectTimeout);
|
|
170
|
+
this.reconnectTimeout = undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private cleanupSocket() {
|
|
175
|
+
this.clearReconnectTimeout();
|
|
176
|
+
|
|
177
|
+
if (!this.socket) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.restoreBaileysConsoleFilter?.();
|
|
182
|
+
this.restoreBaileysConsoleFilter = undefined;
|
|
183
|
+
this.socket.ev.removeAllListeners('connection.update');
|
|
184
|
+
this.socket.ev.removeAllListeners('creds.update');
|
|
185
|
+
this.socket.ev.removeAllListeners('messages.upsert');
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
this.socket.end(undefined);
|
|
189
|
+
} catch {
|
|
190
|
+
// Best-effort cleanup
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.socket = undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private setSocket(socket: WhatsAppSocketLike) {
|
|
197
|
+
this.socket = socket;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private registerSocketListeners(socket: WhatsAppSocketLike, options: WhatsAppStartOptions, saveCreds: () => Promise<void>) {
|
|
201
|
+
socket.ev.on('creds.update', async () => {
|
|
202
|
+
await saveCreds();
|
|
203
|
+
await this.sessionManager.markAuthStateAvailable();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
socket.ev.on('connection.update', async (update) => {
|
|
207
|
+
await this.handleConnectionUpdate(update, options);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
socket.ev.on('messages.upsert', (payload) => {
|
|
211
|
+
void this.handleIncomingMessages(payload);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async createSocket(): Promise<WhatsAppSocketLike> {
|
|
84
216
|
const { state, saveCreds } = await this.sessionManager.getAuthState();
|
|
85
217
|
this.saveCreds = saveCreds;
|
|
86
218
|
const { version } = await fetchLatestBaileysVersion();
|
|
87
219
|
|
|
88
|
-
// Cleanup existing socket if any
|
|
89
|
-
if (this.socket) {
|
|
90
|
-
this.restoreBaileysConsoleFilter?.();
|
|
91
|
-
this.restoreBaileysConsoleFilter = undefined;
|
|
92
|
-
this.socket.ev.removeAllListeners('connection.update');
|
|
93
|
-
this.socket.ev.removeAllListeners('creds.update');
|
|
94
|
-
this.socket.ev.removeAllListeners('messages.upsert');
|
|
95
|
-
try {
|
|
96
|
-
this.socket.end(undefined);
|
|
97
|
-
} catch (e) {}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
220
|
const logger = P({ level: this.verboseMode ? 'trace' : 'silent' });
|
|
101
|
-
|
|
102
|
-
|
|
221
|
+
|
|
222
|
+
const socket = makeWASocket({
|
|
223
|
+
version,
|
|
224
|
+
printQRInTerminal: false,
|
|
225
|
+
auth: {
|
|
226
|
+
creds: state.creds,
|
|
227
|
+
keys: makeCacheableSignalKeyStore(state.keys, logger)
|
|
228
|
+
},
|
|
229
|
+
syncFullHistory: false,
|
|
230
|
+
logger
|
|
231
|
+
}) as WhatsAppSocketLike;
|
|
232
|
+
|
|
233
|
+
return socket;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async start(options: WhatsAppStartOptions = {}) {
|
|
237
|
+
if (this.isReconnecting) return;
|
|
238
|
+
this.onStatusUpdate?.('| WhatsApp: Connecting...');
|
|
239
|
+
|
|
240
|
+
this.cleanupSocket();
|
|
241
|
+
|
|
103
242
|
const originalConsoleLog = console.log;
|
|
104
243
|
const originalConsoleWarn = console.warn;
|
|
105
244
|
const originalConsoleError = console.error;
|
|
106
|
-
|
|
245
|
+
let socketInitialized = false;
|
|
246
|
+
|
|
107
247
|
if (!this.verboseMode) {
|
|
108
248
|
console.log = () => {};
|
|
109
249
|
console.warn = () => {};
|
|
110
250
|
console.error = () => {};
|
|
111
251
|
}
|
|
112
|
-
|
|
252
|
+
|
|
113
253
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
creds: state.creds,
|
|
119
|
-
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
|
120
|
-
},
|
|
121
|
-
syncFullHistory: false,
|
|
122
|
-
logger,
|
|
123
|
-
});
|
|
254
|
+
const socket = await this.createSocket();
|
|
255
|
+
this.setSocket(socket);
|
|
256
|
+
this.registerSocketListeners(socket, options, this.saveCreds ?? (async () => {}));
|
|
257
|
+
socketInitialized = true;
|
|
124
258
|
} catch (error) {
|
|
125
|
-
// Restore console methods even if socket creation fails
|
|
126
259
|
if (!this.verboseMode) {
|
|
127
260
|
console.log = originalConsoleLog;
|
|
128
261
|
console.warn = originalConsoleWarn;
|
|
129
262
|
console.error = originalConsoleError;
|
|
130
263
|
}
|
|
131
264
|
throw error;
|
|
265
|
+
} finally {
|
|
266
|
+
if (!this.verboseMode) {
|
|
267
|
+
console.log = originalConsoleLog;
|
|
268
|
+
console.warn = originalConsoleWarn;
|
|
269
|
+
console.error = originalConsoleError;
|
|
270
|
+
if (socketInitialized) {
|
|
271
|
+
this.restoreBaileysConsoleFilter = installBaileysConsoleFilter(this.verboseMode);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
132
274
|
}
|
|
275
|
+
}
|
|
133
276
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
277
|
+
private async handleConnectionUpdate(update: ConnectionUpdateEvent, options: WhatsAppStartOptions) {
|
|
278
|
+
const { connection, lastDisconnect, qr } = update;
|
|
279
|
+
const allowPairingOnAuthFailure = options.allowPairingOnAuthFailure ?? true;
|
|
138
280
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (qr) {
|
|
143
|
-
this.sessionManager.setStatus('pairing');
|
|
144
|
-
this.onQRCode?.(qr);
|
|
145
|
-
this.onStatusUpdate?.('| WhatsApp: type /whatsapp to connect');
|
|
146
|
-
}
|
|
281
|
+
if (qr) {
|
|
282
|
+
await this.handlePairingQr(qr);
|
|
283
|
+
}
|
|
147
284
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const isBadMac = errorMessage.includes('Bad MAC');
|
|
153
|
-
const isAuthRejected =
|
|
154
|
-
errorMessage.includes('bad-request') ||
|
|
155
|
-
statusCode === 400 ||
|
|
156
|
-
statusCode === 401 ||
|
|
157
|
-
statusCode === DisconnectReason.loggedOut ||
|
|
158
|
-
statusCode === DisconnectReason.badSession;
|
|
159
|
-
const shouldTreatAsLoggedOut = isBadMac || isAuthRejected;
|
|
285
|
+
if (connection === 'close') {
|
|
286
|
+
await this.handleConnectionClosed(lastDisconnect, allowPairingOnAuthFailure, options);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
160
289
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
290
|
+
if (connection === 'open') {
|
|
291
|
+
await this.handleConnectionOpen();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
164
294
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await this.sessionManager.deleteAuthState();
|
|
171
|
-
this.socket.ev.removeAllListeners('connection.update');
|
|
172
|
-
this.socket.ev.removeAllListeners('creds.update');
|
|
173
|
-
this.socket.ev.removeAllListeners('messages.upsert');
|
|
174
|
-
try {
|
|
175
|
-
this.socket.end(undefined);
|
|
176
|
-
} catch (e) {}
|
|
177
|
-
this.socket = undefined;
|
|
178
|
-
await this.start({ allowPairingOnAuthFailure: false });
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (this.verboseMode) {
|
|
183
|
-
console.error(`Session invalid or logged out [${statusCode}] - preserving auth state and requiring re-auth`);
|
|
184
|
-
}
|
|
185
|
-
if (isBadMac) {
|
|
186
|
-
if (this.verboseMode) {
|
|
187
|
-
console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
|
|
188
|
-
console.error('[WhatsApp-Pi] Run /whatsapp-logout to clear auth state, then reconnect with /whatsapp-connect');
|
|
189
|
-
}
|
|
190
|
-
this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
|
|
191
|
-
}
|
|
192
|
-
this.sessionManager.setStatus('logged-out');
|
|
193
|
-
if (!isBadMac) {
|
|
194
|
-
this.onStatusUpdate?.('| WhatsApp: Logged out');
|
|
195
|
-
}
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
295
|
+
private async handlePairingQr(qr: string) {
|
|
296
|
+
this.sessionManager.setStatus('pairing');
|
|
297
|
+
this.onQRCode?.(qr);
|
|
298
|
+
this.onStatusUpdate?.('| WhatsApp: type /whatsapp to connect');
|
|
299
|
+
}
|
|
198
300
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
301
|
+
private async handleConnectionOpen() {
|
|
302
|
+
if (this.verboseMode) {
|
|
303
|
+
console.log('WhatsApp connection successfully opened');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this.isReconnecting = false;
|
|
307
|
+
this.clearReconnectTimeout();
|
|
308
|
+
await this.saveCreds?.();
|
|
309
|
+
await this.sessionManager.markAuthStateAvailable();
|
|
310
|
+
this.sessionManager.setStatus('connected');
|
|
311
|
+
this.onStatusUpdate?.('| WhatsApp: Connected');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private isBadMacError(errorMessage: string): boolean {
|
|
315
|
+
return errorMessage.includes('Bad MAC');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private isAuthRejected(statusCode: number | undefined, errorMessage: string): boolean {
|
|
319
|
+
return errorMessage.includes('bad-request')
|
|
320
|
+
|| statusCode === 400
|
|
321
|
+
|| statusCode === 401
|
|
322
|
+
|| statusCode === DisconnectReason.loggedOut
|
|
323
|
+
|| statusCode === DisconnectReason.badSession;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async handleConnectionClosed(
|
|
327
|
+
lastDisconnect: LastDisconnectLike | undefined,
|
|
328
|
+
allowPairingOnAuthFailure: boolean,
|
|
329
|
+
options: WhatsAppStartOptions
|
|
330
|
+
) {
|
|
331
|
+
const statusCode = this.getDisconnectStatusCode(lastDisconnect?.error);
|
|
332
|
+
const errorMessage = this.getErrorMessage(lastDisconnect?.error);
|
|
333
|
+
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
|
334
|
+
const isBadMac = this.isBadMacError(errorMessage);
|
|
335
|
+
const isAuthRejected = this.isAuthRejected(statusCode, errorMessage);
|
|
336
|
+
const shouldTreatAsLoggedOut = isBadMac || isAuthRejected;
|
|
337
|
+
|
|
338
|
+
if (this.verboseMode) {
|
|
339
|
+
console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (shouldTreatAsLoggedOut) {
|
|
343
|
+
if (isAuthRejected && !isBadMac && allowPairingOnAuthFailure) {
|
|
219
344
|
if (this.verboseMode) {
|
|
220
|
-
console.
|
|
345
|
+
console.error(`Session rejected [${statusCode}] - clearing auth state and starting pairing`);
|
|
221
346
|
}
|
|
347
|
+
await this.sessionManager.deleteAuthState();
|
|
348
|
+
this.cleanupSocket();
|
|
349
|
+
this.socket = undefined;
|
|
222
350
|
this.isReconnecting = false;
|
|
223
|
-
await this.
|
|
224
|
-
|
|
225
|
-
this.sessionManager.setStatus('connected');
|
|
226
|
-
this.onStatusUpdate?.('| WhatsApp: Connected');
|
|
351
|
+
await this.start({ allowPairingOnAuthFailure: false });
|
|
352
|
+
return;
|
|
227
353
|
}
|
|
228
|
-
});
|
|
229
354
|
|
|
230
|
-
|
|
355
|
+
if (this.verboseMode) {
|
|
356
|
+
console.error(`Session invalid or logged out [${statusCode}] - preserving auth state and requiring re-auth`);
|
|
357
|
+
}
|
|
358
|
+
if (isBadMac) {
|
|
359
|
+
if (this.verboseMode) {
|
|
360
|
+
console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
|
|
361
|
+
console.error('[WhatsApp-Pi] Run /whatsapp-logout to clear auth state, then reconnect with /whatsapp-connect');
|
|
362
|
+
}
|
|
363
|
+
this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
|
|
364
|
+
}
|
|
365
|
+
this.sessionManager.setStatus('logged-out');
|
|
366
|
+
if (!isBadMac) {
|
|
367
|
+
this.onStatusUpdate?.('| WhatsApp: Logged out');
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
231
371
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
372
|
+
if (statusCode === DisconnectReason.connectionReplaced) {
|
|
373
|
+
if (this.verboseMode) {
|
|
374
|
+
console.error('Connection replaced - another instance connected');
|
|
375
|
+
}
|
|
376
|
+
this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (shouldReconnect && !this.isReconnecting) {
|
|
381
|
+
this.isReconnecting = true;
|
|
382
|
+
this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
|
|
383
|
+
this.clearReconnectTimeout();
|
|
384
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
385
|
+
this.isReconnecting = false;
|
|
386
|
+
void this.start(options);
|
|
387
|
+
}, 3000);
|
|
388
|
+
} else if (!shouldReconnect) {
|
|
389
|
+
this.sessionManager.setStatus('logged-out');
|
|
390
|
+
this.onStatusUpdate?.('| WhatsApp: Disconnected');
|
|
238
391
|
}
|
|
239
392
|
}
|
|
240
393
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
394
|
+
private extractText(message: IncomingMessageContent | undefined): string {
|
|
395
|
+
return message?.conversation || message?.extendedTextMessage?.text || '';
|
|
396
|
+
}
|
|
244
397
|
|
|
245
|
-
|
|
246
|
-
|
|
398
|
+
private isPiGeneratedMessage(text: string): boolean {
|
|
399
|
+
return text.endsWith('π');
|
|
400
|
+
}
|
|
247
401
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
402
|
+
private getIncomingTimestamp(timestamp: number | string | undefined): number {
|
|
403
|
+
if (typeof timestamp === 'number') {
|
|
404
|
+
return timestamp;
|
|
405
|
+
}
|
|
251
406
|
|
|
252
|
-
|
|
253
|
-
|
|
407
|
+
if (typeof timestamp === 'string') {
|
|
408
|
+
const parsed = Number(timestamp);
|
|
409
|
+
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
410
|
+
}
|
|
254
411
|
|
|
255
|
-
|
|
412
|
+
return Date.now();
|
|
413
|
+
}
|
|
256
414
|
|
|
415
|
+
private async recordIncomingMessage(message: IncomingMessageLike, remoteJid: string, text: string) {
|
|
257
416
|
void Promise.resolve(this.onIncomingMessageRecorded?.({
|
|
258
|
-
id:
|
|
417
|
+
id: message.key.id ?? remoteJid,
|
|
259
418
|
remoteJid,
|
|
260
|
-
pushName:
|
|
419
|
+
pushName: message.pushName || undefined,
|
|
261
420
|
text,
|
|
262
|
-
timestamp:
|
|
421
|
+
timestamp: this.getIncomingTimestamp(message.messageTimestamp)
|
|
263
422
|
})).catch(error => {
|
|
264
423
|
if (this.verboseMode) {
|
|
265
424
|
console.error('Failed to record recent message:', error);
|
|
266
425
|
}
|
|
267
426
|
});
|
|
268
|
-
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
public async handleIncomingMessages(payload: MessagesUpsertEvent) {
|
|
430
|
+
if (this.sessionManager.getStatus() !== 'connected') return;
|
|
431
|
+
|
|
432
|
+
const message = payload.messages?.[0];
|
|
433
|
+
if (!message || !message.key.remoteJid) return;
|
|
434
|
+
|
|
435
|
+
const text = this.extractText(message.message);
|
|
436
|
+
if (this.isPiGeneratedMessage(text)) return;
|
|
437
|
+
|
|
438
|
+
const remoteJid = message.key.remoteJid;
|
|
439
|
+
if (remoteJid.endsWith('@g.us')) return;
|
|
440
|
+
|
|
441
|
+
const senderJid = this.normalizeContactNumber(remoteJid.split('@')[0]);
|
|
442
|
+
void this.recordIncomingMessage(message, remoteJid, text);
|
|
443
|
+
|
|
269
444
|
if (this.sessionManager.isBlocked(senderJid)) {
|
|
270
445
|
if (this.isVerbose()) {
|
|
271
446
|
console.log(`Ignoring message from ${senderJid} (explicitly blocked)`);
|
|
@@ -277,26 +452,20 @@ export class WhatsAppService {
|
|
|
277
452
|
if (this.isVerbose()) {
|
|
278
453
|
console.log(`Ignoring message from ${senderJid} (not in allow list)`);
|
|
279
454
|
}
|
|
280
|
-
|
|
281
|
-
const pushName = msg.pushName || undefined;
|
|
455
|
+
const pushName = message.pushName || undefined;
|
|
282
456
|
await this.sessionManager.trackIgnoredNumber(senderJid, pushName);
|
|
283
457
|
return;
|
|
284
458
|
}
|
|
285
459
|
|
|
286
|
-
this.lastRemoteJid =
|
|
287
|
-
this.onMessage?.(
|
|
460
|
+
this.lastRemoteJid = remoteJid;
|
|
461
|
+
this.onMessage?.(payload);
|
|
288
462
|
}
|
|
289
463
|
|
|
290
|
-
private onQRCode?: (qr: string) => void;
|
|
291
|
-
private onMessage?: (m: any) => void;
|
|
292
|
-
private onStatusUpdate?: (status: string) => void;
|
|
293
|
-
private lastRemoteJid: string | null = null;
|
|
294
|
-
|
|
295
464
|
setQRCodeCallback(callback: (qr: string) => void) {
|
|
296
465
|
this.onQRCode = callback;
|
|
297
466
|
}
|
|
298
467
|
|
|
299
|
-
setMessageCallback(callback: (m:
|
|
468
|
+
setMessageCallback(callback: (m: unknown) => void) {
|
|
300
469
|
this.onMessage = callback;
|
|
301
470
|
}
|
|
302
471
|
|
|
@@ -308,6 +477,14 @@ export class WhatsAppService {
|
|
|
308
477
|
return this.lastRemoteJid;
|
|
309
478
|
}
|
|
310
479
|
|
|
480
|
+
private getActiveSocket(): WhatsAppSocketLike | null {
|
|
481
|
+
if (!this.socket || this.getStatus() !== 'connected') {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return this.socket;
|
|
486
|
+
}
|
|
487
|
+
|
|
311
488
|
async sendMessage(jid: string, text: string) {
|
|
312
489
|
// Ensure we show the typing indicator before sending
|
|
313
490
|
await this.sendPresence(jid, 'composing');
|
|
@@ -323,20 +500,15 @@ export class WhatsAppService {
|
|
|
323
500
|
if (!result.success) {
|
|
324
501
|
console.error(`Failed to send message to ${jid}: ${result.error}`);
|
|
325
502
|
}
|
|
326
|
-
|
|
327
|
-
return result;
|
|
328
|
-
}
|
|
329
503
|
|
|
330
|
-
|
|
331
|
-
if (jid.includes('@')) return jid;
|
|
332
|
-
const digits = jid.startsWith('+') ? jid.slice(1) : jid;
|
|
333
|
-
return `${digits}@s.whatsapp.net`;
|
|
504
|
+
return result;
|
|
334
505
|
}
|
|
335
506
|
|
|
336
507
|
async sendMenuMessage(jid: string, text: string) {
|
|
337
508
|
const normalizedJid = this.normalizeRecipientJid(jid);
|
|
509
|
+
const socket = this.getActiveSocket();
|
|
338
510
|
|
|
339
|
-
if (!
|
|
511
|
+
if (!socket) {
|
|
340
512
|
return {
|
|
341
513
|
success: false,
|
|
342
514
|
error: 'WhatsApp is not connected',
|
|
@@ -346,7 +518,7 @@ export class WhatsAppService {
|
|
|
346
518
|
|
|
347
519
|
try {
|
|
348
520
|
await this.sendPresence(normalizedJid, 'composing');
|
|
349
|
-
const response = await
|
|
521
|
+
const response = await socket.sendMessage(normalizedJid, { text });
|
|
350
522
|
await this.sendPresence(normalizedJid, 'paused');
|
|
351
523
|
|
|
352
524
|
return {
|
|
@@ -354,21 +526,22 @@ export class WhatsAppService {
|
|
|
354
526
|
messageId: response?.key?.id,
|
|
355
527
|
attempts: 1
|
|
356
528
|
};
|
|
357
|
-
} catch (error:
|
|
529
|
+
} catch (error: unknown) {
|
|
358
530
|
await this.sendPresence(normalizedJid, 'paused');
|
|
359
531
|
console.error(`Failed to send menu message to ${normalizedJid}:`, error);
|
|
360
532
|
return {
|
|
361
533
|
success: false,
|
|
362
|
-
error: error
|
|
534
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
363
535
|
attempts: 1
|
|
364
536
|
};
|
|
365
537
|
}
|
|
366
538
|
}
|
|
367
539
|
|
|
368
540
|
async sendPresence(jid: string, presence: 'composing' | 'recording' | 'paused') {
|
|
369
|
-
|
|
541
|
+
const socket = this.getActiveSocket();
|
|
542
|
+
if (!socket) return;
|
|
370
543
|
try {
|
|
371
|
-
await
|
|
544
|
+
await socket.sendPresenceUpdate(presence, jid);
|
|
372
545
|
} catch (error) {
|
|
373
546
|
if (this.verboseMode) {
|
|
374
547
|
console.error(`Failed to send presence update to ${jid}:`, error);
|
|
@@ -377,9 +550,10 @@ export class WhatsAppService {
|
|
|
377
550
|
}
|
|
378
551
|
|
|
379
552
|
async markRead(jid: string, messageId: string, fromMe: boolean = false) {
|
|
380
|
-
|
|
553
|
+
const socket = this.getActiveSocket();
|
|
554
|
+
if (!socket) return;
|
|
381
555
|
try {
|
|
382
|
-
await
|
|
556
|
+
await socket.readMessages([{ remoteJid: jid, id: messageId, fromMe }]);
|
|
383
557
|
} catch (error) {
|
|
384
558
|
if (this.verboseMode) {
|
|
385
559
|
console.error(`Failed to mark message as read:`, error);
|
|
@@ -401,18 +575,8 @@ export class WhatsAppService {
|
|
|
401
575
|
}
|
|
402
576
|
}
|
|
403
577
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
this.restoreBaileysConsoleFilter = undefined;
|
|
407
|
-
this.socket.ev.removeAllListeners('connection.update');
|
|
408
|
-
this.socket.ev.removeAllListeners('creds.update');
|
|
409
|
-
this.socket.ev.removeAllListeners('messages.upsert');
|
|
410
|
-
try {
|
|
411
|
-
this.socket.end(undefined);
|
|
412
|
-
} catch (e) {}
|
|
413
|
-
this.socket = undefined;
|
|
414
|
-
this.isReconnecting = false;
|
|
415
|
-
}
|
|
578
|
+
this.cleanupSocket();
|
|
579
|
+
this.isReconnecting = false;
|
|
416
580
|
await this.sessionManager.setStatus('disconnected');
|
|
417
581
|
this.onStatusUpdate?.('| WhatsApp: Disconnected');
|
|
418
582
|
}
|
package/src/ui/menu.handler.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { WhatsAppService } from '../services/whatsapp.service.js';
|
|
2
2
|
import { SessionManager, type Contact } from '../services/session.manager.js';
|
|
3
|
-
import { validatePhoneNumber, type RecentConversationSummary } from '../models/whatsapp.types.js';
|
|
3
|
+
import { validatePhoneNumber, type RecentConversationMessage, type RecentConversationSummary } from '../models/whatsapp.types.js';
|
|
4
4
|
import { RecentsService } from '../services/recents.service.js';
|
|
5
|
+
import { showMessageDetailView } from './message-detail.view.js';
|
|
6
|
+
import { showMessageReplyView } from './message-reply.view.js';
|
|
5
7
|
import * as qrcode from 'qrcode-terminal';
|
|
6
8
|
import type { ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
|
|
7
9
|
|
|
10
|
+
interface HistoryOptionEntry {
|
|
11
|
+
label: string;
|
|
12
|
+
message: RecentConversationMessage;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
export class MenuHandler {
|
|
9
16
|
private readonly printedAllowedNumbers: string[] = [];
|
|
10
17
|
|
|
@@ -109,7 +116,7 @@ export class MenuHandler {
|
|
|
109
116
|
|
|
110
117
|
private async manageAllowedContact(ctx: ExtensionCommandContext, contact: Contact) {
|
|
111
118
|
const displayName = this.formatAllowedContactOption(contact);
|
|
112
|
-
const options = ['Send Message', 'Print Number'
|
|
119
|
+
const options = ['History', 'Send Message', 'Print Number'];
|
|
113
120
|
if (contact.name) {
|
|
114
121
|
options.push('Remove Alias');
|
|
115
122
|
} else {
|
|
@@ -175,11 +182,18 @@ export class MenuHandler {
|
|
|
175
182
|
|
|
176
183
|
private printAllowedNumber(ctx: ExtensionCommandContext, number: string) {
|
|
177
184
|
this.printedAllowedNumbers.push(number);
|
|
185
|
+
const output = this.printedAllowedNumbers
|
|
186
|
+
.map((entry) => ` • ${entry}`)
|
|
187
|
+
.join('\n');
|
|
188
|
+
console.log([
|
|
189
|
+
'[WhatsApp-Pi] Allowed numbers',
|
|
190
|
+
output
|
|
191
|
+
].join('\n'));
|
|
178
192
|
ctx.ui.notify(this.printedAllowedNumbers.join('\n'), 'info');
|
|
179
193
|
}
|
|
180
194
|
|
|
181
195
|
private async manageBlockList(ctx: ExtensionCommandContext) {
|
|
182
|
-
const list = [...this.sessionManager.
|
|
196
|
+
const list = [...this.sessionManager.getBlockList()].reverse();
|
|
183
197
|
|
|
184
198
|
if (list.length === 0) {
|
|
185
199
|
ctx.ui.notify('No blocked numbers', 'info');
|
|
@@ -203,16 +217,14 @@ export class MenuHandler {
|
|
|
203
217
|
if (action === 'Allow') {
|
|
204
218
|
const ok = await ctx.ui.confirm('Allow', `Move ${number} to Allowed Numbers?`);
|
|
205
219
|
if (ok) {
|
|
206
|
-
|
|
207
|
-
const contact = list.find(c => c.number === number);
|
|
208
|
-
await this.sessionManager.addNumber(number, contact?.name);
|
|
220
|
+
await this.sessionManager.unblockAndAllow(number);
|
|
209
221
|
ctx.ui.notify(`${number} moved to Allowed List`, 'info');
|
|
210
222
|
}
|
|
211
223
|
await this.manageBlockList(ctx);
|
|
212
224
|
} else if (action === 'Delete') {
|
|
213
225
|
const ok = await ctx.ui.confirm('Delete', `Remove ${number} from Block List?`);
|
|
214
226
|
if (ok) {
|
|
215
|
-
await this.sessionManager.
|
|
227
|
+
await this.sessionManager.unblockNumber(number);
|
|
216
228
|
ctx.ui.notify(`${number} removed from Block List`, 'info');
|
|
217
229
|
}
|
|
218
230
|
await this.manageBlockList(ctx);
|
|
@@ -256,13 +268,13 @@ export class MenuHandler {
|
|
|
256
268
|
private async manageRecentConversation(ctx: ExtensionCommandContext, conversation: RecentConversationSummary) {
|
|
257
269
|
const displayName = this.getConversationDisplayName(conversation);
|
|
258
270
|
const allowedContact = this.sessionManager.getAllowedContact(conversation.senderNumber);
|
|
259
|
-
const options: string[] = [];
|
|
271
|
+
const options: string[] = ['History'];
|
|
260
272
|
|
|
261
273
|
if (!allowedContact) {
|
|
262
274
|
options.push('Allow Number');
|
|
263
275
|
}
|
|
264
276
|
|
|
265
|
-
options.push('
|
|
277
|
+
options.push('Send Message');
|
|
266
278
|
|
|
267
279
|
if (allowedContact?.name) {
|
|
268
280
|
options.push('Remove Alias');
|
|
@@ -364,10 +376,20 @@ export class MenuHandler {
|
|
|
364
376
|
}
|
|
365
377
|
|
|
366
378
|
private async showConversationHistory(ctx: ExtensionCommandContext, conversation: RecentConversationSummary) {
|
|
367
|
-
await this.showConversationHistoryForNumber(
|
|
379
|
+
await this.showConversationHistoryForNumber(
|
|
380
|
+
ctx,
|
|
381
|
+
conversation.senderNumber,
|
|
382
|
+
this.getConversationDisplayName(conversation),
|
|
383
|
+
conversation.senderName
|
|
384
|
+
);
|
|
368
385
|
}
|
|
369
386
|
|
|
370
|
-
private async showConversationHistoryForNumber(
|
|
387
|
+
private async showConversationHistoryForNumber(
|
|
388
|
+
ctx: ExtensionCommandContext,
|
|
389
|
+
senderNumber: string,
|
|
390
|
+
displayName: string,
|
|
391
|
+
senderName?: string
|
|
392
|
+
) {
|
|
371
393
|
const history = await this.recentsService.getConversationHistory(senderNumber);
|
|
372
394
|
|
|
373
395
|
if (history.length === 0) {
|
|
@@ -375,16 +397,56 @@ export class MenuHandler {
|
|
|
375
397
|
return;
|
|
376
398
|
}
|
|
377
399
|
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
400
|
+
const historyOptions = this.buildHistoryOptions(this.sortHistoryByMostRecent(history));
|
|
401
|
+
const choice = await ctx.ui.select(`History • ${displayName}`, [
|
|
402
|
+
...historyOptions.map(option => option.label),
|
|
381
403
|
'Back'
|
|
382
|
-
];
|
|
404
|
+
]);
|
|
383
405
|
|
|
384
|
-
|
|
385
|
-
if (choice === 'Back' || !choice) {
|
|
406
|
+
if (!choice || choice === 'Back') {
|
|
386
407
|
return;
|
|
387
408
|
}
|
|
409
|
+
|
|
410
|
+
const selectedMessage = this.resolveHistorySelection(choice, historyOptions);
|
|
411
|
+
if (!selectedMessage) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const detailAction = await showMessageDetailView(ctx, {
|
|
416
|
+
title: `Message • ${displayName}`,
|
|
417
|
+
messageId: selectedMessage.messageId,
|
|
418
|
+
senderNumber: selectedMessage.senderNumber,
|
|
419
|
+
senderName,
|
|
420
|
+
text: selectedMessage.text,
|
|
421
|
+
direction: selectedMessage.direction,
|
|
422
|
+
timestamp: selectedMessage.timestamp
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
if (detailAction === 'reply') {
|
|
426
|
+
await showMessageReplyView(ctx, {
|
|
427
|
+
selectedMessage: {
|
|
428
|
+
messageId: selectedMessage.messageId,
|
|
429
|
+
senderNumber: selectedMessage.senderNumber,
|
|
430
|
+
senderName,
|
|
431
|
+
text: selectedMessage.text,
|
|
432
|
+
direction: selectedMessage.direction,
|
|
433
|
+
timestamp: selectedMessage.timestamp
|
|
434
|
+
},
|
|
435
|
+
whatsappService: this.whatsappService,
|
|
436
|
+
recentsService: this.recentsService
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private buildHistoryOptions(history: RecentConversationMessage[]): HistoryOptionEntry[] {
|
|
442
|
+
return history.map(message => ({
|
|
443
|
+
label: this.formatHistoryOption(message.timestamp, message.direction, message.text),
|
|
444
|
+
message
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private resolveHistorySelection(choice: string, options: HistoryOptionEntry[]): RecentConversationMessage | undefined {
|
|
449
|
+
return options.find(option => option.label === choice)?.message;
|
|
388
450
|
}
|
|
389
451
|
|
|
390
452
|
private formatRecentConversationOption(conversation: RecentConversationSummary): string {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { matchesKey, truncateToWidth, wrapTextWithAnsi, visibleWidth } from '@mariozechner/pi-tui';
|
|
2
|
+
import type { SelectedMessageContext } from '../models/whatsapp.types.js';
|
|
3
|
+
|
|
4
|
+
export interface MessageDetailViewProps extends SelectedMessageContext {
|
|
5
|
+
title: string;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
onReply?: () => void | Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class MessageDetailView {
|
|
11
|
+
constructor(private readonly props: MessageDetailViewProps) {}
|
|
12
|
+
|
|
13
|
+
handleInput(data: string): void {
|
|
14
|
+
const normalized = data.toLowerCase();
|
|
15
|
+
|
|
16
|
+
if (normalized === 'r' || matchesKey(data, 'r')) {
|
|
17
|
+
void this.props.onReply?.();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
data === 'enter' ||
|
|
23
|
+
data === 'return' ||
|
|
24
|
+
data === 'escape' ||
|
|
25
|
+
data === 'esc' ||
|
|
26
|
+
matchesKey(data, 'enter') ||
|
|
27
|
+
matchesKey(data, 'escape') ||
|
|
28
|
+
matchesKey(data, 'backspace')
|
|
29
|
+
) {
|
|
30
|
+
this.props.onClose();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
render(width: number): string[] {
|
|
35
|
+
const title = this.props.title.trim() || 'Message Details';
|
|
36
|
+
const bodyText = this.props.text.length > 0 ? this.props.text : '[No readable text available]';
|
|
37
|
+
|
|
38
|
+
const availableWidth = Math.max(20, width - 4);
|
|
39
|
+
const rawHeaderLines = [
|
|
40
|
+
`Message ID: ${this.props.messageId}`,
|
|
41
|
+
`From: ${this.formatSender()}`,
|
|
42
|
+
`Direction: ${this.formatDirection()} • Time: ${this.formatTimestamp(this.props.timestamp)}`
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const contentWidth = Math.min(
|
|
46
|
+
availableWidth,
|
|
47
|
+
Math.max(
|
|
48
|
+
visibleWidth('Press Enter or Esc to return'),
|
|
49
|
+
...rawHeaderLines.map(line => visibleWidth(line)),
|
|
50
|
+
...wrapTextWithAnsi(bodyText, availableWidth).map(line => visibleWidth(line))
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const wrapWidth = Math.max(1, contentWidth);
|
|
55
|
+
const boxWidth = wrapWidth + 4;
|
|
56
|
+
const padLine = (line: string) => `│ ${truncateToWidth(line, wrapWidth).padEnd(wrapWidth, ' ')} │`;
|
|
57
|
+
const centerLine = (line: string) => {
|
|
58
|
+
const content = truncateToWidth(line, wrapWidth);
|
|
59
|
+
const visible = visibleWidth(content);
|
|
60
|
+
const leftPadding = Math.max(0, Math.floor((wrapWidth - visible) / 2));
|
|
61
|
+
const rightPadding = Math.max(0, wrapWidth - visible - leftPadding);
|
|
62
|
+
return `│ ${' '.repeat(leftPadding)}${content}${' '.repeat(rightPadding)} │`;
|
|
63
|
+
};
|
|
64
|
+
const topBorder = `╭${'─'.repeat(boxWidth - 2)}╮`;
|
|
65
|
+
const separator = `├${'─'.repeat(boxWidth - 2)}┤`;
|
|
66
|
+
const bottomBorder = `╰${'─'.repeat(boxWidth - 2)}╯`;
|
|
67
|
+
const bodyLines = wrapTextWithAnsi(bodyText, wrapWidth).filter(line => line.length > 0 || bodyText.length === 0);
|
|
68
|
+
const exitHint = this.props.onReply
|
|
69
|
+
? `\x1b[90mPress R to reply • Enter or Esc to return\x1b[39m`
|
|
70
|
+
: `\x1b[90mPress Enter or Esc to return\x1b[39m`;
|
|
71
|
+
|
|
72
|
+
return [
|
|
73
|
+
topBorder,
|
|
74
|
+
...rawHeaderLines.map(padLine),
|
|
75
|
+
separator,
|
|
76
|
+
...bodyLines.map(padLine),
|
|
77
|
+
separator,
|
|
78
|
+
centerLine(exitHint),
|
|
79
|
+
bottomBorder
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
invalidate(): void {}
|
|
84
|
+
|
|
85
|
+
private formatSender(): string {
|
|
86
|
+
return this.props.senderName
|
|
87
|
+
? `${this.props.senderName} (${this.props.senderNumber})`
|
|
88
|
+
: this.props.senderNumber;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private formatDirection(): string {
|
|
92
|
+
return this.props.direction === 'outgoing' ? 'Sent' : 'Received';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private formatTimestamp(timestamp: number): string {
|
|
96
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
97
|
+
dateStyle: 'short',
|
|
98
|
+
timeStyle: 'medium'
|
|
99
|
+
}).format(new Date(timestamp));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function showMessageDetailView(
|
|
104
|
+
ctx: {
|
|
105
|
+
ui: {
|
|
106
|
+
custom: <T>(factory: (_tui: unknown, _theme: unknown, _keybindings: unknown, done: (value: T | undefined) => void) => MessageDetailView, options?: { overlay?: boolean }) => Promise<T | undefined>;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
props: Omit<MessageDetailViewProps, 'onClose'>
|
|
110
|
+
): Promise<'reply' | undefined> {
|
|
111
|
+
return await ctx.ui.custom<'reply' | undefined>(
|
|
112
|
+
(_tui, _theme, _keybindings, done) => new MessageDetailView({
|
|
113
|
+
...props,
|
|
114
|
+
onClose: () => done(undefined),
|
|
115
|
+
onReply: () => done('reply')
|
|
116
|
+
}),
|
|
117
|
+
{ overlay: true }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ReplyDraft, ReplySendResult, SelectedMessageContext } from '../models/whatsapp.types.js';
|
|
2
|
+
import { truncateToWidth } from '@mariozechner/pi-tui';
|
|
3
|
+
import type { WhatsAppService } from '../services/whatsapp.service.js';
|
|
4
|
+
import type { RecentsService } from '../services/recents.service.js';
|
|
5
|
+
|
|
6
|
+
export interface MessageReplyViewProps {
|
|
7
|
+
selectedMessage: SelectedMessageContext;
|
|
8
|
+
whatsappService: WhatsAppService;
|
|
9
|
+
recentsService: RecentsService;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface MessageReplyContextUi {
|
|
13
|
+
editor(title: string, prefilled?: string): Promise<string | undefined>;
|
|
14
|
+
notify(message: string, level: 'info' | 'warning' | 'error'): void;
|
|
15
|
+
setWidget(name: string, widget?: string[] | ((tui: unknown, theme: { fg: (tone: string, text: string) => string }) => { render(width: number): string[]; invalidate(): void }) , options?: { placement?: 'belowEditor' }): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MessageReplyContext {
|
|
19
|
+
ui: MessageReplyContextUi;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const buildPreview = (text: string): string => {
|
|
23
|
+
const normalized = text.trim().replace(/\s+/g, ' ');
|
|
24
|
+
if (!normalized) {
|
|
25
|
+
return '[No readable text available]';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const buildReplyWidget = (selectedMessage: SelectedMessageContext): string[] => {
|
|
32
|
+
const sender = selectedMessage.senderName
|
|
33
|
+
? `${selectedMessage.senderName} (${selectedMessage.senderNumber})`
|
|
34
|
+
: selectedMessage.senderNumber;
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
`Replying to: ${sender}`,
|
|
38
|
+
`Message ID: ${selectedMessage.messageId}`,
|
|
39
|
+
`Original: ${buildPreview(selectedMessage.text)}`
|
|
40
|
+
];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const buildReplyTitle = (selectedMessage: SelectedMessageContext): string => {
|
|
44
|
+
const sender = selectedMessage.senderName
|
|
45
|
+
? `${selectedMessage.senderName} (${selectedMessage.senderNumber})`
|
|
46
|
+
: selectedMessage.senderNumber;
|
|
47
|
+
|
|
48
|
+
return truncateToWidth(`Reply to ${sender}`, 120);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export async function showMessageReplyView(
|
|
52
|
+
ctx: MessageReplyContext,
|
|
53
|
+
props: MessageReplyViewProps
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const widgetName = 'message-reply-context';
|
|
56
|
+
ctx.ui.setWidget(widgetName, buildReplyWidget(props.selectedMessage), { placement: 'belowEditor' });
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
while (true) {
|
|
60
|
+
const replyText = await ctx.ui.editor(buildReplyTitle(props.selectedMessage));
|
|
61
|
+
|
|
62
|
+
if (replyText === undefined) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const text = replyText.trim();
|
|
67
|
+
if (!text) {
|
|
68
|
+
ctx.ui.notify('Please enter a message before sending.', 'error');
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const draft: ReplyDraft = {
|
|
73
|
+
text,
|
|
74
|
+
targetMessageId: props.selectedMessage.messageId,
|
|
75
|
+
targetConversation: props.selectedMessage.senderNumber
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result: ReplySendResult = await props.whatsappService.sendMenuMessage(
|
|
79
|
+
props.selectedMessage.senderNumber,
|
|
80
|
+
draft.text
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (result.success) {
|
|
84
|
+
await props.recentsService.recordMessage({
|
|
85
|
+
messageId: result.messageId ?? `${Date.now()}`,
|
|
86
|
+
senderNumber: props.selectedMessage.senderNumber,
|
|
87
|
+
senderName: props.selectedMessage.senderName,
|
|
88
|
+
text: draft.text,
|
|
89
|
+
direction: 'outgoing',
|
|
90
|
+
timestamp: Date.now()
|
|
91
|
+
});
|
|
92
|
+
ctx.ui.notify(`Sent reply to ${buildPreview(props.selectedMessage.text)}`, 'info');
|
|
93
|
+
} else {
|
|
94
|
+
ctx.ui.notify(
|
|
95
|
+
`Failed to send reply: ${result.error ?? 'Unknown error'}`,
|
|
96
|
+
'error'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
ctx.ui.setWidget(widgetName, undefined);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/whatsapp-pi.ts
CHANGED
|
@@ -240,6 +240,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
const formattedMessage = params.message
|
|
244
|
+
.split('\n')
|
|
245
|
+
.map((line) => ` ${line}`)
|
|
246
|
+
.join('\n');
|
|
247
|
+
|
|
248
|
+
console.log([
|
|
249
|
+
'[WhatsApp-Pi] Outgoing WhatsApp message',
|
|
250
|
+
` To: ${params.jid}`,
|
|
251
|
+
' Message:',
|
|
252
|
+
formattedMessage
|
|
253
|
+
].join('\n'));
|
|
254
|
+
|
|
243
255
|
const result = await whatsappService.sendMessage(params.jid, params.message);
|
|
244
256
|
|
|
245
257
|
if (result.success) {
|
|
@@ -250,6 +262,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
250
262
|
direction: 'outgoing',
|
|
251
263
|
timestamp: Date.now()
|
|
252
264
|
});
|
|
265
|
+
console.log([
|
|
266
|
+
'[WhatsApp-Pi] Outgoing WhatsApp message result',
|
|
267
|
+
` To: ${params.jid}`,
|
|
268
|
+
' Status: sent',
|
|
269
|
+
` MessageId: ${result.messageId ?? 'unknown'}`
|
|
270
|
+
].join('\n'));
|
|
271
|
+
} else {
|
|
272
|
+
console.log([
|
|
273
|
+
'[WhatsApp-Pi] Outgoing WhatsApp message result',
|
|
274
|
+
` To: ${params.jid}`,
|
|
275
|
+
' Status: failed',
|
|
276
|
+
` Error: ${result.error ?? 'unknown error'}`
|
|
277
|
+
].join('\n'));
|
|
253
278
|
}
|
|
254
279
|
|
|
255
280
|
return {
|