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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.52",
3
+ "version": "1.0.54",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
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",
@@ -10,6 +10,8 @@ export interface AllowList {
10
10
  numbers: string[];
11
11
  }
12
12
 
13
+ export type ReactionMode = 'active' | 'passive';
14
+
13
15
  export interface IncomingMessage {
14
16
  id: string;
15
17
  remoteJid: string;
@@ -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
- return { number: num, name: item.name };
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 if (!existing.name && contact.name) {
340
- existing.name = contact.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?: { text?: string };
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
  }
@@ -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: {