feat: reply to specific messages in group chats with quote

This commit is contained in:
linuztx 2026-03-26 11:26:36 +08:00
parent 6bf63eb9c6
commit 71a6eb7557
7 changed files with 47 additions and 11 deletions

View file

@ -4,7 +4,7 @@ import asyncio
from helpers.extension import Extension
from helpers.print_style import PrintStyle
from agent import AgentContext, LoopData, UserMessage
from plugins._whatsapp_integration.helpers.handler import CTX_WA_CHAT_ID, CTX_WA_ATTACHMENTS
from plugins._whatsapp_integration.helpers.handler import CTX_WA_CHAT_ID, CTX_WA_ATTACHMENTS, CTX_WA_REPLY_TO
MAX_SEND_RETRIES: int = 2
CTX_SEND_FAILURES: str = "_wa_send_failures"
@ -25,15 +25,17 @@ class WhatsAppAutoReply(Extension):
return
attachments = context.data.pop(CTX_WA_ATTACHMENTS, [])
reply_to = context.data.pop(CTX_WA_REPLY_TO, "")
if attachments:
PrintStyle.info(f"WhatsApp: sending reply with {len(attachments)} attachment(s)")
asyncio.create_task(self._send_reply(context, response_text, attachments))
asyncio.create_task(self._send_reply(context, response_text, attachments, reply_to))
async def _send_reply(
self, context: AgentContext, response_text: str, attachments: list[str],
reply_to: str = "",
):
from plugins._whatsapp_integration.helpers.handler import send_wa_reply
error = await send_wa_reply(context, response_text, attachments)
error = await send_wa_reply(context, response_text, attachments, reply_to=reply_to)
if not error:
context.data[CTX_SEND_FAILURES] = 0
return

View file

@ -3,7 +3,7 @@
from helpers.extension import Extension
from helpers.print_style import PrintStyle
from helpers.tool import Response
from plugins._whatsapp_integration.helpers.handler import CTX_WA_CHAT_ID, CTX_WA_ATTACHMENTS
from plugins._whatsapp_integration.helpers.handler import CTX_WA_CHAT_ID, CTX_WA_ATTACHMENTS, CTX_WA_REPLY_TO
class WhatsAppResponseIntercept(Extension):
@ -23,10 +23,13 @@ class WhatsAppResponseIntercept(Extension):
if not tool:
return
# Capture attachments for later (process_chain_end) or inline send
# Capture attachments and reply_to for later (process_chain_end) or inline send
attachments = tool.args.get("attachments", [])
if attachments:
context.data[CTX_WA_ATTACHMENTS] = attachments
reply_to = tool.args.get("reply_to", "")
if reply_to:
context.data[CTX_WA_REPLY_TO] = reply_to
# Check break_loop arg from agent
agent_break = tool.args.get("break_loop", True)
@ -43,11 +46,12 @@ class WhatsAppResponseIntercept(Extension):
text = tool.args.get("text", tool.args.get("message", ""))
attachments = context.data.pop(CTX_WA_ATTACHMENTS, [])
reply_to = context.data.pop(CTX_WA_REPLY_TO, "")
if attachments:
PrintStyle.info(f"WhatsApp: sending update with {len(attachments)} attachment(s)")
error = await send_wa_reply(context, text, attachments or None)
error = await send_wa_reply(context, text, attachments or None, reply_to=reply_to)
if error:
result = agent.read_prompt("fw.wa.update_error.md", error=error)

View file

