whatsapp-pi 1.0.52 → 1.0.54
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 +4 -1
- package/package.json +1 -1
- package/src/i18n.ts +10 -0
- package/src/models/whatsapp.types.ts +2 -0
- package/src/services/session.manager.ts +40 -7
- package/src/services/whatsapp.service.ts +63 -1
- package/src/ui/menu.handler.ts +32 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Pi is a powerful agentic AI coding assistant that operates in your terminal. Thi
|
|
|
19
19
|
- Manage aliases and print allowed contacts from the menu
|
|
20
20
|
- **Allowed Groups**: Control which WhatsApp groups can interact with Pi
|
|
21
21
|
- Add group JIDs with optional aliases
|
|
22
|
+
- Choose reaction mode per group: **Active** (reply to all allowed group messages) or **Passive** (reply only when mentioned with @)
|
|
22
23
|
- Only groups in Allowed Groups are processed by the agent
|
|
23
24
|
- **Recents & History**: Browse recent conversations, inspect full message history, and reply from message detail view
|
|
24
25
|
- **Reliable Messaging**: Queue-based message sending with retry logic
|
|
@@ -124,7 +125,8 @@ pi -e whatsapp-pi.ts --whatsapp-pi-online
|
|
|
124
125
|
|
|
125
126
|
### Allowed Groups Management
|
|
126
127
|
- **Add Group** - Add a WhatsApp group JID to the allowed groups list (format: 120363012345@g.us)
|
|
127
|
-
- **Select a group** - Open a submenu with **History**, **Send Message**, **Print Group JID**, alias actions, **Remove Group**, and **Back**
|
|
128
|
+
- **Select a group** - Open a submenu with **History**, **Send Message**, **Print Group JID**, **Reaction Mode**, alias actions, **Remove Group**, and **Back**
|
|
129
|
+
- **Reaction Mode** - Switch between **Active** and **Passive** behavior for that group
|
|
128
130
|
- **Back** - Return to main menu
|
|
129
131
|
|
|
130
132
|
### Recents Management
|
|
@@ -160,6 +162,7 @@ npm test
|
|
|
160
162
|
|
|
161
163
|
- **Auto-Connect Support**: Use the `--whatsapp-pi-online` flag to connect on startup when credentials already exist.
|
|
162
164
|
- **Group-Only Mode**: Use `--whatsapp-group <jid>` to bind Pi to a single WhatsApp group. The group must also be present in Allowed Groups.
|
|
165
|
+
- **Allowed Group Reaction Mode**: Each allowed group can be set to Active or Passive. Passive mode only replies when the bot is directly mentioned with @.
|
|
163
166
|
- **Recents Store**: Recent conversations and message history are persisted in `~/.pi/whatsapp-pi/recents/recents.json`.
|
|
164
167
|
- **Message Detail / Reply**: Open a message from history to inspect full content and reply with `R`.
|
|
165
168
|
- **Media Support**: Images are forwarded for vision analysis, audio is transcribed with Whisper, and PDFs are saved under `./.pi-data/whatsapp/documents/` with local text preview when available.
|
package/package.json
CHANGED
package/src/i18n.ts
CHANGED
|
@@ -102,6 +102,11 @@ const fallback = {
|
|
|
102
102
|
"menu.allowedGroups.group.history": "History",
|
|
103
103
|
"menu.allowedGroups.group.sendMessage": "Send Message",
|
|
104
104
|
"menu.allowedGroups.group.printGroup": "Print Group JID",
|
|
105
|
+
"menu.allowedGroups.group.reactionMode": "Reaction Mode",
|
|
106
|
+
"menu.allowedGroups.group.reactionMode.title": "Reaction Mode • {displayName}",
|
|
107
|
+
"menu.allowedGroups.group.reactionMode.active": "Active",
|
|
108
|
+
"menu.allowedGroups.group.reactionMode.passive": "Passive",
|
|
109
|
+
"menu.allowedGroups.group.reactionMode.updated": "Reaction mode set to {mode} for {displayName}",
|
|
105
110
|
"menu.allowedGroups.group.removeAlias": "Remove Alias",
|
|
106
111
|
"menu.allowedGroups.group.addAlias": "Add Alias",
|
|
107
112
|
"menu.allowedGroups.group.removeGroup": "Remove Group",
|
|
@@ -306,6 +311,11 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
|
|
|
306
311
|
"menu.allowedGroups.group.history": "Histórico",
|
|
307
312
|
"menu.allowedGroups.group.sendMessage": "Enviar mensagem",
|
|
308
313
|
"menu.allowedGroups.group.printGroup": "Mostrar JID do grupo",
|
|
314
|
+
"menu.allowedGroups.group.reactionMode": "Modo de reação",
|
|
315
|
+
"menu.allowedGroups.group.reactionMode.title": "Modo de reação • {displayName}",
|
|
316
|
+
"menu.allowedGroups.group.reactionMode.active": "Ativo",
|
|
317
|
+
"menu.allowedGroups.group.reactionMode.passive": "Passivo",
|
|
318
|
+
"menu.allowedGroups.group.reactionMode.updated": "Modo de reação definido como {mode} para {displayName}",
|
|
309
319
|
"menu.allowedGroups.group.removeAlias": "Remover apelido",
|
|
310
320
|
"menu.allowedGroups.group.addAlias": "Adicionar apelido",
|
|
311
321
|
"menu.allowedGroups.group.removeGroup": "Remover grupo",
|
|
@@ -2,12 +2,13 @@ import { useMultiFileAuthState } from 'baileys';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { readFile, writeFile, mkdir, rm, rename } from 'fs/promises';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
|
-
import { SessionStatus } from '../models/whatsapp.types.js';
|
|
5
|
+
import { SessionStatus, type ReactionMode } from '../models/whatsapp.types.js';
|
|
6
6
|
import { t } from '../i18n.js';
|
|
7
7
|
|
|
8
8
|
export interface Contact {
|
|
9
9
|
number: string;
|
|
10
10
|
name?: string;
|
|
11
|
+
reactionMode?: ReactionMode;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export class SessionManager {
|
|
@@ -72,7 +73,10 @@ export class SessionManager {
|
|
|
72
73
|
num = num.number;
|
|
73
74
|
}
|
|
74
75
|
if (typeof num === 'string') {
|
|
75
|
-
|
|
76
|
+
const reactionMode = item.reactionMode === 'active' || item.reactionMode === 'passive'
|
|
77
|
+
? item.reactionMode
|
|
78
|
+
: undefined;
|
|
79
|
+
return { number: num, name: item.name, reactionMode };
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
return null;
|
|
@@ -81,6 +85,8 @@ export class SessionManager {
|
|
|
81
85
|
const loadedAllowList = (config.allowList || []).map(cleanContact).filter(Boolean) as Contact[];
|
|
82
86
|
const loadedAllowedGroups = (config.allowedGroups || []).map(cleanContact).filter(Boolean) as Contact[];
|
|
83
87
|
const migratedGroups = loadedAllowList.filter(c => SessionManager.isGroupJid(c.number));
|
|
88
|
+
const needsReactionModeBackfill = loadedAllowedGroups.some(group => !group.reactionMode)
|
|
89
|
+
|| migratedGroups.some(group => !group.reactionMode);
|
|
84
90
|
this.allowList = loadedAllowList.filter(c => !SessionManager.isGroupJid(c.number));
|
|
85
91
|
this.allowedGroups = this.mergeContacts(loadedAllowedGroups, migratedGroups);
|
|
86
92
|
this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
|
|
@@ -89,7 +95,7 @@ export class SessionManager {
|
|
|
89
95
|
this.openaiKey = config.openaiKey || '';
|
|
90
96
|
this.visionModel = config.visionModel || 'gpt-4o';
|
|
91
97
|
|
|
92
|
-
if (recovered) {
|
|
98
|
+
if (recovered || needsReactionModeBackfill) {
|
|
93
99
|
await this.saveConfig();
|
|
94
100
|
}
|
|
95
101
|
} catch {
|
|
@@ -240,7 +246,7 @@ export class SessionManager {
|
|
|
240
246
|
|
|
241
247
|
const existing = this.allowedGroups.find(c => c.number === groupJid);
|
|
242
248
|
if (!existing) {
|
|
243
|
-
this.allowedGroups.push({ number: groupJid, name });
|
|
249
|
+
this.allowedGroups.push({ number: groupJid, name, reactionMode: 'active' });
|
|
244
250
|
this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== groupJid);
|
|
245
251
|
await this.saveConfig();
|
|
246
252
|
return;
|
|
@@ -250,6 +256,11 @@ export class SessionManager {
|
|
|
250
256
|
existing.name = name;
|
|
251
257
|
await this.saveConfig();
|
|
252
258
|
}
|
|
259
|
+
|
|
260
|
+
if (!existing.reactionMode) {
|
|
261
|
+
existing.reactionMode = 'active';
|
|
262
|
+
await this.saveConfig();
|
|
263
|
+
}
|
|
253
264
|
}
|
|
254
265
|
|
|
255
266
|
async removeAllowedGroup(groupJid: string) {
|
|
@@ -297,6 +308,20 @@ export class SessionManager {
|
|
|
297
308
|
await this.saveConfig();
|
|
298
309
|
}
|
|
299
310
|
|
|
311
|
+
getAllowedGroupReactionMode(groupJid: string): ReactionMode {
|
|
312
|
+
return this.getAllowedGroup(groupJid)?.reactionMode || 'active';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async setAllowedGroupReactionMode(groupJid: string, reactionMode: ReactionMode) {
|
|
316
|
+
const group = this.getAllowedGroup(groupJid);
|
|
317
|
+
if (!group) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
group.reactionMode = reactionMode;
|
|
322
|
+
await this.saveConfig();
|
|
323
|
+
}
|
|
324
|
+
|
|
300
325
|
async removeAllowedGroupAlias(groupJid: string) {
|
|
301
326
|
const group = this.getAllowedGroup(groupJid);
|
|
302
327
|
if (!group || !group.name) {
|
|
@@ -336,11 +361,19 @@ export class SessionManager {
|
|
|
336
361
|
const existing = merged.find(c => c.number === contact.number);
|
|
337
362
|
if (!existing) {
|
|
338
363
|
merged.push(contact);
|
|
339
|
-
} else
|
|
340
|
-
existing.name
|
|
364
|
+
} else {
|
|
365
|
+
if (!existing.name && contact.name) {
|
|
366
|
+
existing.name = contact.name;
|
|
367
|
+
}
|
|
368
|
+
if (!existing.reactionMode && contact.reactionMode) {
|
|
369
|
+
existing.reactionMode = contact.reactionMode;
|
|
370
|
+
}
|
|
341
371
|
}
|
|
342
372
|
}
|
|
343
|
-
return merged
|
|
373
|
+
return merged.map(contact => ({
|
|
374
|
+
...contact,
|
|
375
|
+
reactionMode: contact.reactionMode || 'active'
|
|
376
|
+
}));
|
|
344
377
|
}
|
|
345
378
|
|
|
346
379
|
public async isRegistered(): Promise<boolean> {
|
|
@@ -42,9 +42,16 @@ interface IncomingMessageKey {
|
|
|
42
42
|
participant?: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
interface IncomingMessageContextInfo {
|
|
46
|
+
mentionedJid?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
interface IncomingMessageContent {
|
|
46
50
|
conversation?: string;
|
|
47
|
-
extendedTextMessage?: {
|
|
51
|
+
extendedTextMessage?: {
|
|
52
|
+
text?: string;
|
|
53
|
+
contextInfo?: IncomingMessageContextInfo;
|
|
54
|
+
};
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
interface IncomingMessageLike {
|
|
@@ -59,6 +66,7 @@ interface MessagesUpsertEvent {
|
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
interface WhatsAppSocketLike {
|
|
69
|
+
user?: { id?: string; lid?: string };
|
|
62
70
|
ev: {
|
|
63
71
|
on(event: 'connection.update', handler: (update: ConnectionUpdateEvent) => void | Promise<void>): void;
|
|
64
72
|
on(event: 'creds.update', handler: () => void | Promise<void>): void;
|
|
@@ -170,6 +178,48 @@ export class WhatsAppService {
|
|
|
170
178
|
return `${digits}@s.whatsapp.net`;
|
|
171
179
|
}
|
|
172
180
|
|
|
181
|
+
private normalizeJidForComparison(jid: string): string {
|
|
182
|
+
const [localPart, domain = ''] = jid.split('@');
|
|
183
|
+
const normalizedLocal = localPart.split(':')[0];
|
|
184
|
+
return domain ? `${normalizedLocal}@${domain}` : normalizedLocal;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private normalizeJidIdentity(jid: string): string {
|
|
188
|
+
return this.normalizeJidForComparison(jid).split('@')[0];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private getAgentJidCandidates(): string[] {
|
|
192
|
+
const user = this.socket?.user;
|
|
193
|
+
const rawJids = [user?.id, user?.lid].filter((jid): jid is string => Boolean(jid));
|
|
194
|
+
const candidates = new Set<string>();
|
|
195
|
+
|
|
196
|
+
for (const jid of rawJids) {
|
|
197
|
+
const normalized = this.normalizeJidForComparison(jid);
|
|
198
|
+
candidates.add(normalized);
|
|
199
|
+
candidates.add(this.normalizeJidIdentity(jid));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return [...candidates];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private messageHasDirectMention(message: IncomingMessageLike): boolean {
|
|
206
|
+
const mentionedJids = message.message?.extendedTextMessage?.contextInfo?.mentionedJid || [];
|
|
207
|
+
if (mentionedJids.length === 0) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const agentCandidates = this.getAgentJidCandidates();
|
|
212
|
+
if (agentCandidates.length === 0) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return mentionedJids.some(jid => {
|
|
217
|
+
const normalizedMention = this.normalizeJidForComparison(jid);
|
|
218
|
+
const mentionIdentity = this.normalizeJidIdentity(jid);
|
|
219
|
+
return agentCandidates.includes(normalizedMention) || agentCandidates.includes(mentionIdentity);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
173
223
|
private getDisconnectStatusCode(error: unknown): number | undefined {
|
|
174
224
|
if (!error || typeof error !== 'object') {
|
|
175
225
|
return undefined;
|
|
@@ -496,6 +546,10 @@ export class WhatsAppService {
|
|
|
496
546
|
void this.recordIncomingMessage(message, remoteJid, text);
|
|
497
547
|
|
|
498
548
|
const pushName = message.pushName || undefined;
|
|
549
|
+
const groupAllowed = isGroup && this.sessionManager.isAllowedGroup(remoteJid);
|
|
550
|
+
const passiveGroupBlocked = groupAllowed
|
|
551
|
+
&& this.sessionManager.getAllowedGroupReactionMode(remoteJid) === 'passive'
|
|
552
|
+
&& !this.messageHasDirectMention(message);
|
|
499
553
|
|
|
500
554
|
if (this.boundGroupJid) {
|
|
501
555
|
if (!this.sessionManager.isAllowedGroup(this.boundGroupJid)) {
|
|
@@ -503,6 +557,10 @@ export class WhatsAppService {
|
|
|
503
557
|
return;
|
|
504
558
|
}
|
|
505
559
|
|
|
560
|
+
if (this.sessionManager.getAllowedGroupReactionMode(this.boundGroupJid) === 'passive' && !this.messageHasDirectMention(message)) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
506
564
|
this.lastRemoteJid = remoteJid;
|
|
507
565
|
this.onMessage?.(payload);
|
|
508
566
|
return;
|
|
@@ -516,6 +574,10 @@ export class WhatsAppService {
|
|
|
516
574
|
return;
|
|
517
575
|
}
|
|
518
576
|
|
|
577
|
+
if (passiveGroupBlocked) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
519
581
|
this.lastRemoteJid = remoteJid;
|
|
520
582
|
this.onMessage?.(payload);
|
|
521
583
|
}
|
package/src/ui/menu.handler.ts
CHANGED
|
@@ -255,11 +255,12 @@ export class MenuHandler {
|
|
|
255
255
|
const historyLabel = t('menu.allowedGroups.group.history');
|
|
256
256
|
const sendMessageLabel = t('menu.allowedGroups.group.sendMessage');
|
|
257
257
|
const printGroupLabel = t('menu.allowedGroups.group.printGroup');
|
|
258
|
+
const reactionModeLabel = t('menu.allowedGroups.group.reactionMode');
|
|
258
259
|
const removeAliasLabel = t('menu.allowedGroups.group.removeAlias');
|
|
259
260
|
const addAliasLabel = t('menu.allowedGroups.group.addAlias');
|
|
260
261
|
const removeGroupLabel = t('menu.allowedGroups.group.removeGroup');
|
|
261
262
|
const backLabel = t('menu.allowedGroups.group.back');
|
|
262
|
-
const options = [historyLabel, sendMessageLabel, printGroupLabel];
|
|
263
|
+
const options = [historyLabel, sendMessageLabel, printGroupLabel, reactionModeLabel];
|
|
263
264
|
if (group.name) {
|
|
264
265
|
options.push(removeAliasLabel);
|
|
265
266
|
} else {
|
|
@@ -287,6 +288,12 @@ export class MenuHandler {
|
|
|
287
288
|
return;
|
|
288
289
|
}
|
|
289
290
|
|
|
291
|
+
if (choice === reactionModeLabel) {
|
|
292
|
+
await this.manageAllowedGroupReactionMode(ctx, group);
|
|
293
|
+
await this.manageAllowedGroup(ctx, group);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
290
297
|
if (choice === addAliasLabel) {
|
|
291
298
|
const alias = await ctx.ui.input(t('menu.allowedGroups.enterAlias', { groupJid: group.number }));
|
|
292
299
|
const trimmedAlias = alias?.trim() || '';
|
|
@@ -469,6 +476,30 @@ export class MenuHandler {
|
|
|
469
476
|
});
|
|
470
477
|
}
|
|
471
478
|
|
|
479
|
+
private async manageAllowedGroupReactionMode(ctx: ExtensionCommandContext, group: Contact) {
|
|
480
|
+
const displayName = this.formatAllowedGroupOption(group);
|
|
481
|
+
const title = t('menu.allowedGroups.group.reactionMode.title', { displayName });
|
|
482
|
+
const activeLabel = t('menu.allowedGroups.group.reactionMode.active');
|
|
483
|
+
const passiveLabel = t('menu.allowedGroups.group.reactionMode.passive');
|
|
484
|
+
const backLabel = t('menu.allowedGroups.group.back');
|
|
485
|
+
const currentMode = this.sessionManager.getAllowedGroupReactionMode(group.number);
|
|
486
|
+
const options = [activeLabel, passiveLabel, backLabel];
|
|
487
|
+
|
|
488
|
+
const choice = await ctx.ui.select(title, options);
|
|
489
|
+
|
|
490
|
+
if (choice === backLabel || !choice) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (choice === activeLabel || choice === passiveLabel) {
|
|
495
|
+
const nextMode = choice === activeLabel ? 'active' : 'passive';
|
|
496
|
+
if (currentMode !== nextMode) {
|
|
497
|
+
await this.sessionManager.setAllowedGroupReactionMode(group.number, nextMode);
|
|
498
|
+
}
|
|
499
|
+
ctx.ui.notify(t('menu.allowedGroups.group.reactionMode.updated', { displayName, mode: choice }), 'info');
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
472
503
|
private async sendPromptedMenuMessage(
|
|
473
504
|
ctx: ExtensionCommandContext,
|
|
474
505
|
options: {
|