feat(channel): weixin voice ASR fallback, wecom mixed message handling

This commit is contained in:
matevip 2026-04-13 16:38:09 +08:00
parent d5e3502829
commit 881badd15f
2 changed files with 327 additions and 11 deletions

View File

@ -508,6 +508,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
List<MessageContentPart> contentParts = new ArrayList<>();
String textContent = null;
boolean hasVoice = false;
switch (msgType) {
case "text" -> {
@ -534,6 +535,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
textContent = "[图片]";
}
case "voice" -> {
hasVoice = true;
Map<String, Object> voiceBody = (Map<String, Object>) body.getOrDefault("voice", Map.of());
String asrText = ((String) voiceBody.getOrDefault("content", "")).trim();
if (!asrText.isBlank()) {
@ -569,11 +571,27 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
textBuilder.append(txt).append('\n');
}
} else if ("image".equals(itemType)) {
// 与独立 image 消息对齐下载 + AES 解密对齐 CoPaw
Map<String, Object> img = (Map<String, Object>) item.getOrDefault("image", Map.of());
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));
}
} 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();
@ -621,6 +639,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
.content(textContent != null ? textContent.trim() : "")
.contentType(msgType)
.contentParts(contentParts)
.inputMode(hasVoice ? "voice" : "text")
.timestamp(LocalDateTime.now())
.replyToken(isGroup ? chatId : senderId)
.rawPayload(Map.of(
@ -734,7 +753,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
content, filterThinking, filterToolMessages, format, maxLen);
boolean first = true;
for (String segment : segments) {
for (String rawSegment : segments) {
// WeCom 专用格式化 Markdown 表格对齐 CoPaw format_markdown_tables
String segment = formatMarkdownTables(rawSegment);
// 第一条分段用 processingStreamId 覆盖"思考中..."
if (first && ctx != null && ctx.processingStreamId() != null
&& !ctx.processingStreamId().isBlank()) {
@ -769,6 +790,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
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 "audio" -> sendAudioPart(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());
}
// ==================== 帧发送基础设施 ====================
/**

View File

@ -83,6 +83,19 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
/** 用户最新 context_token 缓存(用于主动推送) */
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) */
@ -124,7 +137,17 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
@Override
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", "");
if (botToken.isBlank()) {
botToken = loadBotTokenFromFile();
}
String baseUrl = getConfigString("base_url", ILinkClient.DEFAULT_BASE_URL);
if (botToken.isBlank()) {
@ -139,6 +162,12 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
return t;
});
// 加载持久化的 context_tokens用于重启后主动推送
loadContextTokens();
// 持久化 bot_tokenQR 登录后或首次启动时保存
saveBotTokenToFile(botToken);
// 启动长轮询线程
stopSignal.set(false);
cursor = "";
@ -146,14 +175,19 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
pollThread.setDaemon(true);
pollThread.start();
log.info("[weixin] Channel started: {} (token={}...)", channelEntity.getName(),
botToken.substring(0, Math.min(12, botToken.length())));
log.info("[weixin] Channel started: {} (token={}..., cached_contexts={})",
channelEntity.getName(),
botToken.substring(0, Math.min(12, botToken.length())),
userContextTokens.size());
}
@Override
protected void doStop() {
stopSignal.set(true);
// 持久化 context_tokens重启后可恢复主动推送能力
saveContextTokens();
// 停止所有输入提示任务
typingTasks.values().forEach(f -> f.cancel(false));
typingTasks.clear();
@ -275,10 +309,10 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
switch (itemType) {
case 1 -> {
// Text
// Text 过滤纯文件名文本借鉴 CoPaw: 避免文件名误触发 Agent
Map<String, Object> textItem = (Map<String, Object>) item.getOrDefault("text_item", Map.of());
String text = getStr(textItem, "text").strip();
if (!text.isEmpty()) {
if (!text.isEmpty() && !isFilenameOnly(text)) {
textParts.add(text);
}
}
@ -313,14 +347,53 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
}
case 3 -> {
// Voice 使用 ASR 语音识别文本
// iLink API ASR 文本可能在两个位置参考 CoPaw 实现
// 路径1: voice_item.text_item.text嵌套结构
// 路径2: voice_item.text直接结构
hasVoice = true;
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 = getStr(voiceTextItem, "text").strip();
String asrText = "";
// 路径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()) {
textParts.add(asrText);
log.info("[weixin] Voice ASR text: {}", asrText.length() > 50
? asrText.substring(0, 50) + "..." : asrText);
} 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 -> {
@ -383,9 +456,13 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
return;
}
// 缓存 context_token用于主动推送
// 缓存 context_token用于主动推送并定期持久化
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;
}
// ==================== 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);
}
}