支持微信/WhatsApp 回送附件直发并补齐链路测试。

保持入站到 agent 流程不变,在回送阶段支持 media_path/media_url 与 base64 附件直发,同时让通道回包沿用会话中助手附件记录以对齐 chat 历史逻辑。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-29 10:13:09 +08:00
parent 25180921e7
commit 1d46356d9e
8 changed files with 255 additions and 7 deletions

View file

@ -300,6 +300,49 @@ def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> Inboun
)
def _parse_message_attachments(raw: Any) -> list[dict[str, Any]]:
if raw is None:
return []
obj = raw
if isinstance(raw, str):
s = str(raw or "").strip()
if not s:
return []
try:
import json
obj = json.loads(s)
except Exception:
return []
if not isinstance(obj, list):
return []
return [x for x in obj if isinstance(x, dict)]
def _collect_reply_attachments_from_history(*, store: Any, session_id: str, reply_text: str) -> list[dict[str, Any]]:
sid = str(session_id or "").strip()
if not sid:
return []
try:
rows = store.get_messages(session_id=sid, limit=120)
except Exception:
rows = []
target = str(reply_text or "").strip()
fallback: list[dict[str, Any]] = []
matched: list[dict[str, Any]] = []
for row in rows or []:
role = str(getattr(row, "role", "") or "").strip().lower()
if role != "assistant":
continue
atts = _parse_message_attachments(getattr(row, "attachments", None))
if atts:
fallback = atts
content = str(getattr(row, "content", "") or "").strip()
if target and content == target and atts:
matched = atts
return matched or fallback
def _should_suppress_channel_reply(*, channel: str, text: str) -> bool:
ch = str(channel or "").strip().lower()
if ch not in {"wechat", "weixin"}:
@ -364,6 +407,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
reply = ("绑定成功。\n\n" + _menu_text()) if info else "绑定失败:无效或已使用的绑定码。"
else:
reply = ""
reply_attachments: list[dict[str, Any]] = []
ident = store.resolve_user_by_channel_identity_v2(
channel=inbound.channel,
account_id=account_id,
@ -506,10 +550,17 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
).reply_text
or ""
).strip()
reply_attachments = _collect_reply_attachments_from_history(
store=store,
session_id=str(session_id),
reply_text=reply,
)
except Exception as e:
reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
reply_attachments = []
else:
reply = "收到消息,但内容为空。请直接发送文本。"
reply_attachments = []
if preface:
if reply:
reply = f"{preface}\n\n{reply}"
@ -527,7 +578,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
"channel": inbound.channel,
"chat_id": inbound.external_chat_id,
"text": reply,
"attachments": [],
"attachments": list(reply_attachments or []),
"metadata": {},
}
]

View file

