whatsapp-pi 1.0.61 → 1.0.63

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,7 +19,6 @@ 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 @)
23
22
  - Only groups in Allowed Groups are processed by the agent
24
23
  - **Recents & History**: Browse recent conversations, inspect full message history, and reply from message detail view
25
24
  - **Reliable Messaging**: Queue-based message sending with retry logic
@@ -153,8 +152,8 @@ pi -e whatsapp-pi.ts --whatsapp-pi-online
153
152
 
154
153
  ### Allowed Groups Management
155
154
  - **Add Group** - Add a WhatsApp group JID to the allowed groups list (format: 120363012345@g.us)
156
- - **Select a group** - Open a submenu with **History**, **Send Message**, **Print Group JID**, **Reaction Mode**, **Add Alias**, **Remove Alias**, **Remove Group**, and **Back**
157
- - **Reaction Mode** - Switch between **Active** and **Passive** behavior for that group
155
+ - **Select a group** - Open a submenu with **History**, **Send Message**, **Print Group JID**, **Add Alias**, **Remove Alias**, **Remove Group**, and **Back**
156
+
158
157
  - **Back** - Return to main menu
159
158
 
160
159
  ### Recents Management
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.61",
3
+ "version": "1.0.63",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
package/src/i18n.ts CHANGED
@@ -189,6 +189,11 @@ const fallback = {
189
189
  "incoming.media.contact": "[Contact]",
190
190
  "incoming.media.location": "[Location]",
191
191
  "incoming.media.unsupported": "[Unsupported Message Type: {typeName}]",
192
+ "incoming.media.reaction": "{emoji} Reacted to message",
193
+ "incoming.media.reactionRemoved": "Removed reaction",
194
+ "tool.sendReaction.label": "Send WhatsApp Reaction",
195
+ "tool.sendReaction.description": "React to a WhatsApp message with an emoji",
196
+ "tool.sendReaction.error.invalidEmoji": "Invalid emoji provided",
192
197
  "incoming.media.audioTranscribing": "[WhatsApp-Pi] Transcribing audio from {pushName}...",
193
198
  "incoming.media.audioTranscribed": "[Transcribed Audio]: {transcription}",
194
199
  "incoming.media.imageDownloading": "[WhatsApp-Pi] Downloading image from {pushName}...",
@@ -387,6 +392,11 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
387
392
  "incoming.media.documentDownloading": "[WhatsApp-Pi] Baixando documento de {pushName}: {fileName}...",
388
393
  "incoming.media.documentSaved": "[WhatsApp-Pi] Documento salvo em {relativePath} ({size} bytes)",
389
394
  "incoming.media.documentReceived": "[Documento recebido: {fileName}]",
395
+ "incoming.media.reaction": "{emoji} Reagiu à mensagem",
396
+ "incoming.media.reactionRemoved": "Reação removida",
397
+ "tool.sendReaction.label": "Enviar Reação no WhatsApp",
398
+ "tool.sendReaction.description": "Reagir a uma mensagem do WhatsApp com um emoji",
399
+ "tool.sendReaction.error.invalidEmoji": "Emoji inválido",
390
400
  "incoming.media.documentMimeType": "Tipo MIME: {mimeType}",
391
401
  "incoming.media.documentSize": "Tamanho: {size}",
392
402
  "incoming.media.documentLocation": "Local: {relativePath}",
@@ -531,6 +541,11 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
531
541
  "session.manager.failedSaveConfig": "Error al guardar config:",
532
542
  "session.manager.invalidNumber": "[SessionManager] Intento de añadir número inválido:",
533
543
  "session.manager.failedDeleteAuthState": "Error al eliminar el estado de autenticación:",
544
+ "incoming.media.reaction": "{emoji} Reaccionó al mensaje",
545
+ "incoming.media.reactionRemoved": "Reacción eliminada",
546
+ "tool.sendReaction.label": "Enviar Reacción en WhatsApp",
547
+ "tool.sendReaction.description": "Reaccionar a un mensaje de WhatsApp con un emoji",
548
+ "tool.sendReaction.error.invalidEmoji": "Emoji inválido",
534
549
  },
