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 +1 -1
- package/src/i18n.ts +15 -0
- package/src/services/incoming-message.resolver.ts +17 -0
- package/src/services/reaction.sender.ts +65 -0
- package/src/services/whatsapp.service.ts +44 -26
- package/src/ui/message-reply.view.ts +32 -21
- package/whatsapp-pi.ts +59 -10
package/package.json
CHANGED
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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 =
|
|
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 ===
|
|
446
|
+
if (toolSentToJid === outboundJid) {
|
|
398
447
|
toolSentToJid = null;
|
|
399
448
|
return;
|
|
400
449
|
}
|
|
401
450
|
|
|
402
|
-
if (
|
|
451
|
+
if (outboundJid && text) {
|
|
403
452
|
try {
|
|
404
|
-
const result = await whatsappService.sendMessage(
|
|
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:
|
|
457
|
+
senderNumber: toRecentSenderNumber(outboundJid),
|
|
409
458
|
text,
|
|
410
459
|
direction: 'outgoing',
|
|
411
460
|
timestamp: Date.now()
|