@ -281,6 +281,32 @@ function buildAttachmentsFromMedia(mediaOpts: Json): Json[] {
return out;
}
function _decodeReplyBase64Attachment(att: Json): { filePath: string; mime: string } | null {
const b64Raw = String(
att.data_base64 || att.media_base64 || att.image_base64 || att.video_base64 || att.audio_base64 || att.data || "",
).trim();
if (!b64Raw) return null;
let mime = String(att.media_type || att.mime_type || att.mime || "application/octet-stream").trim();
let payload = b64Raw;
const m = b64Raw.match(/^data:([^;,]+);base64,(.*)$/i);
if (m) {
mime = String(m[1] || mime).trim() || mime;
payload = String(m[2] || "").trim();
}
try {
const buf = Buffer.from(payload.replace(/\s+/g, ""), "base64");
if (!buf.length) return null;
const dir = path.join(STATE_DIR, "media", "outbound");
ensureDir(dir);
const ext = inferExtension(mime || "application/octet-stream", String(att.name || att.filename || "").trim());
const filePath = path.join(dir, `${Date.now()}-${Math.random().toString(16).slice(2, 10)}${ext}`);
fs.writeFileSync(filePath, buf);
return { filePath, mime };
} catch {
return null;
}
}
async function handleInboundMessage(
modules: OfficialModules,
params: {
@ -349,8 +375,18 @@ async function handleInboundMessage(
const replyContextToken = String(
reply.context_token || modules.getContextToken(params.accountId, deliverTo) || contextToken || "",
).trim();
if (mediaPath || mediaUrl) {
const filePath = mediaPath || mediaUrl;
const replyAtts = Array.isArray(reply.attachments) ? (reply.attachments as Json[]) : [];
let inlineMediaPath = "";
for (const att of replyAtts) {
if (!att || typeof att !== "object") continue;
const decoded = _decodeReplyBase64Attachment(att);
if (decoded?.filePath) {
inlineMediaPath = decoded.filePath;
break;
}
}
if (mediaPath || mediaUrl || inlineMediaPath) {
const filePath = mediaPath || mediaUrl || inlineMediaPath;
await modules.sendWeixinMediaFile({
filePath,
to: deliverTo,

View file

@ -76,6 +76,72 @@ function buildInboundPayload(params: {
};
}
function decodeBase64Payload(raw: string): { mime: string; data: Buffer } | null {
const s = String(raw || "").trim();
if (!s) return null;
let mime = "application/octet-stream";
let payload = s;
const m = s.match(/^data:([^;,]+);base64,(.*)$/i);
if (m) {
mime = String(m[1] || mime).trim() || mime;
payload = String(m[2] || "").trim();
}
try {
const data = Buffer.from(payload.replace(/\s+/g, ""), "base64");
if (!data.length) return null;
return { mime, data };
} catch {
return null;
}
}
async function sendReplyWithAttachments(params: {
sock: ReturnType<typeof makeWASocket> | null;
deliverTo: string;
text: string;
reply: Json;
}): Promise<void> {
const s = params.sock;
if (!s) return;
const reply = params.reply || {};
const outText = String(params.text || "").trim();
const mediaPath = String((reply as any).media_path || (reply as any).mediaPath || "").trim();
const mediaUrl = String((reply as any).media_url || (reply as any).mediaUrl || "").trim();
const attachments = Array.isArray((reply as any).attachments) ? ((reply as any).attachments as Json[]) : [];
const sendMediaRef = async (source: string): Promise<boolean> => {
if (!source) return false;
const msg: Json = { document: source as any };
if (outText) (msg as any).caption = outText;
await s.sendMessage(params.deliverTo, msg as any);
return true;
};
if (await sendMediaRef(mediaPath || mediaUrl)) return;
for (const att of attachments) {
if (!att || typeof att !== "object") continue;
const raw = String(
(att as any).data_base64 || (att as any).media_base64 || (att as any).image_base64 || (att as any).data || "",
).trim();
if (!raw) continue;
const decoded = decodeBase64Payload(raw);
if (!decoded) continue;
const msg: Json = {
document: decoded.data as any,
mimetype: decoded.mime,
fileName: String((att as any).name || (att as any).filename || "attachment.bin"),
};
if (outText) (msg as any).caption = outText;
await s.sendMessage(params.deliverTo, msg as any);
return;
}
if (outText) {
await s.sendMessage(params.deliverTo, { text: outText });
}
}
async function postInbound(payload: Json): Promise<Json> {
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/inbound/whatsapp`;
const res = await fetch(url, {
@ -250,9 +316,8 @@ async function main(): Promise<void> {
if (VERBOSE) log(`inbound ok replies=${replies.length}`);
for (const r of replies) {
const outText = String((r as any).text || "").trim();
if (!outText) continue;
const deliverTo = String((r as any).chat_id || chatId).trim() || chatId;
await sock?.sendMessage(deliverTo, { text: outText });
await sendReplyWithAttachments({ sock, deliverTo, text: outText, reply: r });
}
} catch (err) {
log(`handle message error: ${String(err)}`);