whatsapp-pi 1.0.60 → 1.0.62

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.60",
3
+ "version": "1.0.62",
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
+ }
@@ -185,11 +185,27 @@ export class WhatsAppService {
185
185
  return value;
186
186
  }
187
187
 
188
- private normalizeRecipientJid(jid: string): string {
189
- if (jid.includes('@')) return jid;
190
- const digits = jid.startsWith('+') ? jid.slice(1) : jid;
191
- return `${digits}@s.whatsapp.net`;
192
- }
188
+ private normalizeRecipientJid(jid: string): string {
189
+ if (jid.includes('@')) return jid;
190
+ const digits = jid.startsWith('+') ? jid.slice(1) : jid;
191
+ return `${digits}@s.whatsapp.net`;
192
+ }
193
+
194
+ public resolveOutboundRecipientJid(recipient: string): string {
195
+ if (SessionManager.isGroupJid(recipient)) {
196
+ return recipient;
197
+ }
198
+
199
+ const senderNumber = this.normalizeContactNumber(recipient.split('@')[0]);
200
+ const allowedContact = this.sessionManager.getAllowedContact(recipient)
201
+ ?? this.sessionManager.getAllowedContact(senderNumber);
202
+
203
+ if (allowedContact?.sendNumber) {
204
+ return this.normalizeRecipientJid(allowedContact.sendNumber);
205
+ }
206
+
207
+ return this.normalizeRecipientJid(recipient);
208
+ }
193
209
 
194
210
  private normalizeJidForComparison(jid: string): string {
195
211
  const [localPart, domain = ''] = jid.split('@');
@@ -653,27 +669,29 @@ export class WhatsAppService {
653
669
  }
654
670
  }
655
671
 