@ -28,8 +28,10 @@ CTX_WA_SENDER_NAME = "wa_sender_name"
CTX_WA_SENDER_NUMBER = "wa_sender_number"
CTX_WA_IS_GROUP = "wa_is_group"
CTX_WA_LAST_BODY = "wa_last_body"
CTX_WA_LAST_MSG_ID = "wa_last_msg_id"
# Transient — consumed per-reply, not persisted
CTX_WA_ATTACHMENTS = "_wa_response_attachments"
CTX_WA_REPLY_TO = "_wa_reply_to"
# Poll task — lives here (not in extension module) because
# extension modules are re-executed on each job_loop tick,
@ -112,6 +114,7 @@ async def _start_new_chat(config: dict, msg: dict) -> None:
context.data[CTX_WA_SENDER_NUMBER] = sender_number
context.data[CTX_WA_IS_GROUP] = is_group
context.data[CTX_WA_LAST_BODY] = msg.get("body", "")
context.data[CTX_WA_LAST_MSG_ID] = msg.get("messageId", "")
project = config.get("project", "")
if project:
@ -147,6 +150,7 @@ async def _route_to_chat(
return
context.data[CTX_WA_LAST_BODY] = msg.get("body", "")
context.data[CTX_WA_LAST_MSG_ID] = msg.get("messageId", "")
user_msg = _build_user_message(context.agent0, msg, config)
msg_id = str(uuid.uuid4())
@ -196,6 +200,7 @@ def _build_user_message(agent: Agent, msg: dict, config: dict) -> str:
sender_name=sender_name,
sender_number=sender_number,
group_name=msg.get("chatName", ""),
message_id=msg.get("messageId", ""),
body=msg.get("body", ""),
)
instructions = config.get("agent_instructions", "")
@ -214,6 +219,7 @@ async def send_wa_reply(
context: AgentContext,
response_text: str,
attachments: list[str] | None = None,
reply_to: str = "",
) -> str | None:
chat_id = context.data.get(CTX_WA_CHAT_ID)
if not chat_id:
@ -223,12 +229,16 @@ async def send_wa_reply(
port = int(config.get("bridge_port", 3100))
base_url = bridge_manager.get_bridge_url(port)
# For group chats, auto-reply to last received message if no explicit reply_to
if not reply_to and context.data.get(CTX_WA_IS_GROUP):
reply_to = context.data.get(CTX_WA_LAST_MSG_ID, "")
# Typing indicator
await wa_client.send_typing(base_url, chat_id)
# Send text
try:
result = await wa_client.send_message(base_url, chat_id, response_text)
result = await wa_client.send_message(base_url, chat_id, response_text, reply_to=reply_to)
if result.get("error"):
return result["error"]
except Exception as e:

View file

@ -18,12 +18,15 @@ async def get_messages(base_url: str) -> list[dict]:
async def send_message(
base_url: str, chat_id: str, message: str,
base_url: str, chat_id: str, message: str, reply_to: str = "",
) -> dict:
payload: dict = {"chatId": chat_id, "message": message}
if reply_to:
payload["replyTo"] = reply_to
async with aiohttp.ClientSession() as session:
async with session.post(
f"{base_url}/send",
json={"chatId": chat_id, "message": message},
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
return await resp.json()

View file

@ -5,6 +5,8 @@ dont use code to send message
break_loop true > stop working and wait for user reply
break_loop false > only for mid-task progress updates then keep working
include file paths in attachments array for sending files
in group chats use reply_to with msg id to quote a specific message
group replies auto-quote last received message if reply_to omitted
usage:
~~~json
@ -27,6 +29,7 @@ usage:
"attachments": [
"/path/file.png"
],
"reply_to": "msg_id",
"break_loop": true
}
}

View file

@ -1,3 +1,3 @@
[WhatsApp group "{{group_name}}" from {{sender_name}}]
[WhatsApp group "{{group_name}}" from {{sender_name}} msg:{{message_id}}]
{{body}}

View file

@ -91,6 +91,10 @@ const MAX_QUEUE_SIZE = 100;
const recentlySentIds = new Set();
const MAX_RECENT_IDS = 50;
// Store received messages for reply quoting
const messageStore = new Map();
const MAX_STORED_MESSAGES = 200;
let sock = null;
let connectionState = 'disconnected';
let latestQrDataUrl = null;
@ -348,6 +352,12 @@ async function startSocket() {
timestamp: msg.messageTimestamp,
};
// Store raw message for reply quoting
messageStore.set(msg.key.id, msg);
if (messageStore.size > MAX_STORED_MESSAGES) {
messageStore.delete(messageStore.keys().next().value);
}
messageQueue.push(event);
if (messageQueue.length > MAX_QUEUE_SIZE) {
messageQueue.shift();
@ -378,7 +388,11 @@ app.post('/send', async (req, res) => {
}
try {
const sent = await sock.sendMessage(chatId, { text: message });
const opts = {};
if (replyTo && messageStore.has(replyTo)) {
opts.quoted = messageStore.get(replyTo);
}
const sent = await sock.sendMessage(chatId, { text: message }, opts);
if (sent?.key?.id) {
recentlySentIds.add(sent.key.id);