feat(channel): unify deliverable outbound and WeChat attachments

Require deliverable:true for all channel attachments including images; extend save_deliverable_attachment with attachment_id; add WeChat proactive attachment delivery; inject channel file rules via system prompt instead of repeating on every user message.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-13 15:56:50 +08:00
parent dc5da21f5a
commit cbe68a54cd
14 changed files with 440 additions and 125 deletions

View file

@ -235,7 +235,10 @@ async function flushWeixinDbOutbound(args: {
const toUser = String(item.chat_id || "").trim();
const text = String(item.text || "").trim();
const msgId = String(item.id || "").trim();
if (!toUser || !text || !msgId) continue;
const mediaPath = String(item.media_path || item.mediaPath || "").trim();
const attachments = Array.isArray(item.attachments) ? (item.attachments as Json[]) : [];
if (!toUser || !msgId) continue;
if (!text && !mediaPath && !attachments.length) continue;
const contextToken = String(
(item.context_token as string) ||
args.modules.getContextToken(args.accountId, toUser) ||
@ -247,14 +250,20 @@ async function flushWeixinDbOutbound(args: {
continue;
}
try {
await args.modules.sendMessageWeixin({
to: toUser,
text,
opts: {
baseUrl: args.cloudBaseUrl,
token: args.token,
contextToken,
await deliverWeixinReply(args.modules, {
reply: {
chat_id: toUser,
text,
media_path: mediaPath,
attachments,
context_token: contextToken,
},
defaultTo: toUser,
cloudBaseUrl: args.cloudBaseUrl,
token: args.token,
accountId: args.accountId,
defaultContextToken: contextToken,
userContextTokens: args.userContextTokens,
});
await postLocalJson(args.token, "weixin/outbound/ack", { id: msgId, ok: true }, 8000);
log(`db proactive reply sent: id=${msgId} to=${toUser} textLen=${text.length}`);
@ -307,7 +316,10 @@ async function flushLocalProactiveReplies(args: {
for (const r of msgs) {
const toUser = String(r.chat_id || "").trim();
const text = String(r.text || "").trim();
if (!toUser || !text) continue;
const mediaPath = String(r.media_path || r.mediaPath || "").trim();
const attachments = Array.isArray(r.attachments) ? (r.attachments as Json[]) : [];
if (!toUser) continue;
if (!text && !mediaPath && !attachments.length) continue;
const contextToken = String(
(r.context_token as string) ||
args.modules.getContextToken(args.accountId, toUser) ||
@ -320,14 +332,20 @@ async function flushLocalProactiveReplies(args: {
continue;
}
try {
await args.modules.sendMessageWeixin({
to: toUser,
text,
opts: {
baseUrl: args.cloudBaseUrl,
token: args.token,
contextToken,
await deliverWeixinReply(args.modules, {
reply: {
chat_id: toUser,
text,
media_path: mediaPath,
attachments,
context_token: contextToken,
},
defaultTo: toUser,
cloudBaseUrl: args.cloudBaseUrl,
token: args.token,
accountId: args.accountId,
defaultContextToken: contextToken,
userContextTokens: args.userContextTokens,
});
log(`proactive reply sent: to=${toUser} textLen=${text.length}`);
} catch (err) {
@ -450,16 +468,45 @@ function pickDownloadableMedia(msg: Json): Json | null {
return null;
}
function buildAttachmentsFromMedia(mediaOpts: Json): Json[] {
function attachmentNameFromMediaItem(item: Json | null, kind: string): string {
if (!item || typeof item !== "object") return "";
if (kind === "file") {
const fileItem = item.file_item;
if (fileItem && typeof fileItem === "object") {
return String((fileItem as Json).file_name || (fileItem as Json).filename || (fileItem as Json).name || "").trim();
}
}
if (kind === "image") {
const imageItem = item.image_item;
if (imageItem && typeof imageItem === "object") {
return String((imageItem as Json).file_name || (imageItem as Json).filename || "").trim();
}
}
return "";
}
function buildAttachmentsFromMedia(mediaOpts: Json, mediaItem?: Json | null): Json[] {
const out: Json[] = [];
const pushIf = (key: string, mimeKey: string, kind: string): void => {
const filePath = String(mediaOpts[key] || "").trim();
if (!filePath) return;
out.push({
const name = String(
mediaOpts.fileOriginalName ||
mediaOpts.originalFilename ||
mediaOpts.file_name ||
attachmentNameFromMediaItem(mediaItem || null, kind) ||
"",
).trim();
const att: Json = {
kind,
local_path: filePath,
media_type: String(mediaOpts[mimeKey] || "").trim(),
});
};
if (name) {
att.name = name;
att.filename = name;
}
out.push(att);
};
pushIf("decryptedPicPath", "", "image");
pushIf("decryptedVideoPath", "", "video");
@ -468,6 +515,70 @@ function buildAttachmentsFromMedia(mediaOpts: Json): Json[] {
return out;
}
async function deliverWeixinReply(
modules: OfficialModules,
params: {
reply: Json;
defaultTo: string;
cloudBaseUrl: string;
token: string;
accountId: string;
defaultContextToken?: string;
userContextTokens?: TokenMap;
},
): Promise<boolean> {
const reply = params.reply;
const text = String(reply.text || "").trim();
const mediaPath = String(reply.media_path || reply.mediaPath || "").trim();
const mediaUrl = String(reply.media_url || reply.mediaUrl || "").trim();
const deliverTo = String(reply.chat_id || reply.to || params.defaultTo).trim() || params.defaultTo;
const replyContextToken = String(
reply.context_token ||
modules.getContextToken(params.accountId, deliverTo) ||
params.defaultContextToken ||
params.userContextTokens?.[deliverTo] ||
"",
).trim();
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,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
});
log(`reply media sent to=${deliverTo} textLen=${text.length}`);
return true;
}
if (!text) return false;
await modules.sendMessageWeixin({
to: deliverTo,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
});
log(`reply text sent to=${deliverTo} textLen=${text.length}`);
return true;
}
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 || "",
@ -551,7 +662,8 @@ async function handleInboundMessage(
}
const ctx = modules.weixinMessageToMsgContext(params.full, params.accountId, mediaOpts);
const bodyText = String((ctx as Json).Body || "").trim();
const attachCount = buildAttachmentsFromMedia(mediaOpts).length;
const inboundAttachments = buildAttachmentsFromMedia(mediaOpts, mediaItem);
const attachCount = inboundAttachments.length;
log(`native call bodyLen=${bodyText.length} attachments=${attachCount}`);
let replies: Json[] = [];
const tNative0 = Date.now();
@ -560,7 +672,7 @@ async function handleInboundMessage(
channel: "wechat",
account_id: params.accountId,
ctx,
attachments: buildAttachmentsFromMedia(mediaOpts),
attachments: inboundAttachments,
metadata: {
source: "weixin_official_native",
raw: {
@ -586,50 +698,15 @@ async function handleInboundMessage(
return params.localCursor;
}
for (const reply of replies) {
const text = String(reply.text || "").trim();
const mediaPath = String(reply.media_path || reply.mediaPath || "").trim();
const mediaUrl = String(reply.media_url || reply.mediaUrl || "").trim();
const deliverTo = String(reply.chat_id || reply.to || fromUser).trim() || fromUser;
const replyContextToken = String(
reply.context_token || modules.getContextToken(params.accountId, deliverTo) || contextToken || "",
).trim();
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,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
});
log(`reply media sent to=${deliverTo} textLen=${text.length}`);
continue;
}
if (!text) continue;
await modules.sendMessageWeixin({
to: deliverTo,
text,
opts: {
baseUrl: params.cloudBaseUrl,
token: params.token,
contextToken: replyContextToken || undefined,
},
await deliverWeixinReply(modules, {
reply,
defaultTo: fromUser,
cloudBaseUrl: params.cloudBaseUrl,
token: params.token,
accountId: params.accountId,
defaultContextToken: contextToken || undefined,
userContextTokens: params.userContextTokens,
});
log(`reply text sent to=${deliverTo} textLen=${text.length}`);
}
return params.localCursor;
}