mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +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<>();
|
||||
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());
|
||||
}
|
||||
|
||||
// ==================== 帧发送基础设施 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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_token(QR 登录后或首次启动时保存)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user