656
- async sendMessage(jid: string, text: string) {
657
- // Ensure we show the typing indicator before sending
658
- await this.sendPresence(jid, 'composing');
659
-
660
- const result = await this.messageSender.send({
661
- recipientJid: jid,
662
- text: text
663
- });
664
-
665
- // After sending, we can stop the typing indicator
666
- await this.sendPresence(jid, 'paused');
667
-
668
- if (!result.success) {
669
- console.error(t('service.whatsapp.failedSendMessage', { jid, error: result.error ?? t('message.sender.unknownError') }));
670
- }
671
-
672
- return result;
673
- }
674
-
675
- async sendMenuMessage(jid: string, text: string) {
676
- const normalizedJid = this.normalizeRecipientJid(jid);
672
+ async sendMessage(jid: string, text: string) {
673
+ const recipientJid = this.resolveOutboundRecipientJid(jid);
674
+
675
+ // Ensure we show the typing indicator before sending
676
+ await this.sendPresence(recipientJid, 'composing');
677
+
678
+ const result = await this.messageSender.send({
679
+ recipientJid,
680
+ text: text
681
+ });
682
+
683
+ // After sending, we can stop the typing indicator
684
+ await this.sendPresence(recipientJid, 'paused');
685
+
686
+ if (!result.success) {
687
+ console.error(t('service.whatsapp.failedSendMessage', { jid: recipientJid, error: result.error ?? t('message.sender.unknownError') }));
688
+ }
689
+
690
+ return result;
691
+ }
692
+
693
+ async sendMenuMessage(jid: string, text: string) {
694
+ const normalizedJid = this.resolveOutboundRecipientJid(jid);
677
695
  const socket = this.getActiveSocket();
678
696
 
679
697
  if (!socket) {
@@ -41,13 +41,21 @@ const buildReplyWidget = (selectedMessage: SelectedMessageContext): string[] =>
41
41
  ];
42
42
  };
43
43
 
44
- const buildReplyTitle = (selectedMessage: SelectedMessageContext): string => {
44
+ const buildReplyTitle = (selectedMessage: SelectedMessageContext): string => {
45
45
  const sender = selectedMessage.senderName
46
46
  ? `${selectedMessage.senderName} (${selectedMessage.senderNumber})`
47
47
  : selectedMessage.senderNumber;
48
48
 
49
- return truncateToWidth(`${t('message.reply.title')} ${sender}`, 120);
50
- };
49
+ return truncateToWidth(`${t('message.reply.title')} ${sender}`, 120);
50
+ };
51
+
52
+ const toRecentSenderNumber = (recipientJid: string): string => {
53
+ if (recipientJid.endsWith('@g.us')) {
54
+ return recipientJid;
55
+ }
56
+
57
+ return `+${recipientJid.split('@')[0]}`;
58
+ };
51
59
 
52
60
  export async function showMessageReplyView(
53
61
  ctx: MessageReplyContext,
@@ -70,24 +78,27 @@ export async function showMessageReplyView(
70
78
  continue;
71
79
  }
72
80
 
73
- const draft: ReplyDraft = {
74
- text,
75
- targetMessageId: props.selectedMessage.messageId,
76
- targetConversation: props.selectedMessage.senderNumber
77
- };
78
-
79
- const result: ReplySendResult = await props.whatsappService.sendMenuMessage(
80
- props.selectedMessage.senderNumber,
81
- draft.text
82
- );
83
-
84
- if (result.success) {
85
- await props.recentsService.recordMessage({
86
- messageId: result.messageId ?? `${Date.now()}`,
87
- senderNumber: props.selectedMessage.senderNumber,
88
- senderName: props.selectedMessage.senderName,
89
- text: draft.text,
90
- direction: 'outgoing',
81
+ const draft: ReplyDraft = {
82
+ text,
83
+ targetMessageId: props.selectedMessage.messageId,
84
+ targetConversation: props.selectedMessage.senderNumber
85
+ };
86
+ const recipientJid = props.whatsappService.resolveOutboundRecipientJid(
87
+ props.selectedMessage.senderNumber
88
+ );
89
+
90
+ const result: ReplySendResult = await props.whatsappService.sendMenuMessage(
91
+ recipientJid,
92
+ draft.text
93
+ );
94
+
95
+ if (result.success) {
96
+ await props.recentsService.recordMessage({
97
+ messageId: result.messageId ?? `${Date.now()}`,
98
+ senderNumber: toRecentSenderNumber(recipientJid),
99
+ senderName: props.selectedMessage.senderName,
100
+ text: draft.text,
101
+ direction: 'outgoing',
91
102
  timestamp: Date.now()
92
103
  });
93
104
  ctx.ui.notify(t('message.reply.sent', { preview: buildPreview(props.selectedMessage.text) }), 'info');
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 & {
@@ -208,6 +209,14 @@ export default function (pi: ExtensionAPI) {
208
209
  // Track whether send_wa_message tool already sent a reply this turn
209
210
  let toolSentToJid: string | null = null;
210
211
 
212
+ const toRecentSenderNumber = (recipientJid: string): string => {
213
+ if (recipientJid.endsWith('@g.us')) {
214
+ return recipientJid;
215
+ }
216
+
217
+ return `+${recipientJid.split('@')[0]}`;
218
+ };
219
+
211
220
  // Handle incoming messages by injecting them as user prompts
212
221
  whatsappService.setMessageCallback(async (m) => {
213
222
  const msg = m.messages?.[0];
@@ -318,16 +327,15 @@ export default function (pi: ExtensionAPI) {
318
327
  formattedMessage
319
328
  ].join('\n'));
320
329
 
321
- const result = await whatsappService.sendMessage(resolvedJid, params.message);
330
+ const outboundJid = whatsappService.resolveOutboundRecipientJid(resolvedJid);
331
+ const result = await whatsappService.sendMessage(outboundJid, params.message);
322
332
 
323
333
  if (result.success) {
324
334
  // Mark that tool already sent to this JID — prevents message_end from re-sending
325
- toolSentToJid = resolvedJid;
326
- const isGroupJid = resolvedJid.endsWith('@g.us');
327
- const senderNumber = isGroupJid ? resolvedJid : `+${resolvedJid.split('@')[0]}`;
335
+ toolSentToJid = outboundJid;
328
336
  await recentsService.recordMessage({
329
337
  messageId: result.messageId!,
330
- senderNumber,
338
+ senderNumber: toRecentSenderNumber(outboundJid),
331
339
  text: params.message,
332
340
  direction: 'outgoing',
333
341
  timestamp: Date.now()
@@ -355,6 +363,44 @@ export default function (pi: ExtensionAPI) {
355
363
  }
356
364
  });
357
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
+
358
404
  // Suppress automatic message_end reply when tool already sent
359
405
  // This is checked by the message_end handler below
360
406
 
@@ -380,7 +426,7 @@ export default function (pi: ExtensionAPI) {
380
426
  if (sessionManager.getStatus() !== 'connected') return;
381
427
  const lastJid = whatsappService.getLastRemoteJid();
382
428
  if (lastJid) {
383
- await whatsappService.sendPresence(lastJid, 'composing');
429
+ await whatsappService.sendPresence(whatsappService.resolveOutboundRecipientJid(lastJid), 'composing');
384
430
  }
385
431
  });
386
432
 
@@ -392,20 +438,23 @@ export default function (pi: ExtensionAPI) {
392
438
  if (message.role === "assistant") {
393
439
  const lastJid = whatsappService.getLastRemoteJid();
394
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;
395
444
 
396
445
  // Skip if send_wa_message tool already sent a reply to this JID
397
- if (toolSentToJid === lastJid) {
446
+ if (toolSentToJid === outboundJid) {
398
447
  toolSentToJid = null;
399
448
  return;
400
449
  }
401
450
 
402
- if (lastJid && text) {
451
+ if (outboundJid && text) {
403
452
  try {
404
- const result = await whatsappService.sendMessage(lastJid, text);
453
+ const result = await whatsappService.sendMessage(outboundJid, text);
405
454
  if (result.success) {
406
455
  await recentsService.recordMessage({
407
456
  messageId: result.messageId ?? `${Date.now()}`,
408
- senderNumber: `+${lastJid.split('@')[0]}`,
457
+ senderNumber: toRecentSenderNumber(outboundJid),
409
458
  text,
410
459
  direction: 'outgoing',
411
460
  timestamp: Date.now()