2727 lines
113 KiB
Python
Executable File
2727 lines
113 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Patch openclaw-weixin so /hermes and real image ingress route to Hermes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
|
|
DEFAULT_PLUGIN_ROOT = pathlib.Path(
|
|
"/var/lib/aios/openclaw-session/npm/projects/"
|
|
"tencent-weixin-openclaw-weixin-7783ac86ba/node_modules/"
|
|
"@tencent-weixin/openclaw-weixin"
|
|
)
|
|
|
|
|
|
TS_HERMES_HELPERS = r'''
|
|
const DEFAULT_HERMES_ROUTER_URL = "http://127.0.0.1:18791/internal/hermes/route";
|
|
|
|
function pad2(value: number): string {
|
|
return String(value).padStart(2, "0");
|
|
}
|
|
|
|
function makeHermesRouteId(): string {
|
|
const now = new Date();
|
|
const stamp = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
|
|
return `wx-hermes-${stamp}-${randomBytes(4).toString("hex")}`;
|
|
}
|
|
|
|
function hermesRouterUrl(): string {
|
|
return (process.env.AIOS_HERMES_ROUTER_URL || DEFAULT_HERMES_ROUTER_URL).trim();
|
|
}
|
|
|
|
function maxHermesImageBytes(): number {
|
|
const parsed = Number.parseInt(String(process.env.AIOS_HERMES_MAX_IMAGE_BYTES ?? "10485760"), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10485760;
|
|
}
|
|
|
|
const SUPPORTED_HERMES_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
|
|
function hermesImageMimeFromPath(filePath: string, fallback?: string): string {
|
|
const normalizedFallback = String(fallback ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
if (normalizedFallback === "image/jpg") return "image/jpeg";
|
|
if (SUPPORTED_HERMES_IMAGE_MIME.has(normalizedFallback)) return normalizedFallback;
|
|
const lower = filePath.toLowerCase();
|
|
if (lower.endsWith(".png")) return "image/png";
|
|
if (lower.endsWith(".webp")) return "image/webp";
|
|
return "image/jpeg";
|
|
}
|
|
|
|
function localHermesImageToDataUrl(filePath: string, fallbackMime?: string): string | null {
|
|
const trimmed = String(filePath ?? "").trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
if (!existsSync(trimmed)) return null;
|
|
const stat = statSync(trimmed);
|
|
if (!stat.isFile() || stat.size <= 0 || stat.size > maxHermesImageBytes()) return null;
|
|
const mime = hermesImageMimeFromPath(trimmed, fallbackMime);
|
|
if (!mime.startsWith("image/")) return null;
|
|
return `data:${mime};base64,${readFileSync(trimmed).toString("base64")}`;
|
|
} catch (err) {
|
|
logger.warn(`[weixin] hermes local image read skipped err=${String(err).slice(0, 160)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeHermesImageReference(value: unknown, fallbackMime?: string): string | null {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return null;
|
|
const lower = text.toLowerCase();
|
|
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("data:image/")) return text;
|
|
return localHermesImageToDataUrl(text, fallbackMime);
|
|
}
|
|
|
|
function collectHermesImageUrls(value: unknown, urls: string[], depth = 0, fallbackMime?: string): void {
|
|
if (depth > 6 || urls.length >= 4 || value == null) return;
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
try {
|
|
collectHermesImageUrls(JSON.parse(trimmed), urls, depth + 1, fallbackMime);
|
|
return;
|
|
} catch {
|
|
// Keep treating the value as a plain string.
|
|
}
|
|
}
|
|
const url = normalizeHermesImageReference(trimmed, fallbackMime);
|
|
if (url && !urls.includes(url)) urls.push(url);
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectHermesImageUrls(item, urls, depth + 1, fallbackMime);
|
|
return;
|
|
}
|
|
if (typeof value !== "object") return;
|
|
const obj = value as Record<string, unknown>;
|
|
const objectMime = String(obj.MediaType ?? obj.mediaType ?? obj.mimeType ?? obj.mimetype ?? fallbackMime ?? "");
|
|
for (const [key, item] of Object.entries(obj)) {
|
|
const normalized = key.replace(/-/g, "_").toLowerCase();
|
|
if (
|
|
["image_url", "imageurl", "image_urls", "media_url", "mediaurl", "media_urls", "pic_url", "picurl", "thumb_url", "thumburl", "file_url", "fileurl", "url"].includes(normalized) ||
|
|
["media_path", "mediapath", "image_path", "imagepath", "local_path", "localpath"].includes(normalized) ||
|
|
(normalized.includes("image") && (normalized.includes("url") || normalized.includes("path")))
|
|
) {
|
|
collectHermesImageUrls(item, urls, depth + 1, objectMime);
|
|
}
|
|
}
|
|
for (const key of ["image", "images", "media", "message", "msg", "event", "payload", "rawMessage", "raw_message", "inboundContext", "ctx"]) {
|
|
if (key in obj) collectHermesImageUrls(obj[key], urls, depth + 1, objectMime);
|
|
}
|
|
}
|
|
|
|
function hermesMessageType(ctx: SlashCommandContext): string {
|
|
const anyCtx = ctx as any;
|
|
const raw = String(anyCtx.messageType ?? anyCtx.msgType ?? anyCtx.type ?? anyCtx.message?.type ?? anyCtx.event?.type ?? "").toLowerCase();
|
|
const mediaType = String(anyCtx.mediaType ?? anyCtx.MediaType ?? anyCtx.inboundContext?.MediaType ?? "").toLowerCase();
|
|
if (raw.includes("image") || raw.includes("photo") || raw.includes("picture") || mediaType.startsWith("image/")) return "image";
|
|
return "text";
|
|
}
|
|
|
|
function hermesAutoRouteImagesEnabled(): boolean {
|
|
const value = String(process.env.AIOS_HERMES_AUTO_ROUTE_IMAGES ?? "true").trim().toLowerCase();
|
|
return !["0", "false", "no", "off"].includes(value);
|
|
}
|
|
|
|
function buildHermesImageUrls(ctx: SlashCommandContext): string[] {
|
|
const imageUrls: string[] = [];
|
|
collectHermesImageUrls(ctx as any, imageUrls);
|
|
return imageUrls;
|
|
}
|
|
|
|
function shouldAutoRouteHermesImage(ctx: SlashCommandContext): boolean {
|
|
if (!hermesAutoRouteImagesEnabled()) return false;
|
|
return buildHermesImageUrls(ctx).length > 0 || hermesMessageType(ctx) === "image";
|
|
}
|
|
|
|
async function handleHermes(
|
|
ctx: SlashCommandContext,
|
|
args: string,
|
|
receivedAt: number,
|
|
eventTimestamp?: number,
|
|
routeReason = "explicit_user_prefix",
|
|
): Promise<void> {
|
|
const userPrompt = String(args ?? "").trim();
|
|
const imageUrls = buildHermesImageUrls(ctx);
|
|
const messageType = imageUrls.length > 0 || hermesMessageType(ctx) === "image" ? "image" : "text";
|
|
const visionRequired = messageType === "image";
|
|
const imageSource = String((ctx as any).imageSource ?? "");
|
|
const imageFollowupReason = String((ctx as any).imageFollowupReason ?? "");
|
|
const imageFollowupStrategy = String((ctx as any).imageFollowupStrategy ?? "");
|
|
const quotedMessageId = String((ctx as any).quotedMessageId ?? "");
|
|
const visionMissingReason = visionRequired && imageUrls.length === 0
|
|
? (imageSource === "quoted_unresolved" ? "referenced_image_unresolved" : (imageFollowupReason || "missing_image_url"))
|
|
: "";
|
|
const effectivePrompt = userPrompt || (visionRequired ? "请识别这张图片的主要内容,并说明是否需要进一步处理。" : "Report the injected Hermes route metadata and current memory readback.");
|
|
const routeId = makeHermesRouteId();
|
|
logger.info(`[weixin] hermes route submit route_id=${routeId} route_target=hermes route_reason=${routeReason} channel=openclaw-weixin source=personal_wechat message_type=${messageType} image_url_count=${imageUrls.length} image_source=${imageSource || "none"} cache_strategy=${imageFollowupStrategy || "none"} quoted_message_id=${quotedMessageId || "none"} missing_reason=${visionMissingReason || "none"} accountId=${ctx.accountId} to=${ctx.to}`);
|
|
const failText = `ROUTE_TARGET=FAIL\nFAIL_REASON=Hermes unavailable or route not configured\nAIOS_ROUTE_ID=${routeId}`;
|
|
const payload = {
|
|
route_id: routeId,
|
|
route_target: "hermes",
|
|
route_reason: routeReason,
|
|
channel: "openclaw-weixin",
|
|
source: "personal_wechat",
|
|
user_prompt: effectivePrompt,
|
|
message_type: messageType,
|
|
vision_required: visionRequired,
|
|
image_urls: imageUrls,
|
|
image_url_count: imageUrls.length,
|
|
image_source: imageSource,
|
|
image_followup_reason: imageFollowupReason,
|
|
image_followup_strategy: imageFollowupStrategy,
|
|
quoted_message_id: quotedMessageId,
|
|
vision_missing_reason: visionMissingReason,
|
|
wechat_user_id: ctx.to,
|
|
account_id: ctx.accountId,
|
|
context_token_present: Boolean(ctx.contextToken),
|
|
received_at: receivedAt,
|
|
event_timestamp: eventTimestamp ?? null,
|
|
};
|
|
try {
|
|
const resp = await fetch(hermesRouterUrl(), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(120000),
|
|
});
|
|
let data: any = {};
|
|
try {
|
|
data = await resp.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
if (!resp.ok || !data?.ok) {
|
|
logger.error(`[weixin] hermes route failed route_id=${routeId} status=${resp.status}`);
|
|
await sendReply(ctx, data?.reply_text || failText);
|
|
logger.error(`[weixin] hermes fail reply delivered route_id=${routeId} route_target=FAIL executor=FAIL`);
|
|
return;
|
|
}
|
|
await sendReply(ctx, String(data.reply_text || failText));
|
|
logger.info(`[weixin] hermes reply delivered route_id=${routeId} route_target=hermes executor=hermes message_type=${messageType} image_url_count=${imageUrls.length} memory_write_id=${data.memory_write_id || "none"} memory_read_id=${data.memory_read_id || "none"}`);
|
|
} catch (err) {
|
|
logger.error(`[weixin] hermes route exception route_id=${routeId} err=${String(err)}`);
|
|
await sendReply(ctx, failText);
|
|
logger.error(`[weixin] hermes fail reply delivered route_id=${routeId} route_target=FAIL executor=FAIL`);
|
|
}
|
|
}
|
|
|
|
export async function handleHermesImplicitImage(
|
|
content: string,
|
|
ctx: SlashCommandContext,
|
|
receivedAt: number,
|
|
eventTimestamp?: number,
|
|
): Promise<SlashCommandResult> {
|
|
if (!shouldAutoRouteHermesImage(ctx)) return { handled: false };
|
|
await handleHermes(ctx, content, receivedAt, eventTimestamp, "implicit_image_message");
|
|
return { handled: true };
|
|
}
|
|
'''.strip()
|
|
|
|
|
|
JS_HERMES_HELPERS = r'''
|
|
const DEFAULT_HERMES_ROUTER_URL = "http://127.0.0.1:18791/internal/hermes/route";
|
|
function pad2(value) {
|
|
return String(value).padStart(2, "0");
|
|
}
|
|
function makeHermesRouteId() {
|
|
const now = new Date();
|
|
const stamp = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
|
|
return `wx-hermes-${stamp}-${randomBytes(4).toString("hex")}`;
|
|
}
|
|
function hermesRouterUrl() {
|
|
return (process.env.AIOS_HERMES_ROUTER_URL || DEFAULT_HERMES_ROUTER_URL).trim();
|
|
}
|
|
function maxHermesImageBytes() {
|
|
const parsed = Number.parseInt(String(process.env.AIOS_HERMES_MAX_IMAGE_BYTES ?? "10485760"), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10485760;
|
|
}
|
|
const SUPPORTED_HERMES_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
function hermesImageMimeFromPath(filePath, fallback) {
|
|
const normalizedFallback = String(fallback ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
if (normalizedFallback === "image/jpg")
|
|
return "image/jpeg";
|
|
if (SUPPORTED_HERMES_IMAGE_MIME.has(normalizedFallback))
|
|
return normalizedFallback;
|
|
const lower = filePath.toLowerCase();
|
|
if (lower.endsWith(".png"))
|
|
return "image/png";
|
|
if (lower.endsWith(".webp"))
|
|
return "image/webp";
|
|
return "image/jpeg";
|
|
}
|
|
function localHermesImageToDataUrl(filePath, fallbackMime) {
|
|
const trimmed = String(filePath ?? "").trim();
|
|
if (!trimmed)
|
|
return null;
|
|
try {
|
|
if (!existsSync(trimmed))
|
|
return null;
|
|
const stat = statSync(trimmed);
|
|
if (!stat.isFile() || stat.size <= 0 || stat.size > maxHermesImageBytes())
|
|
return null;
|
|
const mime = hermesImageMimeFromPath(trimmed, fallbackMime);
|
|
if (!mime.startsWith("image/"))
|
|
return null;
|
|
return `data:${mime};base64,${readFileSync(trimmed).toString("base64")}`;
|
|
}
|
|
catch (err) {
|
|
logger.warn(`[weixin] hermes local image read skipped err=${String(err).slice(0, 160)}`);
|
|
return null;
|
|
}
|
|
}
|
|
function normalizeHermesImageReference(value, fallbackMime) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text)
|
|
return null;
|
|
const lower = text.toLowerCase();
|
|
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("data:image/"))
|
|
return text;
|
|
return localHermesImageToDataUrl(text, fallbackMime);
|
|
}
|
|
function collectHermesImageUrls(value, urls, depth = 0, fallbackMime) {
|
|
if (depth > 6 || urls.length >= 4 || value == null)
|
|
return;
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
try {
|
|
collectHermesImageUrls(JSON.parse(trimmed), urls, depth + 1, fallbackMime);
|
|
return;
|
|
}
|
|
catch {
|
|
}
|
|
}
|
|
const url = normalizeHermesImageReference(trimmed, fallbackMime);
|
|
if (url && !urls.includes(url))
|
|
urls.push(url);
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const item of value)
|
|
collectHermesImageUrls(item, urls, depth + 1, fallbackMime);
|
|
return;
|
|
}
|
|
if (typeof value !== "object")
|
|
return;
|
|
const obj = value;
|
|
const objectMime = String(obj.MediaType ?? obj.mediaType ?? obj.mimeType ?? obj.mimetype ?? fallbackMime ?? "");
|
|
for (const [key, item] of Object.entries(obj)) {
|
|
const normalized = key.replace(/-/g, "_").toLowerCase();
|
|
if (["image_url", "imageurl", "image_urls", "media_url", "mediaurl", "media_urls", "pic_url", "picurl", "thumb_url", "thumburl", "file_url", "fileurl", "url"].includes(normalized) ||
|
|
["media_path", "mediapath", "image_path", "imagepath", "local_path", "localpath"].includes(normalized) ||
|
|
(normalized.includes("image") && (normalized.includes("url") || normalized.includes("path")))) {
|
|
collectHermesImageUrls(item, urls, depth + 1, objectMime);
|
|
}
|
|
}
|
|
for (const key of ["image", "images", "media", "message", "msg", "event", "payload", "rawMessage", "raw_message", "inboundContext", "ctx"]) {
|
|
if (key in obj)
|
|
collectHermesImageUrls(obj[key], urls, depth + 1, objectMime);
|
|
}
|
|
}
|
|
function hermesMessageType(ctx) {
|
|
const raw = String(ctx.messageType ?? ctx.msgType ?? ctx.type ?? ctx.message?.type ?? ctx.event?.type ?? "").toLowerCase();
|
|
const mediaType = String(ctx.mediaType ?? ctx.MediaType ?? ctx.inboundContext?.MediaType ?? "").toLowerCase();
|
|
if (raw.includes("image") || raw.includes("photo") || raw.includes("picture") || mediaType.startsWith("image/"))
|
|
return "image";
|
|
return "text";
|
|
}
|
|
function hermesAutoRouteImagesEnabled() {
|
|
const value = String(process.env.AIOS_HERMES_AUTO_ROUTE_IMAGES ?? "true").trim().toLowerCase();
|
|
return !["0", "false", "no", "off"].includes(value);
|
|
}
|
|
function buildHermesImageUrls(ctx) {
|
|
const imageUrls = [];
|
|
collectHermesImageUrls(ctx, imageUrls);
|
|
return imageUrls;
|
|
}
|
|
function shouldAutoRouteHermesImage(ctx) {
|
|
if (!hermesAutoRouteImagesEnabled())
|
|
return false;
|
|
return buildHermesImageUrls(ctx).length > 0 || hermesMessageType(ctx) === "image";
|
|
}
|
|
async function handleHermes(ctx, args, receivedAt, eventTimestamp, routeReason = "explicit_user_prefix") {
|
|
const userPrompt = String(args ?? "").trim();
|
|
const imageUrls = buildHermesImageUrls(ctx);
|
|
const messageType = imageUrls.length > 0 || hermesMessageType(ctx) === "image" ? "image" : "text";
|
|
const visionRequired = messageType === "image";
|
|
const imageSource = String(ctx.imageSource ?? "");
|
|
const imageFollowupReason = String(ctx.imageFollowupReason ?? "");
|
|
const imageFollowupStrategy = String(ctx.imageFollowupStrategy ?? "");
|
|
const quotedMessageId = String(ctx.quotedMessageId ?? "");
|
|
const visionMissingReason = visionRequired && imageUrls.length === 0
|
|
? (imageSource === "quoted_unresolved" ? "referenced_image_unresolved" : (imageFollowupReason || "missing_image_url"))
|
|
: "";
|
|
const effectivePrompt = userPrompt || (visionRequired ? "请识别这张图片的主要内容,并说明是否需要进一步处理。" : "Report the injected Hermes route metadata and current memory readback.");
|
|
const routeId = makeHermesRouteId();
|
|
logger.info(`[weixin] hermes route submit route_id=${routeId} route_target=hermes route_reason=${routeReason} channel=openclaw-weixin source=personal_wechat message_type=${messageType} image_url_count=${imageUrls.length} image_source=${imageSource || "none"} cache_strategy=${imageFollowupStrategy || "none"} quoted_message_id=${quotedMessageId || "none"} missing_reason=${visionMissingReason || "none"} accountId=${ctx.accountId} to=${ctx.to}`);
|
|
const failText = `ROUTE_TARGET=FAIL\nFAIL_REASON=Hermes unavailable or route not configured\nAIOS_ROUTE_ID=${routeId}`;
|
|
const payload = {
|
|
route_id: routeId,
|
|
route_target: "hermes",
|
|
route_reason: routeReason,
|
|
channel: "openclaw-weixin",
|
|
source: "personal_wechat",
|
|
user_prompt: effectivePrompt,
|
|
message_type: messageType,
|
|
vision_required: visionRequired,
|
|
image_urls: imageUrls,
|
|
image_url_count: imageUrls.length,
|
|
image_source: imageSource,
|
|
image_followup_reason: imageFollowupReason,
|
|
image_followup_strategy: imageFollowupStrategy,
|
|
quoted_message_id: quotedMessageId,
|
|
vision_missing_reason: visionMissingReason,
|
|
wechat_user_id: ctx.to,
|
|
account_id: ctx.accountId,
|
|
context_token_present: Boolean(ctx.contextToken),
|
|
received_at: receivedAt,
|
|
event_timestamp: eventTimestamp ?? null,
|
|
};
|
|
try {
|
|
const resp = await fetch(hermesRouterUrl(), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(120000),
|
|
});
|
|
let data = {};
|
|
try {
|
|
data = await resp.json();
|
|
}
|
|
catch {
|
|
data = {};
|
|
}
|
|
if (!resp.ok || !data?.ok) {
|
|
logger.error(`[weixin] hermes route failed route_id=${routeId} status=${resp.status}`);
|
|
await sendReply(ctx, data?.reply_text || failText);
|
|
logger.error(`[weixin] hermes fail reply delivered route_id=${routeId} route_target=FAIL executor=FAIL`);
|
|
return;
|
|
}
|
|
await sendReply(ctx, String(data.reply_text || failText));
|
|
logger.info(`[weixin] hermes reply delivered route_id=${routeId} route_target=hermes executor=hermes message_type=${messageType} image_url_count=${imageUrls.length} memory_write_id=${data.memory_write_id || "none"} memory_read_id=${data.memory_read_id || "none"}`);
|
|
}
|
|
catch (err) {
|
|
logger.error(`[weixin] hermes route exception route_id=${routeId} err=${String(err)}`);
|
|
await sendReply(ctx, failText);
|
|
logger.error(`[weixin] hermes fail reply delivered route_id=${routeId} route_target=FAIL executor=FAIL`);
|
|
}
|
|
}
|
|
export async function handleHermesImplicitImage(content, ctx, receivedAt, eventTimestamp) {
|
|
if (!shouldAutoRouteHermesImage(ctx))
|
|
return { handled: false };
|
|
await handleHermes(ctx, content, receivedAt, eventTimestamp, "implicit_image_message");
|
|
return { handled: true };
|
|
}
|
|
'''.strip()
|
|
|
|
|
|
TS_PROCESS_HELPERS = r'''
|
|
// AIOS Hermes image follow-up helpers begin
|
|
type HermesLastImageEntry = {
|
|
mediaPath?: string;
|
|
mediaUrl?: string;
|
|
mediaType?: string;
|
|
timestampMs: number;
|
|
messageId?: string;
|
|
};
|
|
|
|
type HermesFollowupImageContext = {
|
|
imageIntent: boolean;
|
|
matched: boolean;
|
|
reason: string;
|
|
entry?: HermesLastImageEntry;
|
|
ageMs?: number;
|
|
cacheStrategy?: string;
|
|
quotedMessageId?: string;
|
|
};
|
|
|
|
type HermesQuotedTextContext = {
|
|
referenced: boolean;
|
|
handled: boolean;
|
|
prompt?: string;
|
|
replyText?: string;
|
|
reason: "none" | "quoted_text_resolved" | "quoted_text_unavailable";
|
|
};
|
|
|
|
type HermesTextMessageEntry = {
|
|
text: string;
|
|
timestampMs: number;
|
|
conflict?: boolean;
|
|
};
|
|
|
|
const DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
|
|
const DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS = 15 * 60 * 1000;
|
|
const DEFAULT_HERMES_MEDIA_DEBUG_PATH = "/var/log/aios/hermes-route-memory/weixin_media_debug.jsonl";
|
|
const DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-message-text-cache.json";
|
|
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES = 500;
|
|
|
|
function hermesLastImageCachePath(): string {
|
|
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH).trim();
|
|
}
|
|
|
|
function hermesLastImageMaxAgeMs(): number {
|
|
const parsed = Number.parseInt(String(process.env.AIOS_HERMES_LAST_IMAGE_MAX_AGE_MS ?? DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS;
|
|
}
|
|
|
|
function hermesMediaDebugPath(): string {
|
|
return (process.env.AIOS_HERMES_WEIXIN_MEDIA_DEBUG || DEFAULT_HERMES_MEDIA_DEBUG_PATH).trim();
|
|
}
|
|
|
|
function hermesTextMessageCachePath(): string {
|
|
return (process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE || DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH).trim();
|
|
}
|
|
|
|
function hermesTextMessageCacheKey(accountId: string, senderId: string, messageId: string): string {
|
|
return `${accountId || "unknown"}:${senderId || "unknown"}:${messageId}`;
|
|
}
|
|
|
|
function hermesTextMessageTimeCacheKey(accountId: string, senderId: string, createTimeMs: string): string {
|
|
return `${accountId || "unknown"}:${senderId || "unknown"}:created:${createTimeMs}`;
|
|
}
|
|
|
|
function readHermesTextMessageCache(): Record<string, HermesTextMessageEntry> {
|
|
try {
|
|
const filePath = hermesTextMessageCachePath();
|
|
if (!existsSync(filePath)) return {};
|
|
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, HermesTextMessageEntry> : {};
|
|
} catch (err) {
|
|
logger.warn(`[weixin] Hermes text message cache read skipped err=${String(err).slice(0, 160)}`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeHermesTextMessageCache(cache: Record<string, HermesTextMessageEntry>): void {
|
|
try {
|
|
const filePath = hermesTextMessageCachePath();
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const now = Date.now();
|
|
const retained = Object.entries(cache)
|
|
.filter(([, entry]) => entry && typeof entry.text === "string" && now - Number(entry.timestampMs ?? 0) <= DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
|
|
.sort((a, b) => Number(b[1].timestampMs ?? 0) - Number(a[1].timestampMs ?? 0))
|
|
.slice(0, DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES);
|
|
writeFileSync(filePath, JSON.stringify(Object.fromEntries(retained), null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
} catch (err) {
|
|
logger.warn(`[weixin] Hermes text message cache write skipped err=${String(err).slice(0, 160)}`);
|
|
}
|
|
}
|
|
|
|
function rememberHermesTextMessage(accountId: string, senderId: string, full: any, text: string): void {
|
|
const normalizedText = String(text ?? "").trim().slice(0, 4000);
|
|
if (!normalizedText) return;
|
|
const messageIds = collectHermesMessageIds(full);
|
|
if (!messageIds.size) return;
|
|
const cache = readHermesTextMessageCache();
|
|
const timestampMs = Date.now();
|
|
for (const messageId of messageIds) {
|
|
cache[hermesTextMessageCacheKey(accountId, senderId, messageId)] = { text: normalizedText, timestampMs };
|
|
}
|
|
writeHermesTextMessageCache(cache);
|
|
}
|
|
|
|
function rememberHermesOutboundTextMessage(accountId: string, senderId: string, text: string, createTimeMs: number): void {
|
|
const normalizedText = String(text ?? "").trim().slice(0, 4000);
|
|
if (!normalizedText || !Number.isSafeInteger(createTimeMs) || createTimeMs <= 0) return;
|
|
const cache = readHermesTextMessageCache();
|
|
const key = hermesTextMessageTimeCacheKey(accountId, senderId, String(createTimeMs));
|
|
const existing = cache[key];
|
|
cache[key] = existing && existing.text !== normalizedText
|
|
? { text: "", timestampMs: Date.now(), conflict: true }
|
|
: { text: normalizedText, timestampMs: Date.now() };
|
|
writeHermesTextMessageCache(cache);
|
|
}
|
|
|
|
function resolveHermesCachedQuotedText(accountId: string, senderId: string, messageId?: string, createTimeMs?: string | number): string {
|
|
const normalizedId = String(messageId ?? "").trim();
|
|
const normalizedTime = String(createTimeMs ?? "").trim();
|
|
const cache = readHermesTextMessageCache();
|
|
const entry = (normalizedId ? cache[hermesTextMessageCacheKey(accountId, senderId, normalizedId)] : undefined) ??
|
|
(normalizedTime ? cache[hermesTextMessageTimeCacheKey(accountId, senderId, normalizedTime)] : undefined);
|
|
if (!entry || entry.conflict || Date.now() - Number(entry.timestampMs ?? 0) > DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS) return "";
|
|
return String(entry.text ?? "").trim().slice(0, 4000);
|
|
}
|
|
|
|
function hermesLastImageCacheKey(accountId: string, senderId: string): string {
|
|
return `${accountId || "unknown"}:${senderId || "unknown"}`;
|
|
}
|
|
|
|
function hermesExactImageCacheKey(accountId: string, senderId: string, messageId: string): string {
|
|
return `${hermesLastImageCacheKey(accountId, senderId)}:${messageId || "unknown-message"}`;
|
|
}
|
|
|
|
function hermesApproxMessageId(messageId: string): string {
|
|
const normalized = String(messageId ?? "").trim();
|
|
return /^\d{16,}$/.test(normalized) ? normalized.slice(0, -4) : "";
|
|
}
|
|
|
|
function hermesApproxImageCacheKey(accountId: string, senderId: string, messageId: string): string {
|
|
const approx = hermesApproxMessageId(messageId);
|
|
return approx ? `${hermesLastImageCacheKey(accountId, senderId)}:approx:${approx}` : "";
|
|
}
|
|
|
|
function sanitizeHermesMediaDebug(value: any, depth = 0): any {
|
|
if (depth > 6) return "[max-depth]";
|
|
if (value == null) return value;
|
|
if (typeof value === "string") return value.length > 160 ? `${value.slice(0, 160)}...` : value;
|
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
if (Array.isArray(value)) return value.slice(0, 8).map((item) => sanitizeHermesMediaDebug(item, depth + 1));
|
|
if (typeof value !== "object") return String(value);
|
|
const out: Record<string, any> = {};
|
|
for (const [key, item] of Object.entries(value)) {
|
|
const normalized = key.toLowerCase();
|
|
if (/(token|key|secret|auth|cookie|signature|encrypt_query_param|aes)/i.test(key)) {
|
|
out[key] = item ? "[present]" : item;
|
|
continue;
|
|
}
|
|
if (
|
|
normalized.includes("ref") ||
|
|
normalized.includes("quote") ||
|
|
normalized.includes("media") ||
|
|
normalized.includes("image") ||
|
|
normalized.includes("thumb") ||
|
|
normalized.includes("pic") ||
|
|
normalized.includes("url") ||
|
|
normalized.includes("path") ||
|
|
["type", "item_list", "itemlist", "message_item", "messageitem", "msg", "message", "payload", "text_item", "text", "title"].includes(normalized)
|
|
) {
|
|
out[key] = sanitizeHermesMediaDebug(item, depth + 1);
|
|
}
|
|
}
|
|
const keys = Object.keys(value);
|
|
if (!Object.keys(out).length) out.__keys = keys.slice(0, 40);
|
|
else out.__keys = keys.slice(0, 40);
|
|
return out;
|
|
}
|
|
|
|
function writeHermesMediaDebug(event: string, data: Record<string, any>): void {
|
|
try {
|
|
const filePath = hermesMediaDebugPath();
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
appendFileSync(filePath, `${JSON.stringify({ ts: new Date().toISOString(), event, ...data })}\n`, "utf-8");
|
|
} catch (err) {
|
|
logger.warn(`[weixin] Hermes media debug write skipped err=${String(err).slice(0, 160)}`);
|
|
}
|
|
}
|
|
|
|
function readHermesLastImageCache(): Record<string, HermesLastImageEntry> {
|
|
try {
|
|
const filePath = hermesLastImageCachePath();
|
|
if (!filePath || !existsSync(filePath)) return {};
|
|
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, HermesLastImageEntry> : {};
|
|
} catch (err) {
|
|
logger.warn(`[weixin] Hermes last image cache read skipped err=${String(err).slice(0, 160)}`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeHermesLastImageCache(cache: Record<string, HermesLastImageEntry>): void {
|
|
try {
|
|
const filePath = hermesLastImageCachePath();
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8");
|
|
} catch (err) {
|
|
logger.warn(`[weixin] Hermes last image cache write skipped err=${String(err).slice(0, 160)}`);
|
|
}
|
|
}
|
|
|
|
function hermesPathLooksLikeImage(filePath: string): boolean {
|
|
return /\.(?:jpe?g|png|webp|gif|bmp|heic)$/i.test(String(filePath ?? "").trim());
|
|
}
|
|
|
|
function hermesCurrentMessageIsImage(ctx: any, mediaItem: any): boolean {
|
|
const mediaType = String(ctx?.MediaType ?? ctx?.mediaType ?? "").toLowerCase();
|
|
const mediaPath = String(ctx?.MediaPath ?? ctx?.mediaPath ?? "");
|
|
const mediaUrl = String(ctx?.MediaUrl ?? ctx?.mediaUrl ?? "").toLowerCase();
|
|
return mediaType.startsWith("image/") ||
|
|
hermesPathLooksLikeImage(mediaPath) ||
|
|
mediaUrl.startsWith("data:image/") ||
|
|
mediaItem?.type === MessageItemType.IMAGE;
|
|
}
|
|
|
|
function hasHermesReferenceContext(value: any, depth = 0): boolean {
|
|
if (depth > 8 || value == null) return false;
|
|
if (Array.isArray(value)) return value.some((item) => hasHermesReferenceContext(item, depth + 1));
|
|
if (typeof value !== "object") return false;
|
|
const obj = value as Record<string, any>;
|
|
const referenceKeys = [
|
|
"ref_msg",
|
|
"refMsg",
|
|
"quoted_message",
|
|
"quotedMessage",
|
|
"quoted",
|
|
"quote",
|
|
"referenced_message",
|
|
"referencedMessage",
|
|
];
|
|
if (referenceKeys.some((key) => key in obj)) return true;
|
|
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj && hasHermesReferenceContext(obj[key], depth + 1)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function findHermesReferencedCreateTimeMs(value: any, depth = 0, insideReference = false): string | undefined {
|
|
if (depth > 8 || value == null) return undefined;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findHermesReferencedCreateTimeMs(item, depth + 1, insideReference);
|
|
if (found) return found;
|
|
}
|
|
return undefined;
|
|
}
|
|
if (typeof value !== "object") return undefined;
|
|
const obj = value as Record<string, any>;
|
|
if (insideReference) {
|
|
const raw = obj.create_time_ms ?? obj.createTimeMs;
|
|
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0) return String(raw);
|
|
if (typeof raw === "string" && /^(?:0|[1-9]\d*)$/.test(raw.trim())) return raw.trim();
|
|
}
|
|
const referenceKeys = ["ref_msg", "refMsg", "quoted_message", "quotedMessage", "quoted", "quote", "referenced_message", "referencedMessage"];
|
|
for (const key of referenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
if (insideReference) {
|
|
for (const key of ["message_item", "messageItem", "item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function extractHermesReferencedText(value: any, depth = 0, insideReference = false): string {
|
|
if (depth > 8 || value == null) return "";
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = extractHermesReferencedText(item, depth + 1, insideReference);
|
|
if (found) return found;
|
|
}
|
|
return "";
|
|
}
|
|
if (typeof value !== "object") return "";
|
|
const obj = value as Record<string, any>;
|
|
const referenceKeys = [
|
|
"ref_msg",
|
|
"refMsg",
|
|
"quoted_message",
|
|
"quotedMessage",
|
|
"quoted",
|
|
"quote",
|
|
"referenced_message",
|
|
"referencedMessage",
|
|
];
|
|
for (const key of referenceKeys) {
|
|
if (key in obj) {
|
|
const referenceValue = obj[key];
|
|
const found = extractHermesReferencedText(referenceValue, depth + 1, true);
|
|
if (found) return found;
|
|
const title = String(referenceValue?.title ?? "").trim();
|
|
if (title) return title.slice(0, 4000);
|
|
}
|
|
}
|
|
if (insideReference) {
|
|
const directCandidates = [
|
|
obj.text_item?.text,
|
|
obj.textItem?.text,
|
|
];
|
|
for (const candidate of directCandidates) {
|
|
const normalized = String(candidate ?? "").trim();
|
|
if (normalized) return normalized.slice(0, 4000);
|
|
}
|
|
for (const key of ["message_item", "messageItem", "text_item", "textItem", "item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj) {
|
|
const found = extractHermesReferencedText(obj[key], depth + 1, true);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
} else {
|
|
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj) {
|
|
const found = extractHermesReferencedText(obj[key], depth + 1, false);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function resolveHermesQuotedTextContext(items: any, currentText: string, cachedQuotedText = ""): HermesQuotedTextContext {
|
|
if (!hasHermesReferenceContext(items)) {
|
|
return { referenced: false, handled: false, prompt: String(currentText ?? "").trim(), reason: "none" };
|
|
}
|
|
const quotedText = extractHermesReferencedText(items) || String(cachedQuotedText ?? "").trim().slice(0, 4000);
|
|
if (!quotedText) {
|
|
return {
|
|
referenced: true,
|
|
handled: true,
|
|
replyText: "无法读取被引用文字,请复制原文后再提问。",
|
|
reason: "quoted_text_unavailable",
|
|
};
|
|
}
|
|
const normalizedCurrent = String(currentText ?? "").trim().slice(0, 4000);
|
|
return {
|
|
referenced: true,
|
|
handled: false,
|
|
prompt: `[被引用文字]\n${quotedText}\n[/被引用文字]\n\n[当前问题]\n${normalizedCurrent}\n[/当前问题]`,
|
|
reason: "quoted_text_resolved",
|
|
};
|
|
}
|
|
|
|
async function dispatchHermesQuotedTextFailClosed(context: HermesQuotedTextContext, dispatcher: any): Promise<boolean> {
|
|
if (!context.handled) return false;
|
|
dispatcher.sendFinalReply({
|
|
text: context.replyText ?? "无法读取被引用文字,请复制原文后再提问。",
|
|
});
|
|
dispatcher.markComplete();
|
|
await dispatcher.waitForIdle();
|
|
return true;
|
|
}
|
|
|
|
function findHermesReferencedMediaItem(value: any, depth = 0, insideReference = false): any {
|
|
if (depth > 8 || value == null) return undefined;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findHermesReferencedMediaItem(item, depth + 1, insideReference);
|
|
if (found) return found;
|
|
}
|
|
return undefined;
|
|
}
|
|
if (typeof value !== "object") return undefined;
|
|
if (insideReference && isMediaItem(value)) return value;
|
|
const obj = value as Record<string, any>;
|
|
const referenceKeys = [
|
|
"ref_msg",
|
|
"refMsg",
|
|
"quoted_message",
|
|
"quotedMessage",
|
|
"quoted",
|
|
"quote",
|
|
"referenced_message",
|
|
"referencedMessage",
|
|
];
|
|
const nestedReferenceKeys = [
|
|
"message_item",
|
|
"messageItem",
|
|
"item_list",
|
|
"itemList",
|
|
"message",
|
|
"msg",
|
|
"payload",
|
|
];
|
|
for (const key of referenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMediaItem(obj[key], depth + 1, true);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
if (insideReference) {
|
|
for (const key of nestedReferenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMediaItem(obj[key], depth + 1, true);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function extractHermesMessageId(value: any): string | undefined {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
const obj = value as Record<string, any>;
|
|
for (const key of ["message_id", "messageId", "msg_id", "msgId", "client_msg_id", "clientMsgId", "server_msg_id", "serverMsgId", "new_msg_id", "newMsgId", "id"]) {
|
|
const raw = obj[key];
|
|
if (typeof raw === "number" && !Number.isSafeInteger(raw)) continue;
|
|
if (typeof raw === "string" || typeof raw === "number") {
|
|
const text = String(raw).trim();
|
|
if (text) return text;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function collectHermesMessageIds(value: any, out = new Set<string>(), depth = 0): Set<string> {
|
|
if (depth > 6 || value == null) return out;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectHermesMessageIds(item, out, depth + 1);
|
|
return out;
|
|
}
|
|
if (typeof value !== "object") return out;
|
|
const direct = extractHermesMessageId(value);
|
|
if (direct) out.add(direct);
|
|
const obj = value as Record<string, any>;
|
|
for (const key of ["item_list", "itemList", "message_item", "messageItem", "message", "msg", "payload"]) {
|
|
if (key in obj) collectHermesMessageIds(obj[key], out, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function findHermesReferencedMessageId(value: any, depth = 0, insideReference = false): string | undefined {
|
|
if (depth > 8 || value == null) return undefined;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findHermesReferencedMessageId(item, depth + 1, insideReference);
|
|
if (found) return found;
|
|
}
|
|
return undefined;
|
|
}
|
|
if (typeof value !== "object") return undefined;
|
|
const obj = value as Record<string, any>;
|
|
if (insideReference) {
|
|
const direct = extractHermesMessageId(obj);
|
|
if (direct) return direct;
|
|
}
|
|
const referenceKeys = [
|
|
"ref_msg",
|
|
"refMsg",
|
|
"quoted_message",
|
|
"quotedMessage",
|
|
"quoted",
|
|
"quote",
|
|
"referenced_message",
|
|
"referencedMessage",
|
|
];
|
|
const nestedReferenceKeys = [
|
|
"message_item",
|
|
"messageItem",
|
|
"item_list",
|
|
"itemList",
|
|
"message",
|
|
"msg",
|
|
"payload",
|
|
];
|
|
for (const key of referenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMessageId(obj[key], depth + 1, true);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
if (insideReference) {
|
|
for (const key of nestedReferenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMessageId(obj[key], depth + 1, true);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
} else {
|
|
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMessageId(obj[key], depth + 1, false);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function rememberHermesLastImage(accountId: string, senderId: string, ctx: any, full: any, mediaItem: any): void {
|
|
if (!hermesCurrentMessageIsImage(ctx, mediaItem)) return;
|
|
const mediaPath = String(ctx?.MediaPath ?? ctx?.mediaPath ?? "").trim();
|
|
const mediaUrl = String(ctx?.MediaUrl ?? ctx?.mediaUrl ?? "").trim();
|
|
const mediaType = String(ctx?.MediaType ?? ctx?.mediaType ?? "image/jpeg").trim() || "image/jpeg";
|
|
if (!mediaPath && !mediaUrl) return;
|
|
const cache = readHermesLastImageCache();
|
|
const latestKey = hermesLastImageCacheKey(accountId, senderId);
|
|
const messageId = String(full?.message_id ?? full?.id ?? "").trim();
|
|
const aliasIds = collectHermesMessageIds([full, mediaItem]);
|
|
const entry = {
|
|
mediaPath: mediaPath || undefined,
|
|
mediaUrl: mediaUrl || undefined,
|
|
mediaType,
|
|
timestampMs: Date.now(),
|
|
messageId: messageId || undefined,
|
|
};
|
|
cache[latestKey] = entry;
|
|
for (const aliasId of aliasIds) {
|
|
cache[hermesExactImageCacheKey(accountId, senderId, aliasId)] = entry;
|
|
const approxKey = hermesApproxImageCacheKey(accountId, senderId, aliasId);
|
|
if (approxKey) cache[approxKey] = entry;
|
|
}
|
|
writeHermesLastImageCache(cache);
|
|
logger.info(`[weixin] Hermes last image cache updated accountId=${accountId} sender=${senderId} hasMediaPath=${Boolean(mediaPath)} hasMediaUrl=${Boolean(mediaUrl)} messageId=${entry.messageId ?? "none"} exactKeyCount=${aliasIds.size}`);
|
|
}
|
|
|
|
function hermesImageFollowupHasDiagnosticSignal(normalized: string): boolean {
|
|
if (!normalized) return false;
|
|
return [
|
|
"\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548",
|
|
"\u7f13\u5b58\u5df2\u5931\u6548",
|
|
"\u7f13\u5b58\u5931\u6548",
|
|
"\u56fe\u7247\u7f13\u5b58\u9519\u8bef",
|
|
"\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247",
|
|
"\u91cd\u65b0\u53d1\u9001\u56fe\u7247",
|
|
"\u8fd9\u4e0d\u662f\u56fe\u7247",
|
|
"\u4e0d\u662f\u56fe\u7247",
|
|
"\u6211\u6ca1\u6709\u53d1\u56fe\u7247",
|
|
"\u6ca1\u6709\u53d1\u56fe\u7247",
|
|
"\u6ca1\u53d1\u56fe\u7247",
|
|
"\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7",
|
|
"\u7cfb\u7edf\u65e5\u5fd7",
|
|
"\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef",
|
|
"\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef",
|
|
"image cache error",
|
|
"image cache expired",
|
|
"cache error",
|
|
"cache expired",
|
|
"please resend",
|
|
"text message",
|
|
"not an image",
|
|
"not image",
|
|
"no image",
|
|
"resend the image",
|
|
"check the logs",
|
|
].some((phrase) => normalized.includes(phrase));
|
|
}
|
|
|
|
function looksLikeHermesImageFollowup(text: string): boolean {
|
|
const normalized = normalizeHermesImageIntentText(text);
|
|
if (!normalized) return false;
|
|
if (hermesImageFollowupHasDiagnosticSignal(normalized)) return false;
|
|
return /(\u7167\u7247|\u56fe\u7247|\u56fe\u91cc|\u56fe\u4e2d|\u8fd9\u5f20|\u8fd9\u4e2a\u56fe|\u8fd9\u56fe|\u76f8\u7247|\u62cd\u7684\u662f|\u54ea\u91cc|\u54ea\u513f|\u5730\u70b9|\u4f4d\u7f6e|\u8bc6\u522b|\u770b\u4e00\u4e0b|\u770b\u4e0b|\u662f\u4ec0\u4e48|\u5728\u54ea\u91cc|\u5728\u54ea|photo|image|picture|where|location|what is in|look at)/i.test(normalized);
|
|
}
|
|
|
|
function isHermesSlashCommandText(text: string): boolean {
|
|
return /^\/hermes(?:\s|$)/i.test(String(text ?? "").trim());
|
|
}
|
|
|
|
function normalizeHermesImageIntentText(text: string): string {
|
|
const normalized = String(text ?? "").trim().toLowerCase();
|
|
if (!normalized) return "";
|
|
if (isHermesSlashCommandText(normalized)) {
|
|
return normalized.replace(/^\/hermes(?:\s+|$)/i, "").trim();
|
|
}
|
|
if (normalized.startsWith("/")) return "";
|
|
return normalized;
|
|
}
|
|
|
|
function resolveHermesCachedImageEntry(entry: any, strategy: string, quotedMessageId?: string): HermesFollowupImageContext {
|
|
if (!entry) {
|
|
return { imageIntent: true, matched: false, reason: strategy === "exact" ? "referenced_image_unresolved" : "no_cached_image", cacheStrategy: strategy, quotedMessageId };
|
|
}
|
|
const ageMs = Date.now() - Number(entry.timestampMs ?? 0);
|
|
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > hermesLastImageMaxAgeMs()) {
|
|
return { imageIntent: true, matched: false, reason: "cached_image_expired", ageMs, cacheStrategy: strategy, quotedMessageId };
|
|
}
|
|
const mediaPath = String(entry.mediaPath ?? "").trim();
|
|
const mediaUrl = String(entry.mediaUrl ?? "").trim();
|
|
if (mediaPath && !existsSync(mediaPath) && !mediaUrl) {
|
|
return { imageIntent: true, matched: false, reason: "cached_image_path_missing", ageMs, cacheStrategy: strategy, quotedMessageId };
|
|
}
|
|
if (!mediaPath && !mediaUrl) {
|
|
return { imageIntent: true, matched: false, reason: "cached_image_empty", ageMs, cacheStrategy: strategy, quotedMessageId };
|
|
}
|
|
return {
|
|
imageIntent: true,
|
|
matched: true,
|
|
reason: "cached_image_matched",
|
|
ageMs,
|
|
cacheStrategy: strategy,
|
|
quotedMessageId,
|
|
entry: {
|
|
mediaPath: mediaPath || undefined,
|
|
mediaUrl: mediaUrl || undefined,
|
|
mediaType: String(entry.mediaType ?? "image/jpeg").trim() || "image/jpeg",
|
|
timestampMs: Number(entry.timestampMs ?? Date.now()),
|
|
messageId: entry.messageId,
|
|
},
|
|
};
|
|
}
|
|
|
|
function resolveHermesFollowupImageContext(accountId: string, senderId: string, text: string, quotedMessageId?: string, allowLatestFallback = true, forceImageIntent = false): HermesFollowupImageContext {
|
|
if (!forceImageIntent && !looksLikeHermesImageFollowup(text)) {
|
|
return { imageIntent: false, matched: false, reason: "not_image_followup" };
|
|
}
|
|
const cache = readHermesLastImageCache();
|
|
const normalizedQuotedId = String(quotedMessageId ?? "").trim();
|
|
if (normalizedQuotedId) {
|
|
const exact = resolveHermesCachedImageEntry(cache[hermesExactImageCacheKey(accountId, senderId, normalizedQuotedId)], "exact", normalizedQuotedId);
|
|
if (exact.matched) return exact;
|
|
const approxKey = hermesApproxImageCacheKey(accountId, senderId, normalizedQuotedId);
|
|
if (approxKey) {
|
|
const approx = resolveHermesCachedImageEntry(cache[approxKey], "exact_approx", normalizedQuotedId);
|
|
if (approx.matched) return approx;
|
|
}
|
|
if (!allowLatestFallback) return exact;
|
|
}
|
|
const latest = resolveHermesCachedImageEntry(cache[hermesLastImageCacheKey(accountId, senderId)], "latest", normalizedQuotedId || undefined);
|
|
if (!allowLatestFallback && !latest.matched) {
|
|
return { ...latest, reason: "referenced_image_unresolved", cacheStrategy: "exact", quotedMessageId: normalizedQuotedId || undefined };
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function resolveHermesReferencedImageFollowup(accountId: string, senderId: string, text: string, quotedMessageId?: string): HermesFollowupImageContext {
|
|
const normalizedQuotedId = String(quotedMessageId ?? "").trim();
|
|
const candidate = resolveHermesFollowupImageContext(accountId, senderId, text, normalizedQuotedId || undefined, false, true);
|
|
if (candidate.matched) return candidate;
|
|
return {
|
|
imageIntent: false,
|
|
matched: false,
|
|
reason: "referenced_image_not_confirmed",
|
|
cacheStrategy: "exact",
|
|
quotedMessageId: normalizedQuotedId || undefined,
|
|
};
|
|
}
|
|
// AIOS Hermes image follow-up helpers end
|
|
'''.strip()
|
|
|
|
|
|
JS_PROCESS_HELPERS = r'''
|
|
// AIOS Hermes image follow-up helpers begin
|
|
const DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
|
|
const DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS = 15 * 60 * 1000;
|
|
const DEFAULT_HERMES_MEDIA_DEBUG_PATH = "/var/log/aios/hermes-route-memory/weixin_media_debug.jsonl";
|
|
const DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-message-text-cache.json";
|
|
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
const DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES = 500;
|
|
function hermesLastImageCachePath() {
|
|
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH).trim();
|
|
}
|
|
function hermesLastImageMaxAgeMs() {
|
|
const parsed = Number.parseInt(String(process.env.AIOS_HERMES_LAST_IMAGE_MAX_AGE_MS ?? DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS;
|
|
}
|
|
function hermesMediaDebugPath() {
|
|
return (process.env.AIOS_HERMES_WEIXIN_MEDIA_DEBUG || DEFAULT_HERMES_MEDIA_DEBUG_PATH).trim();
|
|
}
|
|
function hermesTextMessageCachePath() {
|
|
return (process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE || DEFAULT_HERMES_TEXT_MESSAGE_CACHE_PATH).trim();
|
|
}
|
|
function hermesTextMessageCacheKey(accountId, senderId, messageId) {
|
|
return `${accountId || "unknown"}:${senderId || "unknown"}:${messageId}`;
|
|
}
|
|
function hermesTextMessageTimeCacheKey(accountId, senderId, createTimeMs) {
|
|
return `${accountId || "unknown"}:${senderId || "unknown"}:created:${createTimeMs}`;
|
|
}
|
|
function readHermesTextMessageCache() {
|
|
try {
|
|
const filePath = hermesTextMessageCachePath();
|
|
if (!existsSync(filePath))
|
|
return {};
|
|
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
}
|
|
catch (err) {
|
|
logger.warn(`[weixin] Hermes text message cache read skipped err=${String(err).slice(0, 160)}`);
|
|
return {};
|
|
}
|
|
}
|
|
function writeHermesTextMessageCache(cache) {
|
|
try {
|
|
const filePath = hermesTextMessageCachePath();
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const now = Date.now();
|
|
const retained = Object.entries(cache)
|
|
.filter(([, entry]) => entry && typeof entry.text === "string" && now - Number(entry.timestampMs ?? 0) <= DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
|
|
.sort((a, b) => Number(b[1].timestampMs ?? 0) - Number(a[1].timestampMs ?? 0))
|
|
.slice(0, DEFAULT_HERMES_TEXT_MESSAGE_MAX_ENTRIES);
|
|
writeFileSync(filePath, JSON.stringify(Object.fromEntries(retained), null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
}
|
|
catch (err) {
|
|
logger.warn(`[weixin] Hermes text message cache write skipped err=${String(err).slice(0, 160)}`);
|
|
}
|
|
}
|
|
function rememberHermesTextMessage(accountId, senderId, full, text) {
|
|
const normalizedText = String(text ?? "").trim().slice(0, 4000);
|
|
if (!normalizedText)
|
|
return;
|
|
const messageIds = collectHermesMessageIds(full);
|
|
if (!messageIds.size)
|
|
return;
|
|
const cache = readHermesTextMessageCache();
|
|
const timestampMs = Date.now();
|
|
for (const messageId of messageIds) {
|
|
cache[hermesTextMessageCacheKey(accountId, senderId, messageId)] = { text: normalizedText, timestampMs };
|
|
}
|
|
writeHermesTextMessageCache(cache);
|
|
}
|
|
function rememberHermesOutboundTextMessage(accountId, senderId, text, createTimeMs) {
|
|
const normalizedText = String(text ?? "").trim().slice(0, 4000);
|
|
if (!normalizedText || !Number.isSafeInteger(createTimeMs) || createTimeMs <= 0)
|
|
return;
|
|
const cache = readHermesTextMessageCache();
|
|
const key = hermesTextMessageTimeCacheKey(accountId, senderId, String(createTimeMs));
|
|
const existing = cache[key];
|
|
cache[key] = existing && existing.text !== normalizedText
|
|
? { text: "", timestampMs: Date.now(), conflict: true }
|
|
: { text: normalizedText, timestampMs: Date.now() };
|
|
writeHermesTextMessageCache(cache);
|
|
}
|
|
function resolveHermesCachedQuotedText(accountId, senderId, messageId, createTimeMs) {
|
|
const normalizedId = String(messageId ?? "").trim();
|
|
const normalizedTime = String(createTimeMs ?? "").trim();
|
|
const cache = readHermesTextMessageCache();
|
|
const entry = (normalizedId ? cache[hermesTextMessageCacheKey(accountId, senderId, normalizedId)] : undefined) ??
|
|
(normalizedTime ? cache[hermesTextMessageTimeCacheKey(accountId, senderId, normalizedTime)] : undefined);
|
|
if (!entry || entry.conflict || Date.now() - Number(entry.timestampMs ?? 0) > DEFAULT_HERMES_TEXT_MESSAGE_MAX_AGE_MS)
|
|
return "";
|
|
return String(entry.text ?? "").trim().slice(0, 4000);
|
|
}
|
|
function hermesLastImageCacheKey(accountId, senderId) {
|
|
return `${accountId || "unknown"}:${senderId || "unknown"}`;
|
|
}
|
|
function hermesExactImageCacheKey(accountId, senderId, messageId) {
|
|
return `${hermesLastImageCacheKey(accountId, senderId)}:${messageId || "unknown-message"}`;
|
|
}
|
|
function hermesApproxMessageId(messageId) {
|
|
const normalized = String(messageId ?? "").trim();
|
|
return /^\d{16,}$/.test(normalized) ? normalized.slice(0, -4) : "";
|
|
}
|
|
function hermesApproxImageCacheKey(accountId, senderId, messageId) {
|
|
const approx = hermesApproxMessageId(messageId);
|
|
return approx ? `${hermesLastImageCacheKey(accountId, senderId)}:approx:${approx}` : "";
|
|
}
|
|
function sanitizeHermesMediaDebug(value, depth = 0) {
|
|
if (depth > 6)
|
|
return "[max-depth]";
|
|
if (value == null)
|
|
return value;
|
|
if (typeof value === "string")
|
|
return value.length > 160 ? `${value.slice(0, 160)}...` : value;
|
|
if (typeof value === "number" || typeof value === "boolean")
|
|
return value;
|
|
if (Array.isArray(value))
|
|
return value.slice(0, 8).map((item) => sanitizeHermesMediaDebug(item, depth + 1));
|
|
if (typeof value !== "object")
|
|
return String(value);
|
|
const out = {};
|
|
for (const [key, item] of Object.entries(value)) {
|
|
const normalized = key.toLowerCase();
|
|
if (/(token|key|secret|auth|cookie|signature|encrypt_query_param|aes)/i.test(key)) {
|
|
out[key] = item ? "[present]" : item;
|
|
continue;
|
|
}
|
|
if (normalized.includes("ref") ||
|
|
normalized.includes("quote") ||
|
|
normalized.includes("media") ||
|
|
normalized.includes("image") ||
|
|
normalized.includes("thumb") ||
|
|
normalized.includes("pic") ||
|
|
normalized.includes("url") ||
|
|
normalized.includes("path") ||
|
|
["type", "item_list", "itemlist", "message_item", "messageitem", "msg", "message", "payload", "text_item", "text", "title"].includes(normalized)) {
|
|
out[key] = sanitizeHermesMediaDebug(item, depth + 1);
|
|
}
|
|
}
|
|
const keys = Object.keys(value);
|
|
if (!Object.keys(out).length)
|
|
out.__keys = keys.slice(0, 40);
|
|
else
|
|
out.__keys = keys.slice(0, 40);
|
|
return out;
|
|
}
|
|
function writeHermesMediaDebug(event, data) {
|
|
try {
|
|
const filePath = hermesMediaDebugPath();
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
appendFileSync(filePath, `${JSON.stringify({ ts: new Date().toISOString(), event, ...data })}\n`, "utf-8");
|
|
}
|
|
catch (err) {
|
|
logger.warn(`[weixin] Hermes media debug write skipped err=${String(err).slice(0, 160)}`);
|
|
}
|
|
}
|
|
function readHermesLastImageCache() {
|
|
try {
|
|
const filePath = hermesLastImageCachePath();
|
|
if (!filePath || !existsSync(filePath))
|
|
return {};
|
|
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
}
|
|
catch (err) {
|
|
logger.warn(`[weixin] Hermes last image cache read skipped err=${String(err).slice(0, 160)}`);
|
|
return {};
|
|
}
|
|
}
|
|
function writeHermesLastImageCache(cache) {
|
|
try {
|
|
const filePath = hermesLastImageCachePath();
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8");
|
|
}
|
|
catch (err) {
|
|
logger.warn(`[weixin] Hermes last image cache write skipped err=${String(err).slice(0, 160)}`);
|
|
}
|
|
}
|
|
function hermesPathLooksLikeImage(filePath) {
|
|
return /\.(?:jpe?g|png|webp|gif|bmp|heic)$/i.test(String(filePath ?? "").trim());
|
|
}
|
|
function hermesCurrentMessageIsImage(ctx, mediaItem) {
|
|
const mediaType = String(ctx?.MediaType ?? ctx?.mediaType ?? "").toLowerCase();
|
|
const mediaPath = String(ctx?.MediaPath ?? ctx?.mediaPath ?? "");
|
|
const mediaUrl = String(ctx?.MediaUrl ?? ctx?.mediaUrl ?? "").toLowerCase();
|
|
return mediaType.startsWith("image/") ||
|
|
hermesPathLooksLikeImage(mediaPath) ||
|
|
mediaUrl.startsWith("data:image/") ||
|
|
mediaItem?.type === MessageItemType.IMAGE;
|
|
}
|
|
function hasHermesReferenceContext(value, depth = 0) {
|
|
if (depth > 8 || value == null)
|
|
return false;
|
|
if (Array.isArray(value))
|
|
return value.some((item) => hasHermesReferenceContext(item, depth + 1));
|
|
if (typeof value !== "object")
|
|
return false;
|
|
const obj = value;
|
|
const referenceKeys = [
|
|
"ref_msg",
|
|
"refMsg",
|
|
"quoted_message",
|
|
"quotedMessage",
|
|
"quoted",
|
|
"quote",
|
|
"referenced_message",
|
|
"referencedMessage",
|
|
];
|
|
if (referenceKeys.some((key) => key in obj))
|
|
return true;
|
|
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj && hasHermesReferenceContext(obj[key], depth + 1))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function findHermesReferencedCreateTimeMs(value, depth = 0, insideReference = false) {
|
|
if (depth > 8 || value == null)
|
|
return undefined;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findHermesReferencedCreateTimeMs(item, depth + 1, insideReference);
|
|
if (found)
|
|
return found;
|
|
}
|
|
return undefined;
|
|
}
|
|
if (typeof value !== "object")
|
|
return undefined;
|
|
const obj = value;
|
|
if (insideReference) {
|
|
const raw = obj.create_time_ms ?? obj.createTimeMs;
|
|
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0)
|
|
return String(raw);
|
|
if (typeof raw === "string" && /^(?:0|[1-9]\d*)$/.test(raw.trim()))
|
|
return raw.trim();
|
|
}
|
|
const referenceKeys = ["ref_msg", "refMsg", "quoted_message", "quotedMessage", "quoted", "quote", "referenced_message", "referencedMessage"];
|
|
for (const key of referenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
if (insideReference) {
|
|
for (const key of ["message_item", "messageItem", "item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedCreateTimeMs(obj[key], depth + 1, true);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
function extractHermesReferencedText(value, depth = 0, insideReference = false) {
|
|
if (depth > 8 || value == null)
|
|
return "";
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = extractHermesReferencedText(item, depth + 1, insideReference);
|
|
if (found)
|
|
return found;
|
|
}
|
|
return "";
|
|
}
|
|
if (typeof value !== "object")
|
|
return "";
|
|
const obj = value;
|
|
const referenceKeys = [
|
|
"ref_msg",
|
|
"refMsg",
|
|
"quoted_message",
|
|
"quotedMessage",
|
|
"quoted",
|
|
"quote",
|
|
"referenced_message",
|
|
"referencedMessage",
|
|
];
|
|
for (const key of referenceKeys) {
|
|
if (key in obj) {
|
|
const referenceValue = obj[key];
|
|
const found = extractHermesReferencedText(referenceValue, depth + 1, true);
|
|
if (found)
|
|
return found;
|
|
const title = String(referenceValue?.title ?? "").trim();
|
|
if (title)
|
|
return title.slice(0, 4000);
|
|
}
|
|
}
|
|
if (insideReference) {
|
|
const directCandidates = [
|
|
obj.text_item?.text,
|
|
obj.textItem?.text,
|
|
];
|
|
for (const candidate of directCandidates) {
|
|
const normalized = String(candidate ?? "").trim();
|
|
if (normalized)
|
|
return normalized.slice(0, 4000);
|
|
}
|
|
for (const key of ["message_item", "messageItem", "text_item", "textItem", "item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj) {
|
|
const found = extractHermesReferencedText(obj[key], depth + 1, true);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj) {
|
|
const found = extractHermesReferencedText(obj[key], depth + 1, false);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
function resolveHermesQuotedTextContext(items, currentText, cachedQuotedText = "") {
|
|
if (!hasHermesReferenceContext(items)) {
|
|
return { referenced: false, handled: false, prompt: String(currentText ?? "").trim(), reason: "none" };
|
|
}
|
|
const quotedText = extractHermesReferencedText(items) || String(cachedQuotedText ?? "").trim().slice(0, 4000);
|
|
if (!quotedText) {
|
|
return {
|
|
referenced: true,
|
|
handled: true,
|
|
replyText: "无法读取被引用文字,请复制原文后再提问。",
|
|
reason: "quoted_text_unavailable",
|
|
};
|
|
}
|
|
const normalizedCurrent = String(currentText ?? "").trim().slice(0, 4000);
|
|
return {
|
|
referenced: true,
|
|
handled: false,
|
|
prompt: `[被引用文字]\n${quotedText}\n[/被引用文字]\n\n[当前问题]\n${normalizedCurrent}\n[/当前问题]`,
|
|
reason: "quoted_text_resolved",
|
|
};
|
|
}
|
|
async function dispatchHermesQuotedTextFailClosed(context, dispatcher) {
|
|
if (!context.handled)
|
|
return false;
|
|
dispatcher.sendFinalReply({
|
|
text: context.replyText ?? "无法读取被引用文字,请复制原文后再提问。",
|
|
});
|
|
dispatcher.markComplete();
|
|
await dispatcher.waitForIdle();
|
|
return true;
|
|
}
|
|
function findHermesReferencedMediaItem(value, depth = 0, insideReference = false) {
|
|
if (depth > 8 || value == null)
|
|
return undefined;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findHermesReferencedMediaItem(item, depth + 1, insideReference);
|
|
if (found)
|
|
return found;
|
|
}
|
|
return undefined;
|
|
}
|
|
if (typeof value !== "object")
|
|
return undefined;
|
|
if (insideReference && isMediaItem(value))
|
|
return value;
|
|
const obj = value;
|
|
const referenceKeys = [
|
|
"ref_msg",
|
|
"refMsg",
|
|
"quoted_message",
|
|
"quotedMessage",
|
|
"quoted",
|
|
"quote",
|
|
"referenced_message",
|
|
"referencedMessage",
|
|
];
|
|
const nestedReferenceKeys = [
|
|
"message_item",
|
|
"messageItem",
|
|
"item_list",
|
|
"itemList",
|
|
"message",
|
|
"msg",
|
|
"payload",
|
|
];
|
|
for (const key of referenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMediaItem(obj[key], depth + 1, true);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
if (insideReference) {
|
|
for (const key of nestedReferenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMediaItem(obj[key], depth + 1, true);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
function extractHermesMessageId(value) {
|
|
if (!value || typeof value !== "object")
|
|
return undefined;
|
|
const obj = value;
|
|
for (const key of ["message_id", "messageId", "msg_id", "msgId", "client_msg_id", "clientMsgId", "server_msg_id", "serverMsgId", "new_msg_id", "newMsgId", "id"]) {
|
|
const raw = obj[key];
|
|
if (typeof raw === "number" && !Number.isSafeInteger(raw))
|
|
continue;
|
|
if (typeof raw === "string" || typeof raw === "number") {
|
|
const text = String(raw).trim();
|
|
if (text)
|
|
return text;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
function collectHermesMessageIds(value, out = new Set(), depth = 0) {
|
|
if (depth > 6 || value == null)
|
|
return out;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value)
|
|
collectHermesMessageIds(item, out, depth + 1);
|
|
return out;
|
|
}
|
|
if (typeof value !== "object")
|
|
return out;
|
|
const direct = extractHermesMessageId(value);
|
|
if (direct)
|
|
out.add(direct);
|
|
const obj = value;
|
|
for (const key of ["item_list", "itemList", "message_item", "messageItem", "message", "msg", "payload"]) {
|
|
if (key in obj)
|
|
collectHermesMessageIds(obj[key], out, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
function findHermesReferencedMessageId(value, depth = 0, insideReference = false) {
|
|
if (depth > 8 || value == null)
|
|
return undefined;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findHermesReferencedMessageId(item, depth + 1, insideReference);
|
|
if (found)
|
|
return found;
|
|
}
|
|
return undefined;
|
|
}
|
|
if (typeof value !== "object")
|
|
return undefined;
|
|
const obj = value;
|
|
if (insideReference) {
|
|
const direct = extractHermesMessageId(obj);
|
|
if (direct)
|
|
return direct;
|
|
}
|
|
const referenceKeys = [
|
|
"ref_msg",
|
|
"refMsg",
|
|
"quoted_message",
|
|
"quotedMessage",
|
|
"quoted",
|
|
"quote",
|
|
"referenced_message",
|
|
"referencedMessage",
|
|
];
|
|
const nestedReferenceKeys = [
|
|
"message_item",
|
|
"messageItem",
|
|
"item_list",
|
|
"itemList",
|
|
"message",
|
|
"msg",
|
|
"payload",
|
|
];
|
|
for (const key of referenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMessageId(obj[key], depth + 1, true);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
if (insideReference) {
|
|
for (const key of nestedReferenceKeys) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMessageId(obj[key], depth + 1, true);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
|
|
if (key in obj) {
|
|
const found = findHermesReferencedMessageId(obj[key], depth + 1, false);
|
|
if (found)
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
function rememberHermesLastImage(accountId, senderId, ctx, full, mediaItem) {
|
|
if (!hermesCurrentMessageIsImage(ctx, mediaItem))
|
|
return;
|
|
const mediaPath = String(ctx?.MediaPath ?? ctx?.mediaPath ?? "").trim();
|
|
const mediaUrl = String(ctx?.MediaUrl ?? ctx?.mediaUrl ?? "").trim();
|
|
const mediaType = String(ctx?.MediaType ?? ctx?.mediaType ?? "image/jpeg").trim() || "image/jpeg";
|
|
if (!mediaPath && !mediaUrl)
|
|
return;
|
|
const cache = readHermesLastImageCache();
|
|
const latestKey = hermesLastImageCacheKey(accountId, senderId);
|
|
const messageId = String(full?.message_id ?? full?.id ?? "").trim();
|
|
const aliasIds = collectHermesMessageIds([full, mediaItem]);
|
|
const entry = {
|
|
mediaPath: mediaPath || undefined,
|
|
mediaUrl: mediaUrl || undefined,
|
|
mediaType,
|
|
timestampMs: Date.now(),
|
|
messageId: messageId || undefined,
|
|
};
|
|
cache[latestKey] = entry;
|
|
for (const aliasId of aliasIds) {
|
|
cache[hermesExactImageCacheKey(accountId, senderId, aliasId)] = entry;
|
|
const approxKey = hermesApproxImageCacheKey(accountId, senderId, aliasId);
|
|
if (approxKey)
|
|
cache[approxKey] = entry;
|
|
}
|
|
writeHermesLastImageCache(cache);
|
|
logger.info(`[weixin] Hermes last image cache updated accountId=${accountId} sender=${senderId} hasMediaPath=${Boolean(mediaPath)} hasMediaUrl=${Boolean(mediaUrl)} messageId=${entry.messageId ?? "none"} exactKeyCount=${aliasIds.size}`);
|
|
}
|
|
function hermesImageFollowupHasDiagnosticSignal(normalized) {
|
|
if (!normalized)
|
|
return false;
|
|
return [
|
|
"\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548",
|
|
"\u7f13\u5b58\u5df2\u5931\u6548",
|
|
"\u7f13\u5b58\u5931\u6548",
|
|
"\u56fe\u7247\u7f13\u5b58\u9519\u8bef",
|
|
"\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247",
|
|
"\u91cd\u65b0\u53d1\u9001\u56fe\u7247",
|
|
"\u8fd9\u4e0d\u662f\u56fe\u7247",
|
|
"\u4e0d\u662f\u56fe\u7247",
|
|
"\u6211\u6ca1\u6709\u53d1\u56fe\u7247",
|
|
"\u6ca1\u6709\u53d1\u56fe\u7247",
|
|
"\u6ca1\u53d1\u56fe\u7247",
|
|
"\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7",
|
|
"\u7cfb\u7edf\u65e5\u5fd7",
|
|
"\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef",
|
|
"\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef",
|
|
"image cache error",
|
|
"image cache expired",
|
|
"cache error",
|
|
"cache expired",
|
|
"please resend",
|
|
"text message",
|
|
"not an image",
|
|
"not image",
|
|
"no image",
|
|
"resend the image",
|
|
"check the logs",
|
|
].some((phrase) => normalized.includes(phrase));
|
|
}
|
|
function looksLikeHermesImageFollowup(text) {
|
|
const normalized = normalizeHermesImageIntentText(text);
|
|
if (!normalized)
|
|
return false;
|
|
if (hermesImageFollowupHasDiagnosticSignal(normalized))
|
|
return false;
|
|
return /(\u7167\u7247|\u56fe\u7247|\u56fe\u91cc|\u56fe\u4e2d|\u8fd9\u5f20|\u8fd9\u4e2a\u56fe|\u8fd9\u56fe|\u76f8\u7247|\u62cd\u7684\u662f|\u54ea\u91cc|\u54ea\u513f|\u5730\u70b9|\u4f4d\u7f6e|\u8bc6\u522b|\u770b\u4e00\u4e0b|\u770b\u4e0b|\u662f\u4ec0\u4e48|\u5728\u54ea\u91cc|\u5728\u54ea|photo|image|picture|where|location|what is in|look at)/i.test(normalized);
|
|
}
|
|
|
|
function isHermesSlashCommandText(text) {
|
|
return /^\/hermes(?:\s|$)/i.test(String(text ?? "").trim());
|
|
}
|
|
function normalizeHermesImageIntentText(text) {
|
|
const normalized = String(text ?? "").trim().toLowerCase();
|
|
if (!normalized)
|
|
return "";
|
|
if (isHermesSlashCommandText(normalized)) {
|
|
return normalized.replace(/^\/hermes(?:\s+|$)/i, "").trim();
|
|
}
|
|
if (normalized.startsWith("/"))
|
|
return "";
|
|
return normalized;
|
|
}
|
|
function resolveHermesCachedImageEntry(entry, strategy, quotedMessageId) {
|
|
if (!entry) {
|
|
return { imageIntent: true, matched: false, reason: strategy === "exact" ? "referenced_image_unresolved" : "no_cached_image", cacheStrategy: strategy, quotedMessageId };
|
|
}
|
|
const ageMs = Date.now() - Number(entry.timestampMs ?? 0);
|
|
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > hermesLastImageMaxAgeMs()) {
|
|
return { imageIntent: true, matched: false, reason: "cached_image_expired", ageMs, cacheStrategy: strategy, quotedMessageId };
|
|
}
|
|
const mediaPath = String(entry.mediaPath ?? "").trim();
|
|
const mediaUrl = String(entry.mediaUrl ?? "").trim();
|
|
if (mediaPath && !existsSync(mediaPath) && !mediaUrl) {
|
|
return { imageIntent: true, matched: false, reason: "cached_image_path_missing", ageMs, cacheStrategy: strategy, quotedMessageId };
|
|
}
|
|
if (!mediaPath && !mediaUrl) {
|
|
return { imageIntent: true, matched: false, reason: "cached_image_empty", ageMs, cacheStrategy: strategy, quotedMessageId };
|
|
}
|
|
return {
|
|
imageIntent: true,
|
|
matched: true,
|
|
reason: "cached_image_matched",
|
|
ageMs,
|
|
cacheStrategy: strategy,
|
|
quotedMessageId,
|
|
entry: {
|
|
mediaPath: mediaPath || undefined,
|
|
mediaUrl: mediaUrl || undefined,
|
|
mediaType: String(entry.mediaType ?? "image/jpeg").trim() || "image/jpeg",
|
|
timestampMs: Number(entry.timestampMs ?? Date.now()),
|
|
messageId: entry.messageId,
|
|
},
|
|
};
|
|
}
|
|
function resolveHermesFollowupImageContext(accountId, senderId, text, quotedMessageId, allowLatestFallback = true, forceImageIntent = false) {
|
|
if (!forceImageIntent && !looksLikeHermesImageFollowup(text)) {
|
|
return { imageIntent: false, matched: false, reason: "not_image_followup" };
|
|
}
|
|
const cache = readHermesLastImageCache();
|
|
const normalizedQuotedId = String(quotedMessageId ?? "").trim();
|
|
if (normalizedQuotedId) {
|
|
const exact = resolveHermesCachedImageEntry(cache[hermesExactImageCacheKey(accountId, senderId, normalizedQuotedId)], "exact", normalizedQuotedId);
|
|
if (exact.matched)
|
|
return exact;
|
|
const approxKey = hermesApproxImageCacheKey(accountId, senderId, normalizedQuotedId);
|
|
if (approxKey) {
|
|
const approx = resolveHermesCachedImageEntry(cache[approxKey], "exact_approx", normalizedQuotedId);
|
|
if (approx.matched)
|
|
return approx;
|
|
}
|
|
if (!allowLatestFallback)
|
|
return exact;
|
|
}
|
|
const latest = resolveHermesCachedImageEntry(cache[hermesLastImageCacheKey(accountId, senderId)], "latest", normalizedQuotedId || undefined);
|
|
if (!allowLatestFallback && !latest.matched) {
|
|
return { ...latest, reason: "referenced_image_unresolved", cacheStrategy: "exact", quotedMessageId: normalizedQuotedId || undefined };
|
|
}
|
|
return latest;
|
|
}
|
|
function resolveHermesReferencedImageFollowup(accountId, senderId, text, quotedMessageId) {
|
|
const normalizedQuotedId = String(quotedMessageId ?? "").trim();
|
|
const candidate = resolveHermesFollowupImageContext(accountId, senderId, text, normalizedQuotedId || undefined, false, true);
|
|
if (candidate.matched)
|
|
return candidate;
|
|
return {
|
|
imageIntent: false,
|
|
matched: false,
|
|
reason: "referenced_image_not_confirmed",
|
|
cacheStrategy: "exact",
|
|
quotedMessageId: normalizedQuotedId || undefined,
|
|
};
|
|
}
|
|
// AIOS Hermes image follow-up helpers end
|
|
'''.strip()
|
|
|
|
|
|
TS_PROCESS_HOOK = r'''
|
|
// AIOS Hermes image ingress hook begin
|
|
const hermesSenderId = full.from_user_id ?? "";
|
|
rememberHermesLastImage(deps.accountId, hermesSenderId, ctx, full, mediaItem);
|
|
const hermesCtx = ctx as typeof ctx & { MediaPath?: string; MediaUrl?: string; MediaType?: string };
|
|
const hermesContent = rawBody || textBody;
|
|
const hermesExplicitSlash = isHermesSlashCommandText(hermesContent);
|
|
const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);
|
|
const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);
|
|
const hermesQuotedCreateTimeMs = findHermesReferencedCreateTimeMs(full.item_list);
|
|
rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody);
|
|
const hermesCachedQuotedText = resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs);
|
|
const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));
|
|
const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;
|
|
const hermesCurrentImage = hermesCurrentMessageIsImage(hermesCtx, mediaItem);
|
|
const hermesFollowup = hermesCurrentImage
|
|
? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }
|
|
: (hermesReferencedMediaIsImage
|
|
? resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId, false, true)
|
|
: (hermesReferencedImageFollowupIntent
|
|
? resolveHermesReferencedImageFollowup(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId)
|
|
: resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent)));
|
|
const hermesQuotedTextContext = hermesReferenceContextPresent &&
|
|
!hermesExplicitSlash &&
|
|
!hermesCurrentImage &&
|
|
!hermesReferencedMediaIsImage &&
|
|
!hermesFollowup.matched
|
|
? resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)
|
|
: { referenced: false, handled: false, prompt: hermesContent, reason: "none" as const };
|
|
if (hermesQuotedTextContext.reason === "quoted_text_resolved" && hermesQuotedTextContext.prompt) {
|
|
ctx.Body = hermesQuotedTextContext.prompt;
|
|
ctx.CommandBody = hermesQuotedTextContext.prompt;
|
|
logger.info(`[weixin] quoted text context resolved quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
|
|
}
|
|
if (hermesFollowup.reason === "referenced_image_unresolved") {
|
|
writeHermesMediaDebug("referenced_image_unresolved", {
|
|
accountId: deps.accountId,
|
|
senderId: hermesSenderId,
|
|
messageId: String(full?.message_id ?? full?.id ?? "") || undefined,
|
|
quotedMessageId: hermesQuotedMessageId || undefined,
|
|
cacheStrategy: hermesFollowup.cacheStrategy,
|
|
itemList: sanitizeHermesMediaDebug(full.item_list),
|
|
rawMessage: sanitizeHermesMediaDebug(full),
|
|
});
|
|
}
|
|
if (hermesFollowup.imageIntent) {
|
|
logger.info(`[weixin] Hermes image follow-up ${hermesFollowup.matched ? "matched cached media" : "has no cached media, fail-closed"} reason=${hermesFollowup.reason} ageMs=${hermesFollowup.ageMs ?? "none"} cache_strategy=${hermesFollowup.cacheStrategy ?? "none"} quoted_message_id=${hermesFollowup.quotedMessageId ?? hermesQuotedMessageId ?? "none"}`);
|
|
}
|
|
const hermesMessageType = hermesCurrentImage || hermesFollowup.imageIntent ? "image" : undefined;
|
|
const hermesImageSource = hermesCurrentImage ? (refMediaItem ? "quoted" : "current") : (hermesFollowup.reason === "referenced_image_unresolved" ? "quoted_unresolved" : (hermesFollowup.matched ? "cache" : "none"));
|
|
const hermesMediaPath = hermesCurrentImage ? hermesCtx.MediaPath : hermesFollowup.entry?.mediaPath;
|
|
const hermesMediaUrl = hermesCurrentImage ? hermesCtx.MediaUrl : hermesFollowup.entry?.mediaUrl;
|
|
const hermesMediaType = hermesCurrentImage ? hermesCtx.MediaType : hermesFollowup.entry?.mediaType;
|
|
const hermesRouteCtx = {
|
|
to: full.from_user_id ?? "",
|
|
contextToken: full.context_token,
|
|
baseUrl: deps.baseUrl,
|
|
token: deps.token,
|
|
accountId: deps.accountId,
|
|
log: deps.log,
|
|
errLog: deps.errLog,
|
|
messageType: hermesMessageType,
|
|
mediaPath: hermesMediaPath,
|
|
mediaUrl: hermesMediaUrl,
|
|
mediaType: hermesMediaType,
|
|
imageSource: hermesImageSource,
|
|
imageFollowup: hermesFollowup.imageIntent,
|
|
imageFollowupReason: hermesFollowup.reason,
|
|
imageFollowupStrategy: hermesFollowup.cacheStrategy,
|
|
quotedMessageId: hermesFollowup.quotedMessageId ?? hermesQuotedMessageId,
|
|
cachedImageAgeMs: hermesFollowup.ageMs,
|
|
rawMessage: full,
|
|
inboundContext: ctx,
|
|
};
|
|
if (hermesExplicitSlash) {
|
|
const hermesSlashResult = await handleSlashCommand(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
|
|
if (hermesSlashResult.handled) {
|
|
logger.info(`[weixin] Hermes slash command handled after media context, skipping AI pipeline`);
|
|
return;
|
|
}
|
|
}
|
|
if (!hermesQuotedTextContext.referenced && !textBody.startsWith("/")) {
|
|
const hermesImageResult = await handleHermesImplicitImage(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
|
|
if (hermesImageResult.handled) {
|
|
logger.info(`[weixin] Hermes implicit image handled, skipping AI pipeline`);
|
|
return;
|
|
}
|
|
if (hermesFollowup.imageIntent) {
|
|
logger.error(`[weixin] Hermes image follow-up failed to route, stopping direct AI pipeline reason=${hermesFollowup.reason}`);
|
|
return;
|
|
}
|
|
}
|
|
// AIOS Hermes image ingress hook end
|
|
'''.strip()
|
|
|
|
|
|
JS_PROCESS_HOOK = r'''
|
|
// AIOS Hermes image ingress hook begin
|
|
const hermesSenderId = full.from_user_id ?? "";
|
|
rememberHermesLastImage(deps.accountId, hermesSenderId, ctx, full, mediaItem);
|
|
const hermesContent = rawBody || textBody;
|
|
const hermesExplicitSlash = isHermesSlashCommandText(hermesContent);
|
|
const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);
|
|
const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);
|
|
const hermesQuotedCreateTimeMs = findHermesReferencedCreateTimeMs(full.item_list);
|
|
rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody);
|
|
const hermesCachedQuotedText = resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs);
|
|
const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));
|
|
const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;
|
|
const hermesCurrentImage = hermesCurrentMessageIsImage(ctx, mediaItem);
|
|
const hermesFollowup = hermesCurrentImage
|
|
? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }
|
|
: (hermesReferencedMediaIsImage
|
|
? resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId, false, true)
|
|
: (hermesReferencedImageFollowupIntent
|
|
? resolveHermesReferencedImageFollowup(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId)
|
|
: resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent)));
|
|
const hermesQuotedTextContext = hermesReferenceContextPresent &&
|
|
!hermesExplicitSlash &&
|
|
!hermesCurrentImage &&
|
|
!hermesReferencedMediaIsImage &&
|
|
!hermesFollowup.matched
|
|
? resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)
|
|
: { referenced: false, handled: false, prompt: hermesContent, reason: "none" };
|
|
if (hermesQuotedTextContext.reason === "quoted_text_resolved" && hermesQuotedTextContext.prompt) {
|
|
ctx.Body = hermesQuotedTextContext.prompt;
|
|
ctx.CommandBody = hermesQuotedTextContext.prompt;
|
|
logger.info(`[weixin] quoted text context resolved quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
|
|
}
|
|
if (hermesFollowup.reason === "referenced_image_unresolved") {
|
|
writeHermesMediaDebug("referenced_image_unresolved", {
|
|
accountId: deps.accountId,
|
|
senderId: hermesSenderId,
|
|
messageId: String(full?.message_id ?? full?.id ?? "") || undefined,
|
|
quotedMessageId: hermesQuotedMessageId || undefined,
|
|
cacheStrategy: hermesFollowup.cacheStrategy,
|
|
itemList: sanitizeHermesMediaDebug(full.item_list),
|
|
rawMessage: sanitizeHermesMediaDebug(full),
|
|
});
|
|
}
|
|
if (hermesFollowup.imageIntent) {
|
|
logger.info(`[weixin] Hermes image follow-up ${hermesFollowup.matched ? "matched cached media" : "has no cached media, fail-closed"} reason=${hermesFollowup.reason} ageMs=${hermesFollowup.ageMs ?? "none"} cache_strategy=${hermesFollowup.cacheStrategy ?? "none"} quoted_message_id=${hermesFollowup.quotedMessageId ?? hermesQuotedMessageId ?? "none"}`);
|
|
}
|
|
const hermesMessageType = hermesCurrentImage || hermesFollowup.imageIntent ? "image" : undefined;
|
|
const hermesImageSource = hermesCurrentImage ? (refMediaItem ? "quoted" : "current") : (hermesFollowup.reason === "referenced_image_unresolved" ? "quoted_unresolved" : (hermesFollowup.matched ? "cache" : "none"));
|
|
const hermesMediaPath = hermesCurrentImage ? ctx.MediaPath : hermesFollowup.entry?.mediaPath;
|
|
const hermesMediaUrl = hermesCurrentImage ? ctx.MediaUrl : hermesFollowup.entry?.mediaUrl;
|
|
const hermesMediaType = hermesCurrentImage ? ctx.MediaType : hermesFollowup.entry?.mediaType;
|
|
const hermesRouteCtx = {
|
|
to: full.from_user_id ?? "",
|
|
contextToken: full.context_token,
|
|
baseUrl: deps.baseUrl,
|
|
token: deps.token,
|
|
accountId: deps.accountId,
|
|
log: deps.log,
|
|
errLog: deps.errLog,
|
|
messageType: hermesMessageType,
|
|
mediaPath: hermesMediaPath,
|
|
mediaUrl: hermesMediaUrl,
|
|
mediaType: hermesMediaType,
|
|
imageSource: hermesImageSource,
|
|
imageFollowup: hermesFollowup.imageIntent,
|
|
imageFollowupReason: hermesFollowup.reason,
|
|
imageFollowupStrategy: hermesFollowup.cacheStrategy,
|
|
quotedMessageId: hermesFollowup.quotedMessageId ?? hermesQuotedMessageId,
|
|
cachedImageAgeMs: hermesFollowup.ageMs,
|
|
rawMessage: full,
|
|
inboundContext: ctx,
|
|
};
|
|
if (hermesExplicitSlash) {
|
|
const hermesSlashResult = await handleSlashCommand(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
|
|
if (hermesSlashResult.handled) {
|
|
logger.info(`[weixin] Hermes slash command handled after media context, skipping AI pipeline`);
|
|
return;
|
|
}
|
|
}
|
|
if (!hermesQuotedTextContext.referenced && !textBody.startsWith("/")) {
|
|
const hermesImageResult = await handleHermesImplicitImage(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
|
|
if (hermesImageResult.handled) {
|
|
logger.info(`[weixin] Hermes implicit image handled, skipping AI pipeline`);
|
|
return;
|
|
}
|
|
if (hermesFollowup.imageIntent) {
|
|
logger.error(`[weixin] Hermes image follow-up failed to route, stopping direct AI pipeline reason=${hermesFollowup.reason}`);
|
|
return;
|
|
}
|
|
}
|
|
// AIOS Hermes image ingress hook end
|
|
'''.strip()
|
|
|
|
|
|
TS_QUOTED_TEXT_DISPATCH_GUARD = r'''
|
|
// AIOS quoted text dispatch guard begin
|
|
if (await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)) {
|
|
logger.info(`[weixin] quoted text fail-closed reason=${hermesQuotedTextContext.reason} quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
|
|
markDispatchIdle();
|
|
return;
|
|
}
|
|
// AIOS quoted text dispatch guard end
|
|
'''.strip()
|
|
|
|
|
|
JS_QUOTED_TEXT_DISPATCH_GUARD = r'''
|
|
// AIOS quoted text dispatch guard begin
|
|
if (await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)) {
|
|
logger.info(`[weixin] quoted text fail-closed reason=${hermesQuotedTextContext.reason} quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
|
|
markDispatchIdle();
|
|
return;
|
|
}
|
|
// AIOS quoted text dispatch guard end
|
|
'''.strip()
|
|
|
|
|
|
TS_HANDLE_SLASH_COMMAND = r'''
|
|
export async function handleSlashCommand(
|
|
content: string,
|
|
ctx: SlashCommandContext,
|
|
receivedAt: number,
|
|
eventTimestamp?: number,
|
|
): Promise<SlashCommandResult> {
|
|
const trimmed = content.trim();
|
|
if (!trimmed.startsWith("/")) {
|
|
return handleHermesImplicitImage(content, ctx, receivedAt, eventTimestamp);
|
|
}
|
|
|
|
const commandMatch = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
|
|
const command = (commandMatch?.[1] ?? "").toLowerCase();
|
|
const args = commandMatch?.[2] ?? "";
|
|
|
|
logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
|
|
|
|
try {
|
|
switch (command) {
|
|
case "/hermes":
|
|
await handleHermes(ctx, args, receivedAt, eventTimestamp);
|
|
return { handled: true };
|
|
case "/echo":
|
|
await handleEcho(ctx, args, receivedAt, eventTimestamp);
|
|
return { handled: true };
|
|
case "/toggle-debug": {
|
|
const enabled = toggleDebugMode(ctx.accountId);
|
|
await sendReply(
|
|
ctx,
|
|
enabled
|
|
? "Debug 模式已开启"
|
|
: "Debug 模式已关闭",
|
|
);
|
|
return { handled: true };
|
|
}
|
|
default:
|
|
return { handled: false };
|
|
}
|
|
} catch (err) {
|
|
logger.error(`[weixin] Slash command error: ${String(err)}`);
|
|
try {
|
|
await sendReply(ctx, `指令执行失败: ${String(err).slice(0, 200)}`);
|
|
} catch {
|
|
// The original command has already failed; preserve the root error in logs.
|
|
}
|
|
return { handled: true };
|
|
}
|
|
}
|
|
'''.strip()
|
|
|
|
|
|
JS_HANDLE_SLASH_COMMAND = r'''
|
|
export async function handleSlashCommand(content, ctx, receivedAt, eventTimestamp) {
|
|
const trimmed = content.trim();
|
|
if (!trimmed.startsWith("/")) {
|
|
return handleHermesImplicitImage(content, ctx, receivedAt, eventTimestamp);
|
|
}
|
|
const commandMatch = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
|
|
const command = (commandMatch?.[1] ?? "").toLowerCase();
|
|
const args = commandMatch?.[2] ?? "";
|
|
logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
|
|
try {
|
|
switch (command) {
|
|
case "/hermes":
|
|
await handleHermes(ctx, args, receivedAt, eventTimestamp);
|
|
return { handled: true };
|
|
case "/echo":
|
|
await handleEcho(ctx, args, receivedAt, eventTimestamp);
|
|
return { handled: true };
|
|
case "/toggle-debug": {
|
|
const enabled = toggleDebugMode(ctx.accountId);
|
|
await sendReply(ctx, enabled
|
|
? "Debug 模式已开启"
|
|
: "Debug 模式已关闭");
|
|
return { handled: true };
|
|
}
|
|
default:
|
|
return { handled: false };
|
|
}
|
|
}
|
|
catch (err) {
|
|
logger.error(`[weixin] Slash command error: ${String(err)}`);
|
|
try {
|
|
await sendReply(ctx, `指令执行失败: ${String(err).slice(0, 200)}`);
|
|
}
|
|
catch {
|
|
}
|
|
return { handled: true };
|
|
}
|
|
}
|
|
'''.strip()
|
|
|
|
|
|
TS_CHANNEL_OUTBOUND_CACHE_HELPERS = r'''
|
|
// AIOS Hermes outbound image cache helpers begin
|
|
const DEFAULT_HERMES_OUTBOUND_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
|
|
|
|
function hermesOutboundImageCachePath(): string {
|
|
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_OUTBOUND_IMAGE_CACHE_PATH).trim();
|
|
}
|
|
|
|
function hermesOutboundCacheKey(accountId: string, to: string): string {
|
|
return `${accountId || "unknown"}:${to || "unknown"}`;
|
|
}
|
|
|
|
function hermesOutboundExactCacheKey(accountId: string, to: string, messageId: string): string {
|
|
return `${hermesOutboundCacheKey(accountId, to)}:${messageId || "unknown-message"}`;
|
|
}
|
|
|
|
function hermesOutboundImageMimeFromPath(filePath: string): string {
|
|
const lower = String(filePath ?? "").toLowerCase();
|
|
if (lower.endsWith(".png")) return "image/png";
|
|
if (lower.endsWith(".webp")) return "image/webp";
|
|
return "image/jpeg";
|
|
}
|
|
|
|
function hermesOutboundPathLooksLikeImage(filePath: string): boolean {
|
|
return /\.(?:jpe?g|png|webp)$/i.test(String(filePath ?? "").trim());
|
|
}
|
|
|
|
function readHermesOutboundImageCache(): Record<string, any> {
|
|
try {
|
|
const filePath = hermesOutboundImageCachePath();
|
|
if (!filePath || !existsSync(filePath)) return {};
|
|
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
} catch (err) {
|
|
logger.warn(`[weixin] Hermes outbound image cache read skipped err=${String(err).slice(0, 160)}`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeHermesOutboundImageCache(cache: Record<string, any>): void {
|
|
try {
|
|
const filePath = hermesOutboundImageCachePath();
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8");
|
|
} catch (err) {
|
|
logger.warn(`[weixin] Hermes outbound image cache write skipped err=${String(err).slice(0, 160)}`);
|
|
}
|
|
}
|
|
|
|
function rememberHermesOutboundImage(accountId: string, to: string, filePath: string, messageId: string): void {
|
|
const normalizedPath = String(filePath ?? "").trim();
|
|
const normalizedMessageId = String(messageId ?? "").trim();
|
|
if (!normalizedPath || !normalizedMessageId || !hermesOutboundPathLooksLikeImage(normalizedPath)) return;
|
|
const cache = readHermesOutboundImageCache();
|
|
const baseKey = hermesOutboundCacheKey(accountId, to);
|
|
const shortMessageId = normalizedMessageId.replace(/^openclaw-weixin:/, "");
|
|
const entry = {
|
|
mediaPath: normalizedPath,
|
|
mediaType: hermesOutboundImageMimeFromPath(normalizedPath),
|
|
messageId: normalizedMessageId,
|
|
timestampMs: Date.now(),
|
|
source: "outbound_channel_send",
|
|
};
|
|
cache[baseKey] = entry;
|
|
cache[hermesOutboundExactCacheKey(accountId, to, normalizedMessageId)] = entry;
|
|
if (shortMessageId && shortMessageId !== normalizedMessageId) {
|
|
cache[hermesOutboundExactCacheKey(accountId, to, shortMessageId)] = entry;
|
|
}
|
|
writeHermesOutboundImageCache(cache);
|
|
logger.info(`[weixin] Hermes outbound image cache updated accountId=${accountId} to=${to} messageId=${normalizedMessageId} hasMediaPath=true exactKey=true`);
|
|
}
|
|
// AIOS Hermes outbound image cache helpers end
|
|
'''.strip()
|
|
|
|
|
|
JS_CHANNEL_OUTBOUND_CACHE_HELPERS = r'''
|
|
// AIOS Hermes outbound image cache helpers begin
|
|
const DEFAULT_HERMES_OUTBOUND_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
|
|
function hermesOutboundImageCachePath() {
|
|
return (process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE || DEFAULT_HERMES_OUTBOUND_IMAGE_CACHE_PATH).trim();
|
|
}
|
|
function hermesOutboundCacheKey(accountId, to) {
|
|
return `${accountId || "unknown"}:${to || "unknown"}`;
|
|
}
|
|
function hermesOutboundExactCacheKey(accountId, to, messageId) {
|
|
return `${hermesOutboundCacheKey(accountId, to)}:${messageId || "unknown-message"}`;
|
|
}
|
|
function hermesOutboundImageMimeFromPath(filePath) {
|
|
const lower = String(filePath ?? "").toLowerCase();
|
|
if (lower.endsWith(".png"))
|
|
return "image/png";
|
|
if (lower.endsWith(".webp"))
|
|
return "image/webp";
|
|
return "image/jpeg";
|
|
}
|
|
function hermesOutboundPathLooksLikeImage(filePath) {
|
|
return /\.(?:jpe?g|png|webp)$/i.test(String(filePath ?? "").trim());
|
|
}
|
|
function readHermesOutboundImageCache() {
|
|
try {
|
|
const filePath = hermesOutboundImageCachePath();
|
|
if (!filePath || !existsSync(filePath))
|
|
return {};
|
|
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
}
|
|
catch (err) {
|
|
logger.warn(`[weixin] Hermes outbound image cache read skipped err=${String(err).slice(0, 160)}`);
|
|
return {};
|
|
}
|
|
}
|
|
function writeHermesOutboundImageCache(cache) {
|
|
try {
|
|
const filePath = hermesOutboundImageCachePath();
|
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8");
|
|
}
|
|
catch (err) {
|
|
logger.warn(`[weixin] Hermes outbound image cache write skipped err=${String(err).slice(0, 160)}`);
|
|
}
|
|
}
|
|
function rememberHermesOutboundImage(accountId, to, filePath, messageId) {
|
|
const normalizedPath = String(filePath ?? "").trim();
|
|
const normalizedMessageId = String(messageId ?? "").trim();
|
|
if (!normalizedPath || !normalizedMessageId || !hermesOutboundPathLooksLikeImage(normalizedPath))
|
|
return;
|
|
const cache = readHermesOutboundImageCache();
|
|
const baseKey = hermesOutboundCacheKey(accountId, to);
|
|
const shortMessageId = normalizedMessageId.replace(/^openclaw-weixin:/, "");
|
|
const entry = {
|
|
mediaPath: normalizedPath,
|
|
mediaType: hermesOutboundImageMimeFromPath(normalizedPath),
|
|
messageId: normalizedMessageId,
|
|
timestampMs: Date.now(),
|
|
source: "outbound_channel_send",
|
|
};
|
|
cache[baseKey] = entry;
|
|
cache[hermesOutboundExactCacheKey(accountId, to, normalizedMessageId)] = entry;
|
|
if (shortMessageId && shortMessageId !== normalizedMessageId) {
|
|
cache[hermesOutboundExactCacheKey(accountId, to, shortMessageId)] = entry;
|
|
}
|
|
writeHermesOutboundImageCache(cache);
|
|
logger.info(`[weixin] Hermes outbound image cache updated accountId=${accountId} to=${to} messageId=${normalizedMessageId} hasMediaPath=true exactKey=true`);
|
|
}
|
|
// AIOS Hermes outbound image cache helpers end
|
|
'''.strip()
|
|
|
|
|
|
def read_text(path: pathlib.Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def write_if_changed(path: pathlib.Path, text: str, original: str) -> bool:
|
|
if text == original:
|
|
return False
|
|
path.write_text(text, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
def ensure_import(text: str, import_line: str, anchor: str) -> str:
|
|
if import_line in text:
|
|
return text
|
|
if anchor in text:
|
|
return text.replace(anchor, import_line + anchor, 1)
|
|
return import_line + text
|
|
|
|
|
|
def replace_or_insert_hermes_helpers(text: str, helpers: str) -> str:
|
|
start = text.find("const DEFAULT_HERMES_ROUTER_URL")
|
|
if start >= 0:
|
|
end = text.find("async function sendReply", start)
|
|
if end < 0:
|
|
raise RuntimeError("sendReply not found after existing Hermes helper block")
|
|
return text[:start] + helpers + "\n\n" + text[end:]
|
|
marker = "async function sendReply"
|
|
if marker not in text:
|
|
raise RuntimeError("sendReply not found for Hermes helper insertion")
|
|
return text.replace(marker, helpers + "\n\n" + marker, 1)
|
|
|
|
|
|
def ensure_ts_context_fields(text: str) -> str:
|
|
if "mediaPath?: string;" in text:
|
|
return text
|
|
needle = " errLog: (msg: string) => void;\n}"
|
|
replacement = (
|
|
" errLog: (msg: string) => void;\n"
|
|
" messageType?: string;\n"
|
|
" mediaPath?: string;\n"
|
|
" mediaUrl?: string;\n"
|
|
" mediaType?: string;\n"
|
|
" rawMessage?: unknown;\n"
|
|
" inboundContext?: unknown;\n"
|
|
"}"
|
|
)
|
|
if needle not in text:
|
|
return text
|
|
return text.replace(needle, replacement, 1)
|
|
|
|
|
|
def ensure_hermes_case(text: str) -> str:
|
|
if 'case "/hermes":' in text:
|
|
return text
|
|
pattern = re.compile(r'(?m)^(\s*)case "/echo":')
|
|
match = pattern.search(text)
|
|
if not match:
|
|
raise RuntimeError("slash command /echo case not found")
|
|
indent = match.group(1)
|
|
stmt_indent = indent + (" " if len(indent) <= 6 else " ")
|
|
hermes_case = (
|
|
f'{indent}case "/hermes":\n'
|
|
f'{stmt_indent}await handleHermes(ctx, args, receivedAt, eventTimestamp);\n'
|
|
f'{stmt_indent}return {{ handled: true }};\n'
|
|
f'{indent}case "/echo":'
|
|
)
|
|
return pattern.sub(hermes_case, text, count=1)
|
|
|
|
|
|
def replace_non_slash_branch(text: str, is_ts: bool) -> str:
|
|
var_match = re.search(r"const trimmed = (content|text|input)\.trim\(\);", text)
|
|
if not var_match:
|
|
raise RuntimeError("handleSlashCommand trim source not found")
|
|
source_var = var_match.group(1)
|
|
replacement = (
|
|
' if (!trimmed.startsWith("/")) {\n'
|
|
f' return handleHermesImplicitImage({source_var}, ctx, receivedAt, eventTimestamp);\n'
|
|
' }\n'
|
|
if is_ts
|
|
else ' if (!trimmed.startsWith("/")) {\n'
|
|
f' return handleHermesImplicitImage({source_var}, ctx, receivedAt, eventTimestamp);\n'
|
|
' }\n'
|
|
)
|
|
pattern = re.compile(
|
|
r'\s*if \(!trimmed\.startsWith\("/"\)\) \{.*?\n\s*\}\n',
|
|
re.S,
|
|
)
|
|
text, count = pattern.subn(replacement, text, count=1)
|
|
if count == 0:
|
|
raise RuntimeError("handleSlashCommand non-slash branch not found")
|
|
return text
|
|
|
|
|
|
def replace_handle_slash_command(text: str, is_ts: bool) -> str:
|
|
start = text.find("export async function handleSlashCommand")
|
|
if start < 0:
|
|
raise RuntimeError("handleSlashCommand function not found")
|
|
source_map_start = text.find("\n//# sourceMappingURL", start)
|
|
if source_map_start >= 0:
|
|
return text[:start] + (TS_HANDLE_SLASH_COMMAND if is_ts else JS_HANDLE_SLASH_COMMAND) + text[source_map_start:]
|
|
return text[:start] + (TS_HANDLE_SLASH_COMMAND if is_ts else JS_HANDLE_SLASH_COMMAND) + "\n"
|
|
|
|
|
|
def replace_or_insert_process_helpers(text: str, is_ts: bool) -> str:
|
|
start_marker = "// AIOS Hermes image follow-up helpers begin"
|
|
end_marker = "// AIOS Hermes image follow-up helpers end"
|
|
helpers = TS_PROCESS_HELPERS if is_ts else JS_PROCESS_HELPERS
|
|
start = text.find(start_marker)
|
|
if start >= 0:
|
|
end = text.find(end_marker, start)
|
|
if end < 0:
|
|
raise RuntimeError("unterminated Hermes process helper block")
|
|
end = text.find("\n", end)
|
|
if end < 0:
|
|
end = len(text)
|
|
else:
|
|
end += 1
|
|
while end < len(text) and text[end] == "\n":
|
|
end += 1
|
|
text = text[:start] + helpers + "\n\n" + text[end:]
|
|
return text
|
|
marker = "export async function processOneMessage"
|
|
if marker not in text:
|
|
raise RuntimeError("processOneMessage function not found for Hermes helper insertion")
|
|
return text.replace(marker, helpers + "\n\n" + marker, 1)
|
|
|
|
|
|
def replace_or_insert_channel_outbound_helpers(text: str, is_ts: bool) -> str:
|
|
start_marker = "// AIOS Hermes outbound image cache helpers begin"
|
|
end_marker = "// AIOS Hermes outbound image cache helpers end"
|
|
helpers = TS_CHANNEL_OUTBOUND_CACHE_HELPERS if is_ts else JS_CHANNEL_OUTBOUND_CACHE_HELPERS
|
|
start = text.find(start_marker)
|
|
if start >= 0:
|
|
end = text.find(end_marker, start)
|
|
if end < 0:
|
|
raise RuntimeError("unterminated Hermes outbound image cache helper block")
|
|
end = text.find("\n", end)
|
|
if end < 0:
|
|
end = len(text)
|
|
else:
|
|
end += 1
|
|
return text[:start] + helpers + "\n\n" + text[end:]
|
|
marker = "const MEDIA_OUTBOUND_TEMP_DIR ="
|
|
idx = text.find(marker)
|
|
if idx < 0:
|
|
raise RuntimeError("MEDIA_OUTBOUND_TEMP_DIR not found for outbound cache helper insertion")
|
|
line_end = text.find("\n", idx)
|
|
if line_end < 0:
|
|
line_end = len(text)
|
|
return text[: line_end + 1] + "\n" + helpers + "\n\n" + text[line_end + 1 :]
|
|
|
|
|
|
def patch_channel_outbound_cache(text: str) -> str:
|
|
if "rememberHermesOutboundImage(account.accountId, ctx.to, filePath, result.messageId);" in text:
|
|
return text
|
|
pattern = re.compile(
|
|
r'(?m)^([ \t]*)emitWeixinMessageSent\(\{ to: ctx\.to, content: text, success: true, accountId: account\.accountId \}\);\n'
|
|
r'\1return \{ channel: "openclaw-weixin", messageId: result\.messageId \};'
|
|
)
|
|
match = pattern.search(text)
|
|
if match:
|
|
indent = match.group(1)
|
|
replacement = (
|
|
f"{indent}rememberHermesOutboundImage(account.accountId, ctx.to, filePath, result.messageId);\n"
|
|
f"{indent}emitWeixinMessageSent({{ to: ctx.to, content: text, success: true, accountId: account.accountId }});\n"
|
|
f'{indent}return {{ channel: "openclaw-weixin", messageId: result.messageId }};'
|
|
)
|
|
return text[: match.start()] + replacement + text[match.end() :]
|
|
raise RuntimeError("sendWeixinMediaFile success branch not found for outbound cache patch")
|
|
|
|
|
|
def remove_managed_process_block(text: str, start_marker: str, end_marker: str, label: str) -> str:
|
|
while True:
|
|
start = text.find(start_marker)
|
|
if start < 0:
|
|
return text
|
|
line_start = text.rfind("\n", 0, start)
|
|
block_start = 0 if line_start < 0 else line_start + 1
|
|
if line_start > 0 and text[line_start - 1] == "\n":
|
|
block_start = line_start
|
|
end = text.find(end_marker, start)
|
|
if end < 0:
|
|
raise RuntimeError(f"unterminated {label} block")
|
|
end = text.find("\n", end)
|
|
block_end = len(text) if end < 0 else end + 1
|
|
while block_end < len(text) and text[block_end] == "\n":
|
|
block_end += 1
|
|
text = text[:block_start] + text[block_end:]
|
|
|
|
|
|
def remove_existing_process_hermes_hook(text: str) -> str:
|
|
text = remove_managed_process_block(
|
|
text,
|
|
"// AIOS Hermes image ingress hook begin",
|
|
"// AIOS Hermes image ingress hook end",
|
|
"Hermes process hook",
|
|
)
|
|
text = remove_managed_process_block(
|
|
text,
|
|
"// AIOS quoted text dispatch guard begin",
|
|
"// AIOS quoted text dispatch guard end",
|
|
"quoted text dispatch guard",
|
|
)
|
|
|
|
old_hook_pattern = re.compile(
|
|
r'\n\s*if \(!textBody\.startsWith\("/"\)\) \{\n'
|
|
r'.*?logger\.info\(`\[weixin\] Hermes implicit image handled, skipping AI pipeline`\);\n'
|
|
r'\s*return;\n'
|
|
r'\s*\}\n'
|
|
r'\s*\}\n',
|
|
re.S,
|
|
)
|
|
text, count = old_hook_pattern.subn("\n", text, count=1)
|
|
return text
|
|
|
|
|
|
def defer_hermes_slash_until_media_context(text: str) -> str:
|
|
text = text.replace(
|
|
'if (textBody.startsWith("/")) {',
|
|
'if (textBody.startsWith("/") && !isHermesSlashCommandText(textBody)) {',
|
|
1,
|
|
)
|
|
return text
|
|
|
|
|
|
def prefer_recursive_referenced_media(text: str) -> str:
|
|
direct_ref_pattern = re.compile(
|
|
r'const refMediaItem = !mainMediaItem\s*'
|
|
r'\?\s*full\.item_list\?\.find\(\(i(?::\s*any)?\) => i\.type === MessageItemType\.TEXT &&\s*'
|
|
r'i\.ref_msg\?\.message_item &&\s*'
|
|
r'isMediaItem\(i\.ref_msg\.message_item\)\)\?\.ref_msg\?\.message_item\s*'
|
|
r':\s*undefined;'
|
|
)
|
|
text, _ = direct_ref_pattern.subn('const refMediaItem = findHermesReferencedMediaItem(full.item_list);', text, count=1)
|
|
text = text.replace(
|
|
'const refMediaItem = !mainMediaItem ? findHermesReferencedMediaItem(full.item_list) : undefined;',
|
|
'const refMediaItem = findHermesReferencedMediaItem(full.item_list);',
|
|
1,
|
|
)
|
|
text = text.replace(
|
|
'const mediaItem = mainMediaItem ?? refMediaItem;',
|
|
'const mediaItem = refMediaItem ?? (hasHermesReferenceContext(full.item_list) ? undefined : mainMediaItem);',
|
|
1,
|
|
)
|
|
text = text.replace(
|
|
'const mediaItem = mainMediaItem ?? refMediaItem ?? { type: MessageItemType.IMAGE };',
|
|
'const mediaItem = refMediaItem ?? (hasHermesReferenceContext(full.item_list) ? undefined : mainMediaItem) ?? { type: MessageItemType.IMAGE };',
|
|
1,
|
|
)
|
|
text = text.replace(
|
|
'const mediaItem = refMediaItem ?? mainMediaItem;',
|
|
'const mediaItem = refMediaItem ?? (hasHermesReferenceContext(full.item_list) ? undefined : mainMediaItem);',
|
|
1,
|
|
)
|
|
text = text.replace(
|
|
'const mediaItem = refMediaItem ?? mainMediaItem ?? { type: MessageItemType.IMAGE };',
|
|
'const mediaItem = refMediaItem ?? (hasHermesReferenceContext(full.item_list) ? undefined : mainMediaItem) ?? { type: MessageItemType.IMAGE };',
|
|
1,
|
|
)
|
|
return text
|
|
|
|
|
|
def patch_slash_command_file(path: pathlib.Path, is_ts: bool) -> bool:
|
|
text = read_text(path)
|
|
original = text
|
|
if is_ts:
|
|
text = ensure_import(text, 'import { existsSync, readFileSync, statSync } from "node:fs";\n', 'import type { WeixinApiOptions } from "../api/api.js";\n')
|
|
text = ensure_import(text, 'import { randomBytes } from "node:crypto";\n', 'import type { WeixinApiOptions } from "../api/api.js";\n')
|
|
text = ensure_ts_context_fields(text)
|
|
text = replace_or_insert_hermes_helpers(text, TS_HERMES_HELPERS)
|
|
else:
|
|
text = ensure_import(text, 'import { existsSync, readFileSync, statSync } from "node:fs";\n', 'import { logger } from "../util/logger.js";\n')
|
|
text = ensure_import(text, 'import { randomBytes } from "node:crypto";\n', 'import { logger } from "../util/logger.js";\n')
|
|
text = replace_or_insert_hermes_helpers(text, JS_HERMES_HELPERS)
|
|
text = replace_handle_slash_command(text, is_ts=is_ts)
|
|
return write_if_changed(path, text, original)
|
|
|
|
|
|
def patch_process_message_file(path: pathlib.Path, is_ts: bool) -> bool:
|
|
text = read_text(path)
|
|
original = text
|
|
fs_import = 'import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";\n'
|
|
text = text.replace(
|
|
'import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";',
|
|
'import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";',
|
|
)
|
|
text = ensure_import(text, fs_import, 'import path from "node:path";\n')
|
|
text = ensure_import(text, 'import path from "node:path";\n', 'import { randomUUID } from "node:crypto";\n')
|
|
text = text.replace(
|
|
'import { handleSlashCommand } from "./slash-commands.js";',
|
|
'import { handleHermesImplicitImage, handleSlashCommand } from "./slash-commands.js";',
|
|
1,
|
|
)
|
|
if "handleHermesImplicitImage" not in text:
|
|
raise RuntimeError("process-message slash import not found")
|
|
text = replace_or_insert_process_helpers(text, is_ts=is_ts)
|
|
text = remove_existing_process_hermes_hook(text)
|
|
text = defer_hermes_slash_until_media_context(text)
|
|
text = prefer_recursive_referenced_media(text)
|
|
hook = TS_PROCESS_HOOK if is_ts else JS_PROCESS_HOOK
|
|
needle = " ctx.CommandAuthorized = commandAuthorized;\n" if is_ts else " ctx.CommandAuthorized = commandAuthorized;\n"
|
|
if needle not in text:
|
|
raise RuntimeError("ctx.CommandAuthorized assignment not found")
|
|
text = text.replace(needle, needle + hook + "\n", 1)
|
|
dispatch_guard = TS_QUOTED_TEXT_DISPATCH_GUARD if is_ts else JS_QUOTED_TEXT_DISPATCH_GUARD
|
|
dispatch_needle = (
|
|
' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'
|
|
if is_ts
|
|
else ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'
|
|
)
|
|
if dispatch_needle not in text:
|
|
raise RuntimeError("dispatchReplyFromConfig start log not found")
|
|
text = text.replace("\n\n" + dispatch_needle, "\n" + dispatch_needle, 1)
|
|
text = text.replace(dispatch_needle, dispatch_guard + "\n" + dispatch_needle, 1)
|
|
return write_if_changed(path, text, original)
|
|
|
|
|
|
def patch_channel_file(path: pathlib.Path, is_ts: bool) -> bool:
|
|
text = read_text(path)
|
|
original = text
|
|
fs_import = 'import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";\n'
|
|
text = ensure_import(text, fs_import, 'import path from "node:path";\n')
|
|
text = replace_or_insert_channel_outbound_helpers(text, is_ts=is_ts)
|
|
text = patch_channel_outbound_cache(text)
|
|
return write_if_changed(path, text, original)
|
|
|
|
|
|
def patch_ts(root: pathlib.Path) -> bool:
|
|
return patch_slash_command_file(root / "src/messaging/slash-commands.ts", is_ts=True)
|
|
|
|
|
|
def patch_js(root: pathlib.Path) -> bool:
|
|
return patch_slash_command_file(root / "dist/src/messaging/slash-commands.js", is_ts=False)
|
|
|
|
|
|
def patch_process_messages(root: pathlib.Path) -> dict[str, bool]:
|
|
changes: dict[str, bool] = {}
|
|
for rel, is_ts in (
|
|
("src/messaging/process-message.ts", True),
|
|
("dist/src/messaging/process-message.js", False),
|
|
):
|
|
path = root / rel
|
|
if path.exists():
|
|
changes[rel] = patch_process_message_file(path, is_ts=is_ts)
|
|
else:
|
|
changes[rel] = False
|
|
return changes
|
|
|
|
|
|
def patch_channel_files(root: pathlib.Path) -> dict[str, bool]:
|
|
changes: dict[str, bool] = {}
|
|
for rel, is_ts in (
|
|
("src/channel.ts", True),
|
|
("dist/src/channel.js", False),
|
|
):
|
|
path = root / rel
|
|
if path.exists():
|
|
changes[rel] = patch_channel_file(path, is_ts=is_ts)
|
|
return changes
|
|
|
|
|
|
def patch_existing_runtime_behavior(root: pathlib.Path) -> dict[str, bool]:
|
|
# Compatibility entry point kept for older tests and operators.
|
|
changes = patch_process_messages(root)
|
|
changes.update({f"channel:{key}": value for key, value in patch_channel_files(root).items()})
|
|
return changes
|
|
|
|
|
|
def source_files(root: pathlib.Path) -> list[pathlib.Path]:
|
|
files: list[pathlib.Path] = []
|
|
for rel_root in ("src", "dist/src"):
|
|
base = root / rel_root
|
|
if not base.exists():
|
|
continue
|
|
files.extend(path for path in base.rglob("*") if path.suffix in {".ts", ".js", ".mjs", ".cjs"})
|
|
return sorted(set(files))
|
|
|
|
|
|
def verify_plugin_hook(root: pathlib.Path) -> dict[str, object]:
|
|
hook_files = [
|
|
root / "src/messaging/slash-commands.ts",
|
|
root / "dist/src/messaging/slash-commands.js",
|
|
]
|
|
hook_status: dict[str, bool] = {}
|
|
for path in hook_files:
|
|
if not path.exists():
|
|
hook_status[str(path.relative_to(root))] = False
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
hook_status[str(path.relative_to(root))] = (
|
|
"export async function handleHermesImplicitImage" in text
|
|
and "shouldAutoRouteHermesImage" in text
|
|
and "localHermesImageToDataUrl" in text
|
|
and 'case "/hermes":' in text
|
|
and "return handleHermesImplicitImage(" in text
|
|
)
|
|
|
|
process_files = [
|
|
root / "src/messaging/process-message.ts",
|
|
root / "dist/src/messaging/process-message.js",
|
|
]
|
|
process_status: dict[str, bool] = {}
|
|
for path in process_files:
|
|
if not path.exists():
|
|
process_status[str(path.relative_to(root))] = False
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
process_status[str(path.relative_to(root))] = (
|
|
"handleHermesImplicitImage" in text
|
|
and "Hermes implicit image handled, skipping AI pipeline" in text
|
|
and "rememberHermesLastImage" in text
|
|
and "resolveHermesFollowupImageContext" in text
|
|
and "matched cached media" in text
|
|
and "has no cached media, fail-closed" in text
|
|
and "resolveHermesQuotedTextContext" in text
|
|
and "dispatchHermesQuotedTextFailClosed" in text
|
|
and "AIOS quoted text dispatch guard begin" in text
|
|
and "无法读取被引用文字,请复制原文后再提问。" in text
|
|
and "mediaPath" in text
|
|
)
|
|
|
|
channel_files = [
|
|
root / "src/channel.ts",
|
|
root / "dist/src/channel.js",
|
|
]
|
|
channel_status: dict[str, bool] = {}
|
|
for path in channel_files:
|
|
if not path.exists():
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
channel_status[str(path.relative_to(root))] = (
|
|
"rememberHermesOutboundImage" in text
|
|
and "hermesOutboundExactCacheKey" in text
|
|
and "outbound_channel_send" in text
|
|
and "Hermes outbound image cache updated" in text
|
|
and "sendWeixinMediaFile({" in text
|
|
)
|
|
|
|
dispatch_refs: list[str] = []
|
|
for path in source_files(root):
|
|
rel = str(path.relative_to(root)).replace("\\", "/")
|
|
if rel in {
|
|
"src/messaging/slash-commands.ts",
|
|
"dist/src/messaging/slash-commands.js",
|
|
}:
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
if "handleHermesImplicitImage" in text or "handleSlashCommand" in text or "slash-commands" in text:
|
|
dispatch_refs.append(rel)
|
|
|
|
return {
|
|
"hook_status": hook_status,
|
|
"hook_installed": all(hook_status.values()) if hook_status else False,
|
|
"process_status": process_status,
|
|
"process_hook_installed": all(process_status.values()) if process_status else False,
|
|
"channel_status": channel_status,
|
|
"channel_hook_installed": all(channel_status.values()) if channel_status else True,
|
|
"dispatch_reference_found": bool(dispatch_refs),
|
|
"dispatch_reference_files": dispatch_refs[:20],
|
|
}
|
|
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(prog='patch_openclaw_weixin_hermes.py')
|
|
parser.add_argument('plugin_root', nargs='?', type=pathlib.Path, default=DEFAULT_PLUGIN_ROOT)
|
|
parser.add_argument('--process-only', action='store_true', help='only patch process-message files')
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def patch_plugin(root: pathlib.Path, process_only: bool = False) -> dict[str, object]:
|
|
mode = 'process-only' if process_only else 'full'
|
|
changed_ts = False
|
|
changed_js = False
|
|
slash_changes: dict[str, bool] = {}
|
|
process_changes: dict[str, bool] = {}
|
|
channel_changes: dict[str, bool] = {}
|
|
|
|
if process_only:
|
|
process_changes = patch_process_messages(root)
|
|
else:
|
|
changed_ts = patch_ts(root)
|
|
changed_js = patch_js(root)
|
|
process_changes = patch_process_messages(root)
|
|
channel_changes = patch_channel_files(root)
|
|
slash_changes = {
|
|
'src/messaging/slash-commands.ts': changed_ts,
|
|
'dist/src/messaging/slash-commands.js': changed_js,
|
|
}
|
|
|
|
verification = verify_plugin_hook(root)
|
|
result = {
|
|
'plugin_root': str(root),
|
|
'mode': mode,
|
|
'changed_ts': changed_ts,
|
|
'changed_js': changed_js,
|
|
'slash_changes': slash_changes,
|
|
'process_changes': process_changes,
|
|
'channel_changes': channel_changes,
|
|
'behavior_changes': process_changes if process_only else {**process_changes, **channel_changes},
|
|
'verification': verification,
|
|
}
|
|
return result
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
root = args.plugin_root
|
|
if not root.exists():
|
|
raise SystemExit(f'plugin root not found: {root}')
|
|
result = patch_plugin(root, process_only=args.process_only)
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
verification = result['verification']
|
|
if args.process_only:
|
|
if not verification.get('process_hook_installed'):
|
|
raise SystemExit('process-message implicit image dispatch hook not installed')
|
|
return 0
|
|
if not verification.get('hook_installed'):
|
|
raise SystemExit('Hermes slash/image hook not installed')
|
|
if not verification.get('process_hook_installed'):
|
|
raise SystemExit('process-message implicit image dispatch hook not installed')
|
|
if not verification.get('channel_hook_installed'):
|
|
raise SystemExit('channel outbound image cache hook not installed')
|
|
if not verification.get('dispatch_reference_found'):
|
|
raise SystemExit('dispatch reference to Hermes slash/image hook not found')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|