535
550
  fr: {
536
551
  "tool.label": "Envoyer un message WhatsApp",
@@ -9,6 +9,7 @@ export type IncomingResolution =
9
9
  | { kind: 'contact'; text: string }
10
10
  | { kind: 'location'; text: string }
11
11
  | { kind: 'system'; text: string }
12
+ | { kind: 'reaction'; text: string; reactionMessage: any }
12
13
  | { kind: 'unsupported'; text: string };
13
14
 
14
15
  const protocolTypes: Record<number, keyof typeof protocolLabels> = {
@@ -146,5 +147,21 @@ export const extractIncomingText = (message: any): IncomingResolution => {
146
147
  return { kind: 'text', text: resolved.templateButtonReplyMessage.selectedDisplayText };
147
148
  }
148
149
 
150
+ if (resolved?.reactionMessage) {
151
+ const emoji = resolved.reactionMessage.text;
152
+ if (emoji) {
153
+ return {
154
+ kind: 'reaction',
155
+ text: t('incoming.media.reaction', { emoji }),
156
+ reactionMessage: resolved.reactionMessage
157
+ };
158
+ }
159
+ return {
160
+ kind: 'reaction',
161
+ text: t('incoming.media.reactionRemoved'),
162
+ reactionMessage: resolved.reactionMessage
163
+ };
164
+ }
165
+
149
166
  return { kind: 'unsupported', text: t('incoming.media.unsupported', { typeName }) };
150
167
  };
@@ -0,0 +1,65 @@
1
+ import { t } from '../i18n.js';
2
+
3
+ export interface SendReactionOptions {
4
+ jid: string;
5
+ messageId: string;
6
+ emoji: string;
7
+ }
8
+
9
+ export interface SendReactionResult {
10
+ success: boolean;
11
+ messageId?: string;
12
+ error?: string;
13
+ }
14
+
15
+ // Socket interface that matches what we need from Baileys
16
+ interface ReactionSocket {
17
+ sendMessage: (jid: string, content: { react: { text: string; key: { remoteJid: string; id: string; fromMe: boolean } } }) => Promise<{ key?: { id?: string | null } } | undefined>;
18
+ }
19
+
20
+ export class ReactionSender {
21
+ constructor(private socket: ReactionSocket | null) {}
22
+
23
+ async sendReaction(options: SendReactionOptions): Promise<SendReactionResult> {
24
+ if (!this.socket) {
25
+ return { success: false, error: t('service.whatsapp.notConnected') };
26
+ }
27
+
28
+ // Validate emoji (must be single emoji or empty string to remove)
29
+ if (!this.isValidEmoji(options.emoji)) {
30
+ return { success: false, error: t('tool.sendReaction.error.invalidEmoji') };
31
+ }
32
+
33
+ try {
34
+ const result = await this.socket.sendMessage(options.jid, {
35
+ react: {
36
+ text: options.emoji,
37
+ key: {
38
+ remoteJid: options.jid,
39
+ id: options.messageId,
40
+ fromMe: false
41
+ }
42
+ }
43
+ });
44
+
45
+ return {
46
+ success: true,
47
+ messageId: result?.key?.id ?? undefined
48
+ };
49
+ } catch (error) {
50
+ return {
51
+ success: false,
52
+ error: error instanceof Error ? error.message : 'Unknown error'
53
+ };
54
+ }
55
+ }
56
+
57
+ private isValidEmoji(emoji: string): boolean {
58
+ // Allow empty string (removes reaction)
59
+ if (emoji === '') return true;
60
+ // Check if single emoji using Unicode emoji regex
61
+ // This matches single Unicode emojis including variation selectors
62
+ const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
63
+ return emojiRegex.test(emoji);
64
+ }
65
+ }
package/whatsapp-pi.ts CHANGED
@@ -8,6 +8,7 @@ import { AudioService } from './src/services/audio.service.js';
8
8
  import { extractIncomingText } from './src/services/incoming-message.resolver.js';
9
9
  import { IncomingMediaService } from './src/services/incoming-media.service.js';
10
10
  import { WhatsAppPiLogger } from './src/services/whatsapp-pi.logger.js';
11
+ import { ReactionSender } from './src/services/reaction.sender.js';
11
12
  import { initI18n, t } from './src/i18n.js';
12
13
 
13
14
  const shutdownState = globalThis as typeof globalThis & {
@@ -205,16 +206,16 @@ export default function (pi: ExtensionAPI) {
205
206
  ctx.ui.notify('WhatsApp: Session reset via /new is now fully supported.', 'info');
206
207
  });
207
208
 
208
- // Track whether send_wa_message tool already sent a reply this turn
209
- let toolSentToJid: string | null = null;
210
-
211
- const toRecentSenderNumber = (recipientJid: string): string => {
212
- if (recipientJid.endsWith('@g.us')) {
213
- return recipientJid;
214
- }
215
-
216
- return `+${recipientJid.split('@')[0]}`;
217
- };
209
+ // Track whether send_wa_message tool already sent a reply this turn
210
+ let toolSentToJid: string | null = null;
211
+
212
+ const toRecentSenderNumber = (recipientJid: string): string => {
213
+ if (recipientJid.endsWith('@g.us')) {
214
+ return recipientJid;
215
+ }
216
+
217
+ return `+${recipientJid.split('@')[0]}`;
218
+ };
218
219
 
219
220
  // Handle incoming messages by injecting them as user prompts
220
221
  whatsappService.setMessageCallback(async (m) => {
@@ -326,15 +327,15 @@ export default function (pi: ExtensionAPI) {
326
327
  formattedMessage
327
328
  ].join('\n'));
328
329
 
329
- const outboundJid = whatsappService.resolveOutboundRecipientJid(resolvedJid);
330
- const result = await whatsappService.sendMessage(outboundJid, params.message);
330
+ const outboundJid = whatsappService.resolveOutboundRecipientJid(resolvedJid);
331
+ const result = await whatsappService.sendMessage(outboundJid, params.message);
331
332
 
332
333
  if (result.success) {
333
334
  // Mark that tool already sent to this JID — prevents message_end from re-sending
334
- toolSentToJid = outboundJid;
335
- await recentsService.recordMessage({
336
- messageId: result.messageId!,
337
- senderNumber: toRecentSenderNumber(outboundJid),
335
+ toolSentToJid = outboundJid;
336
+ await recentsService.recordMessage({
337
+ messageId: result.messageId!,
338
+ senderNumber: toRecentSenderNumber(outboundJid),
338
339
  text: params.message,
339
340
  direction: 'outgoing',
340
341
  timestamp: Date.now()
@@ -362,6 +363,44 @@ export default function (pi: ExtensionAPI) {
362
363
  }
363
364
  });
364
365
 
366
+ // Register send_reaction tool (LLM-callable)
367
+ pi.registerTool({
368
+ name: "send_reaction",
369
+ label: t("tool.sendReaction.label"),
370
+ description: t("tool.sendReaction.description"),
371
+ promptSnippet: "send_reaction(jid, messageId, emoji) - React to a WhatsApp message with an emoji. The 'jid' is the chat JID (e.g. 5511999998888@s.whatsapp.net), 'messageId' is the ID of the message to react to, and 'emoji' is the emoji to react with (e.g., 👍, ❤️, 😂).",
372
+ parameters: Type.Object({
373
+ jid: Type.String({ description: "WhatsApp JID of the chat (e.g. 5511999998888@s.whatsapp.net or 120363012345@g.us)" }),
374
+ messageId: Type.String({ description: "ID of the message to react to" }),
375
+ emoji: Type.String({ description: "Emoji to react with (e.g., 👍, ❤️, 😂). Use empty string to remove reaction." })
376
+ }),
377
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
378
+ // Get socket from WhatsApp service
379
+ const socket = whatsappService.getSocket();
380
+ if (!socket) {
381
+ return {
382
+ isError: true,
383
+ details: undefined,
384
+ content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: t("service.whatsapp.notConnected") }) }]
385
+ };
386
+ }
387
+
388
+ // Create sender with the socket
389
+ const sender = new ReactionSender(socket as any);
390
+ const result = await sender.sendReaction({
391
+ jid: params.jid,
392
+ messageId: params.messageId,
393
+ emoji: params.emoji
394
+ });
395
+
396
+ return {
397
+ isError: !result.success,
398
+ details: undefined,
399
+ content: [{ type: "text" as const, text: JSON.stringify({ success: result.success, messageId: result.messageId, error: result.error }) }]
400
+ };
401
+ }
402
+ });
403
+
365
404
  // Suppress automatic message_end reply when tool already sent
