mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(channel): weixin voice ASR fallback, wecom mixed message handling
This commit is contained in:
parent
d5e3502829
commit
881badd15f
@ -508,6 +508,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
|
|
||||||
List<MessageContentPart> contentParts = new ArrayList<>();
|
List<MessageContentPart> contentParts = new ArrayList<>();
|
||||||
String textContent = null;
|
String textContent = null;
|
||||||
|
boolean hasVoice = false;
|
||||||
|
|
||||||
switch (msgType) {
|
switch (msgType) {
|
||||||
case "text" -> {
|
case "text" -> {
|
||||||
@ -534,6 +535,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
textContent = "[图片]";
|
textContent = "[图片]";
|
||||||
}
|
}
|
||||||
case "voice" -> {
|
case "voice" -> {
|
||||||
|
hasVoice = true;
|
||||||
Map<String, Object> voiceBody = (Map<String, Object>) body.getOrDefault("voice", Map.of());
|
Map<String, Object> voiceBody = (Map<String, Object>) body.getOrDefault("voice", Map.of());
|
||||||
String asrText = ((String) voiceBody.getOrDefault("content", "")).trim();
|
String asrText = ((String) voiceBody.getOrDefault("content", "")).trim();
|
||||||
if (!asrText.isBlank()) {
|
if (!asrText.isBlank()) {
|
||||||
@ -569,11 +571,27 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
textBuilder.append(txt).append('\n');
|
textBuilder.append(txt).append('\n');
|
||||||
}
|
}
|
||||||
} else if ("image".equals(itemType)) {
|
} else if ("image".equals(itemType)) {
|
||||||
|
// 与独立 image 消息对齐:下载 + AES 解密(对齐 CoPaw)
|
||||||
Map<String, Object> img = (Map<String, Object>) item.getOrDefault("image", Map.of());
|
Map<String, Object> img = (Map<String, Object>) item.getOrDefault("image", Map.of());
|
||||||
String url = (String) img.getOrDefault("url", "");
|
String url = (String) img.getOrDefault("url", "");
|
||||||
if (!url.isBlank()) {
|
String aesKey = (String) img.getOrDefault("aeskey", "");
|
||||||
|
if (getConfigBoolean("media_download_enabled", true) && !url.isBlank()) {
|
||||||
|
String localPath = downloadAndDecryptMedia(url, aesKey, msgId, "mixed_image.jpg");
|
||||||
|
if (localPath != null) {
|
||||||
|
contentParts.add(MessageContentPart.image(localPath, url));
|
||||||
|
} else {
|
||||||
|
contentParts.add(MessageContentPart.image(url, url));
|
||||||
|
}
|
||||||
|
} else if (!url.isBlank()) {
|
||||||
contentParts.add(MessageContentPart.image(url, url));
|
contentParts.add(MessageContentPart.image(url, url));
|
||||||
}
|
}
|
||||||
|
} else if ("voice".equals(itemType)) {
|
||||||
|
Map<String, Object> v = (Map<String, Object>) item.getOrDefault("voice", Map.of());
|
||||||
|
String asrText = ((String) v.getOrDefault("content", "")).trim();
|
||||||
|
if (!asrText.isBlank()) {
|
||||||
|
hasVoice = true;
|
||||||
|
textBuilder.append(asrText).append('\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
textContent = textBuilder.toString().trim();
|
textContent = textBuilder.toString().trim();
|
||||||
@ -621,6 +639,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
.content(textContent != null ? textContent.trim() : "")
|
.content(textContent != null ? textContent.trim() : "")
|
||||||
.contentType(msgType)
|
.contentType(msgType)
|
||||||
.contentParts(contentParts)
|
.contentParts(contentParts)
|
||||||
|
.inputMode(hasVoice ? "voice" : "text")
|
||||||
.timestamp(LocalDateTime.now())
|
.timestamp(LocalDateTime.now())
|
||||||
.replyToken(isGroup ? chatId : senderId)
|
.replyToken(isGroup ? chatId : senderId)
|
||||||
.rawPayload(Map.of(
|
.rawPayload(Map.of(
|
||||||
@ -734,7 +753,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
content, filterThinking, filterToolMessages, format, maxLen);
|
content, filterThinking, filterToolMessages, format, maxLen);
|
||||||
|
|
||||||
boolean first = true;
|
boolean first = true;
|
||||||
for (String segment : segments) {
|
for (String rawSegment : segments) {
|
||||||
|
// WeCom 专用:格式化 Markdown 表格(对齐 CoPaw format_markdown_tables)
|
||||||
|
String segment = formatMarkdownTables(rawSegment);
|
||||||
// 第一条分段用 processingStreamId 覆盖"思考中..."
|
// 第一条分段用 processingStreamId 覆盖"思考中..."
|
||||||
if (first && ctx != null && ctx.processingStreamId() != null
|
if (first && ctx != null && ctx.processingStreamId() != null
|
||||||
&& !ctx.processingStreamId().isBlank()) {
|
&& !ctx.processingStreamId().isBlank()) {
|
||||||
@ -769,6 +790,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
sentText = true;
|
sentText = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "refusal" -> {
|
||||||
|
// 模型拒绝回复(如内容策略限制),以文本形式发送
|
||||||
|
String refusalText = part.getText();
|
||||||
|
if (refusalText != null && !refusalText.isBlank()) {
|
||||||
|
sendMessage(targetId, "⚠️ " + refusalText);
|
||||||
|
sentText = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
case "image" -> sendImagePart(targetId, part, ctx);
|
case "image" -> sendImagePart(targetId, part, ctx);
|
||||||
case "audio" -> sendAudioPart(targetId, part, ctx);
|
case "audio" -> sendAudioPart(targetId, part, ctx);
|
||||||
case "file" -> sendFilePart(targetId, part, ctx);
|
case "file" -> sendFilePart(targetId, part, ctx);
|
||||||
@ -1135,6 +1164,131 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Markdown 表格格式化(对齐 CoPaw format_markdown_tables)====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 GFM Markdown 表格,使其在企业微信中对齐显示。
|
||||||
|
* <p>
|
||||||
|
* 企业微信要求表格列宽一致才能正确渲染。此方法解析表格,
|
||||||
|
* 计算每列最大宽度,统一填充空格对齐。
|
||||||
|
* 代码块内的表格不做处理。
|
||||||
|
* <p>
|
||||||
|
* 移植自 CoPaw wecom/utils.py format_markdown_tables()
|
||||||
|
*/
|
||||||
|
static String formatMarkdownTables(String text) {
|
||||||
|
if (text == null || !text.contains("|")) return text;
|
||||||
|
|
||||||
|
String[] lines = text.split("\n", -1);
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
int i = 0;
|
||||||
|
boolean inCodeFence = false;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
String line = lines[i];
|
||||||
|
String stripped = line.strip();
|
||||||
|
|
||||||
|
// 跟踪代码块(``` 内的内容不处理)
|
||||||
|
if (stripped.startsWith("```")) {
|
||||||
|
inCodeFence = !inCodeFence;
|
||||||
|
result.add(line);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCodeFence) {
|
||||||
|
result.add(line);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测表格开始(含 | 的行)
|
||||||
|
if (line.contains("|")) {
|
||||||
|
List<String> tableLines = new ArrayList<>();
|
||||||
|
while (i < lines.length && lines[i].contains("|")
|
||||||
|
&& !lines[i].strip().startsWith("```")) {
|
||||||
|
tableLines.add(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!tableLines.isEmpty()) {
|
||||||
|
result.addAll(formatTable(tableLines));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.add(line);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return String.join("\n", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化单个 Markdown 表格
|
||||||
|
*/
|
||||||
|
private static List<String> formatTable(List<String> lines) {
|
||||||
|
if (lines.isEmpty()) return lines;
|
||||||
|
|
||||||
|
// 检测第二行是否为分隔行(只含 -, :, |, 空格)
|
||||||
|
boolean hasSeparator = lines.size() >= 2
|
||||||
|
&& lines.get(1).strip().matches("[\\s\\-:|]+");
|
||||||
|
|
||||||
|
// 解析单元格(跳过分隔行,后面会重建)
|
||||||
|
List<List<String>> rows = new ArrayList<>();
|
||||||
|
for (int idx = 0; idx < lines.size(); idx++) {
|
||||||
|
if (hasSeparator && idx == 1) continue;
|
||||||
|
String[] cells = lines.get(idx).split("\\|", -1);
|
||||||
|
List<String> trimmed = new ArrayList<>();
|
||||||
|
for (String cell : cells) {
|
||||||
|
trimmed.add(cell.strip());
|
||||||
|
}
|
||||||
|
// 去掉首尾空元素(由前导/尾随 | 产生)
|
||||||
|
if (!trimmed.isEmpty() && trimmed.getFirst().isEmpty()) trimmed.removeFirst();
|
||||||
|
if (!trimmed.isEmpty() && trimmed.getLast().isEmpty()) trimmed.removeLast();
|
||||||
|
if (!trimmed.isEmpty()) rows.add(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.isEmpty()) return lines;
|
||||||
|
|
||||||
|
// 计算每列最大宽度
|
||||||
|
int colCount = rows.stream().mapToInt(List::size).max().orElse(0);
|
||||||
|
int[] widths = new int[colCount];
|
||||||
|
for (List<String> row : rows) {
|
||||||
|
for (int j = 0; j < colCount; j++) {
|
||||||
|
String cell = j < row.size() ? row.get(j) : "";
|
||||||
|
widths[j] = Math.max(widths[j], cell.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建格式化结果
|
||||||
|
List<String> formatted = new ArrayList<>();
|
||||||
|
for (int idx = 0; idx < rows.size(); idx++) {
|
||||||
|
List<String> row = rows.get(idx);
|
||||||
|
StringBuilder sb = new StringBuilder("| ");
|
||||||
|
for (int j = 0; j < colCount; j++) {
|
||||||
|
String cell = j < row.size() ? row.get(j) : "";
|
||||||
|
sb.append(padRight(cell, widths[j]));
|
||||||
|
if (j < colCount - 1) sb.append(" | ");
|
||||||
|
}
|
||||||
|
sb.append(" |");
|
||||||
|
formatted.add(sb.toString());
|
||||||
|
|
||||||
|
// 头部行后插入分隔行
|
||||||
|
if (idx == 0) {
|
||||||
|
StringBuilder sep = new StringBuilder("| ");
|
||||||
|
for (int j = 0; j < colCount; j++) {
|
||||||
|
sep.append("-".repeat(Math.max(3, widths[j])));
|
||||||
|
if (j < colCount - 1) sep.append(" | ");
|
||||||
|
}
|
||||||
|
sep.append(" |");
|
||||||
|
formatted.add(sep.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String padRight(String s, int width) {
|
||||||
|
if (s.length() >= width) return s;
|
||||||
|
return s + " ".repeat(width - s.length());
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 帧发送基础设施 ====================
|
// ==================== 帧发送基础设施 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -83,6 +83,19 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
/** 用户最新 context_token 缓存(用于主动推送) */
|
/** 用户最新 context_token 缓存(用于主动推送) */
|
||||||
private final ConcurrentHashMap<String, String> userContextTokens = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, String> userContextTokens = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** context_token 持久化文件路径 */
|
||||||
|
private Path contextTokensFile;
|
||||||
|
|
||||||
|
/** bot_token 持久化文件路径 */
|
||||||
|
private Path botTokenFile;
|
||||||
|
|
||||||
|
/** 文件名扩展名列表(用于过滤纯文件名文本,避免误触发 Agent) */
|
||||||
|
private static final Set<String> FILENAME_EXTENSIONS = Set.of(
|
||||||
|
".txt", ".doc", ".docx", ".pdf", ".jpg", ".jpeg", ".png", ".gif",
|
||||||
|
".mp4", ".avi", ".mov", ".mp3", ".wav", ".zip", ".rar",
|
||||||
|
".xlsx", ".xls", ".ppt", ".pptx", ".csv", ".json", ".xml"
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== 输入中提示 ====================
|
// ==================== 输入中提示 ====================
|
||||||
|
|
||||||
/** 输入提示 ticket 缓存:userId -> (ticket, expireTime) */
|
/** 输入提示 ticket 缓存:userId -> (ticket, expireTime) */
|
||||||
@ -124,7 +137,17 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doStart() {
|
protected void doStart() {
|
||||||
|
// 初始化持久化路径
|
||||||
|
String dataDir = getConfigString("data_dir", "data/weixin");
|
||||||
|
Path dataDirPath = Path.of(dataDir, String.valueOf(channelEntity.getId()));
|
||||||
|
botTokenFile = dataDirPath.resolve("bot_token.txt");
|
||||||
|
contextTokensFile = dataDirPath.resolve("context_tokens.json");
|
||||||
|
|
||||||
|
// bot_token 优先级:config > 持久化文件
|
||||||
String botToken = getConfigString("bot_token", "");
|
String botToken = getConfigString("bot_token", "");
|
||||||
|
if (botToken.isBlank()) {
|
||||||
|
botToken = loadBotTokenFromFile();
|
||||||
|
}
|
||||||
String baseUrl = getConfigString("base_url", ILinkClient.DEFAULT_BASE_URL);
|
String baseUrl = getConfigString("base_url", ILinkClient.DEFAULT_BASE_URL);
|
||||||
|
|
||||||
if (botToken.isBlank()) {
|
if (botToken.isBlank()) {
|
||||||
@ -139,6 +162,12 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 加载持久化的 context_tokens(用于重启后主动推送)
|
||||||
|
loadContextTokens();
|
||||||
|
|
||||||
|
// 持久化 bot_token(QR 登录后或首次启动时保存)
|
||||||
|
saveBotTokenToFile(botToken);
|
||||||
|
|
||||||
// 启动长轮询线程
|
// 启动长轮询线程
|
||||||
stopSignal.set(false);
|
stopSignal.set(false);
|
||||||
cursor = "";
|
cursor = "";
|
||||||
@ -146,14 +175,19 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
pollThread.setDaemon(true);
|
pollThread.setDaemon(true);
|
||||||
pollThread.start();
|
pollThread.start();
|
||||||
|
|
||||||
log.info("[weixin] Channel started: {} (token={}...)", channelEntity.getName(),
|
log.info("[weixin] Channel started: {} (token={}..., cached_contexts={})",
|
||||||
botToken.substring(0, Math.min(12, botToken.length())));
|
channelEntity.getName(),
|
||||||
|
botToken.substring(0, Math.min(12, botToken.length())),
|
||||||
|
userContextTokens.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doStop() {
|
protected void doStop() {
|
||||||
stopSignal.set(true);
|
stopSignal.set(true);
|
||||||
|
|
||||||
|
// 持久化 context_tokens(重启后可恢复主动推送能力)
|
||||||
|
saveContextTokens();
|
||||||
|
|
||||||
// 停止所有输入提示任务
|
// 停止所有输入提示任务
|
||||||
typingTasks.values().forEach(f -> f.cancel(false));
|
typingTasks.values().forEach(f -> f.cancel(false));
|
||||||
typingTasks.clear();
|
typingTasks.clear();
|
||||||
@ -275,10 +309,10 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
|
|
||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
case 1 -> {
|
case 1 -> {
|
||||||
// Text
|
// Text — 过滤纯文件名文本(借鉴 CoPaw: 避免文件名误触发 Agent)
|
||||||
Map<String, Object> textItem = (Map<String, Object>) item.getOrDefault("text_item", Map.of());
|
Map<String, Object> textItem = (Map<String, Object>) item.getOrDefault("text_item", Map.of());
|
||||||
String text = getStr(textItem, "text").strip();
|
String text = getStr(textItem, "text").strip();
|
||||||
if (!text.isEmpty()) {
|
if (!text.isEmpty() && !isFilenameOnly(text)) {
|
||||||
textParts.add(text);
|
textParts.add(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -313,14 +347,53 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
}
|
}
|
||||||
case 3 -> {
|
case 3 -> {
|
||||||
// Voice — 使用 ASR 语音识别文本
|
// Voice — 使用 ASR 语音识别文本
|
||||||
|
// iLink API 的 ASR 文本可能在两个位置(参考 CoPaw 实现):
|
||||||
|
// 路径1: voice_item.text_item.text(嵌套结构)
|
||||||
|
// 路径2: voice_item.text(直接结构)
|
||||||
hasVoice = true;
|
hasVoice = true;
|
||||||
Map<String, Object> voiceItem = (Map<String, Object>) item.getOrDefault("voice_item", Map.of());
|
Map<String, Object> voiceItem = (Map<String, Object>) item.getOrDefault("voice_item", Map.of());
|
||||||
Map<String, Object> voiceTextItem = (Map<String, Object>) voiceItem.getOrDefault("text_item", Map.of());
|
String asrText = "";
|
||||||
String asrText = getStr(voiceTextItem, "text").strip();
|
|
||||||
|
// 路径1: voice_item → text_item → text
|
||||||
|
Object textItemObj = voiceItem.get("text_item");
|
||||||
|
if (textItemObj instanceof Map<?,?> textItemMap) {
|
||||||
|
asrText = getStr((Map<String, Object>) textItemMap, "text").strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径2: voice_item → text(直接字段,CoPaw fallback)
|
||||||
|
if (asrText.isEmpty()) {
|
||||||
|
asrText = getStr(voiceItem, "text").strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路径3: voice_item → content(与 WeCom 一致的字段名)
|
||||||
|
if (asrText.isEmpty()) {
|
||||||
|
asrText = getStr(voiceItem, "content").strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("[weixin] Voice item payload: {}", voiceItem);
|
||||||
|
|
||||||
if (!asrText.isEmpty()) {
|
if (!asrText.isEmpty()) {
|
||||||
textParts.add(asrText);
|
textParts.add(asrText);
|
||||||
|
log.info("[weixin] Voice ASR text: {}", asrText.length() > 50
|
||||||
|
? asrText.substring(0, 50) + "..." : asrText);
|
||||||
} else {
|
} else {
|
||||||
textParts.add("[语音: 无转写结果]");
|
// ASR 为空:可能是语音过短、噪音、或 iLink API 字段变更
|
||||||
|
// 尝试下载语音文件保存到本地(供后续调试 / 自有 STT 使用)
|
||||||
|
if (mediaDownloadEnabled) {
|
||||||
|
String voicePath = downloadMediaItem(item, "voice_item", "voice.amr", mediaDir);
|
||||||
|
if (voicePath != null) {
|
||||||
|
// 保存为 audio content part,即使无 ASR 文本
|
||||||
|
MessageContentPart audioPart = new MessageContentPart();
|
||||||
|
audioPart.setType("audio");
|
||||||
|
audioPart.setPath(voicePath);
|
||||||
|
audioPart.setFileName("voice.amr");
|
||||||
|
contentParts.add(audioPart);
|
||||||
|
log.info("[weixin] Voice audio downloaded (no ASR): {}", voicePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textParts.add("[语音消息]");
|
||||||
|
log.warn("[weixin] Voice message with no ASR result. voice_item keys: {}, full: {}",
|
||||||
|
voiceItem.keySet(), voiceItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 4 -> {
|
case 4 -> {
|
||||||
@ -383,9 +456,13 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存 context_token(用于主动推送)
|
// 缓存 context_token(用于主动推送)并定期持久化
|
||||||
if (!fromUserId.isBlank() && !contextToken.isBlank()) {
|
if (!fromUserId.isBlank() && !contextToken.isBlank()) {
|
||||||
userContextTokens.put(fromUserId, contextToken);
|
String prev = userContextTokens.put(fromUserId, contextToken);
|
||||||
|
// token 变更时才持久化(减少 I/O)
|
||||||
|
if (!contextToken.equals(prev)) {
|
||||||
|
saveContextTokens();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建统一消息
|
// 构建统一消息
|
||||||
@ -842,4 +919,89 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Token 持久化(对齐 CoPaw)====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件加载 bot_token(启动时如果 config 中无 token,尝试从文件恢复)
|
||||||
|
*/
|
||||||
|
private String loadBotTokenFromFile() {
|
||||||
|
if (botTokenFile == null) return "";
|
||||||
|
try {
|
||||||
|
if (Files.exists(botTokenFile)) {
|
||||||
|
String token = Files.readString(botTokenFile).strip();
|
||||||
|
if (!token.isBlank()) {
|
||||||
|
log.info("[weixin] Loaded bot_token from {}", botTokenFile);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[weixin] Failed to read bot_token file: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化 bot_token 到文件(QR 登录后或首次启动时保存)
|
||||||
|
*/
|
||||||
|
private void saveBotTokenToFile(String token) {
|
||||||
|
if (botTokenFile == null || token == null || token.isBlank()) return;
|
||||||
|
try {
|
||||||
|
Files.createDirectories(botTokenFile.getParent());
|
||||||
|
Files.writeString(botTokenFile, token);
|
||||||
|
log.info("[weixin] Bot token saved to {}", botTokenFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[weixin] Failed to save bot_token file: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件加载 context_tokens(启动时恢复主动推送能力)
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void loadContextTokens() {
|
||||||
|
if (contextTokensFile == null) return;
|
||||||
|
try {
|
||||||
|
if (Files.exists(contextTokensFile)) {
|
||||||
|
String json = Files.readString(contextTokensFile);
|
||||||
|
Map<String, String> data = objectMapper.readValue(json,
|
||||||
|
objectMapper.getTypeFactory().constructMapType(HashMap.class, String.class, String.class));
|
||||||
|
if (data != null && !data.isEmpty()) {
|
||||||
|
userContextTokens.putAll(data);
|
||||||
|
log.info("[weixin] Loaded {} context_tokens from {}", data.size(), contextTokensFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[weixin] Failed to load context_tokens: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化 context_tokens 到文件(停止时保存 + token 变更时保存)
|
||||||
|
*/
|
||||||
|
private void saveContextTokens() {
|
||||||
|
if (contextTokensFile == null || userContextTokens.isEmpty()) return;
|
||||||
|
try {
|
||||||
|
Files.createDirectories(contextTokensFile.getParent());
|
||||||
|
Files.writeString(contextTokensFile,
|
||||||
|
objectMapper.writeValueAsString(new HashMap<>(userContextTokens)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[weixin] Failed to save context_tokens: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 文件名过滤(对齐 CoPaw)====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断文本是否仅为文件名(如 "photo.jpg"、"report.pdf")。
|
||||||
|
* 微信发送文件时会同时发一条文本消息包含文件名,这不应触发 Agent 回复。
|
||||||
|
* 参考 CoPaw channel.py:538-566
|
||||||
|
*/
|
||||||
|
private static boolean isFilenameOnly(String text) {
|
||||||
|
if (text == null || text.isBlank()) return false;
|
||||||
|
// 文件名不应包含换行(多行文本不是纯文件名)
|
||||||
|
if (text.contains("\n")) return false;
|
||||||
|
String lower = text.strip().toLowerCase();
|
||||||
|
return FILENAME_EXTENSIONS.stream().anyMatch(lower::endsWith);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user