366
405
  // This is checked by the message_end handler below
367
406
 
@@ -385,10 +424,10 @@ export default function (pi: ExtensionAPI) {
385
424
  // Handle outgoing messages (Agent -> WhatsApp)
386
425
  pi.on("agent_start", async (_event, _ctx) => {
387
426
  if (sessionManager.getStatus() !== 'connected') return;
388
- const lastJid = whatsappService.getLastRemoteJid();
389
- if (lastJid) {
390
- await whatsappService.sendPresence(whatsappService.resolveOutboundRecipientJid(lastJid), 'composing');
391
- }
427
+ const lastJid = whatsappService.getLastRemoteJid();
428
+ if (lastJid) {
429
+ await whatsappService.sendPresence(whatsappService.resolveOutboundRecipientJid(lastJid), 'composing');
430
+ }
392
431
  });
393
432
 
394
433
  pi.on("message_end", async (event, ctx) => {
@@ -396,26 +435,26 @@ export default function (pi: ExtensionAPI) {
396
435
 
397
436
  const { message } = event;
398
437
  // Only reply if it's the assistant and we have a valid target
399
- if (message.role === "assistant") {
400
- const lastJid = whatsappService.getLastRemoteJid();
401
- const text = message.content.filter(c => c.type === "text").map(c => c.text).join("\n");
402
- const outboundJid = lastJid
403
- ? whatsappService.resolveOutboundRecipientJid(lastJid)
404
- : null;
405
-
406
- // Skip if send_wa_message tool already sent a reply to this JID
407
- if (toolSentToJid === outboundJid) {
408
- toolSentToJid = null;
409
- return;
410
- }
411
-
412
- if (outboundJid && text) {
413
- try {
414
- const result = await whatsappService.sendMessage(outboundJid, text);
415
- if (result.success) {
416
- await recentsService.recordMessage({
417
- messageId: result.messageId ?? `${Date.now()}`,
418
- senderNumber: toRecentSenderNumber(outboundJid),
438
+ if (message.role === "assistant") {
439
+ const lastJid = whatsappService.getLastRemoteJid();
440
+ const text = message.content.filter(c => c.type === "text").map(c => c.text).join("\n");
441
+ const outboundJid = lastJid
442
+ ? whatsappService.resolveOutboundRecipientJid(lastJid)
443
+ : null;
444
+
445
+ // Skip if send_wa_message tool already sent a reply to this JID
446
+ if (toolSentToJid === outboundJid) {
447
+ toolSentToJid = null;
448
+ return;
449
+ }
450
+
451
+ if (outboundJid && text) {
452
+ try {
453
+ const result = await whatsappService.sendMessage(outboundJid, text);
454
+ if (result.success) {
455
+ await recentsService.recordMessage({
456
+ messageId: result.messageId ?? `${Date.now()}`,
457
+ senderNumber: toRecentSenderNumber(outboundJid),
419
458
  text,
420
459
  direction: 'outgoing',
421
460
  timestamp: Date.now()