package vip.mate.channel.weixin;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.weixin.error.TokenExpiredException;
import vip.mate.common.security.SecretEquals;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 微信个人号渠道适配器 — 基于 iLink Bot HTTP API
*
* 微信个人号渠道实现(基于 iLink Bot HTTP API):
*
* - HTTP 长轮询接收消息(getupdates,服务端最长 35s)
* - HTTP POST 发送消息(sendmessage)
* - Bearer Token 认证(可通过 QR 码扫码登录获取)
* - 支持 text(1), image(2), voice/ASR(3), file(4), video(5) 消息类型
* - 基于 context_token 的消息去重和主动推送
*
*
* 会话 ID 规则:
*
* - 私聊:weixin:{fromUserId}
* - 群聊:weixin:group:{groupId}
*
*
* configJson 配置项:
*
* - bot_token: iLink Bot Token(扫码登录获取)
* - base_url: API 基础地址(默认 https://ilinkai.weixin.qq.com)
* - media_download_enabled: 是否下载媒体文件(默认 true)
* - media_dir: 媒体文件保存目录(默认 data/media)
*
*
* @author MateClaw Team
*/
@Slf4j
public class WeixinChannelAdapter extends AbstractChannelAdapter {
public static final String CHANNEL_TYPE = "weixin";
/** 消息去重最大记录数 */
private static final int PROCESSED_IDS_MAX = 2000;
// ==================== 运行时状态 ====================
private ILinkClient client;
/** 长轮询线程 */
private volatile Thread pollThread;
/** 停止信号 */
private final AtomicBoolean stopSignal = new AtomicBoolean(false);
/** 长轮询游标 */
private volatile String cursor = "";
/**
* RFC-024 Change 5:pollLoop 错误重试专用退避器。
* 3s 起步、60s 上限、1.8 倍递增、±20% jitter、无限重试。
* 成功一次 getUpdates 即 reset()。
*/
private final ExponentialBackoff pollBackoff =
new ExponentialBackoff(3000, 60000, 1.8, -1, 0.2);
/**
* RFC-024 Change 4:pollLoop watchdog。虚拟线程调度,每 30s 检查一次活跃度。
* 由 {@link #startWatchdog()} 启动,{@link #stopWatchdog()} 关闭。
*/
private volatile ScheduledExecutorService watchdogScheduler;
private volatile ScheduledFuture> watchdogTask;
/**
* pollLoop 卡死判定阈值(毫秒)。getUpdates 最长 45s 就该回包一次;
* 超过此值说明客户端或代理层有问题,主动置 ERROR 让 HealthMonitor 重启。
* 默认 90s(45s × 2 缓冲)。
*/
private static final long POLL_STUCK_THRESHOLD_MS = 90_000;
private static final long WATCHDOG_INTERVAL_MS = 30_000;
/** 消息去重集合(LRU) */
private final LinkedHashMap processedIds = new LinkedHashMap<>(256, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > PROCESSED_IDS_MAX;
}
};
/** 用户最新 context_token 缓存(用于主动推送) */
private final ConcurrentHashMap userContextTokens = new ConcurrentHashMap<>();
/** context_token 持久化文件路径 */
private Path contextTokensFile;
/** bot_token 持久化文件路径 */
private Path botTokenFile;
/** 文件名扩展名列表(用于过滤纯文件名文本,避免误触发 Agent) */
private static final Set 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) */
private final ConcurrentHashMap typingTickets = new ConcurrentHashMap<>();
/** 输入提示刷新任务:userId -> ScheduledFuture */
private final ConcurrentHashMap> typingTasks = new ConcurrentHashMap<>();
/** 输入提示调度器 */
private ScheduledExecutorService typingScheduler;
/** Typing ticket 缓存 24 小时 */
private static final long TYPING_TICKET_TTL_MS = 24 * 60 * 60 * 1000L;
/** 输入提示刷新间隔 5 秒 */
private static final long TYPING_REFRESH_INTERVAL_MS = 5_000;
private record TypingTicketEntry(String ticket, long expireAt) {
boolean isValid() { return !ticket.isBlank() && System.currentTimeMillis() < expireAt; }
}
// ==================== 文件上传 ====================
/** 用于文件 URL 下载的 HttpClient */
private HttpClient uploadHttpClient;
public WeixinChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
// ==================== 生命周期 ====================
@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()) {
throw new RuntimeException("weixin: bot_token is required. Please scan QR code to obtain one.");
}
client = new ILinkClient(botToken, baseUrl, objectMapper);
uploadHttpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
typingScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "weixin-typing-" + channelEntity.getId());
t.setDaemon(true);
return t;
});
// 加载持久化的 context_tokens(用于重启后主动推送)
loadContextTokens();
// 持久化 bot_token(QR 登录后或首次启动时保存)
saveBotTokenToFile(botToken);
// 启动长轮询线程
stopSignal.set(false);
cursor = "";
pollBackoff.reset(); // RFC-024 Change 5: 每次启动从 3s 起步
touchActivity(); // RFC-024 Change 4: watchdog 基准点
pollThread = new Thread(this::pollLoop, "weixin-poll-" + channelEntity.getId());
pollThread.setDaemon(true);
pollThread.start();
// RFC-024 Change 4: 启动 pollLoop watchdog
startWatchdog();
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);
// RFC-024 Change 4: 关闭 watchdog(在中断 pollThread 之前,避免最后一次 tick 误判)
stopWatchdog();
// 持久化 context_tokens(重启后可恢复主动推送能力)
saveContextTokens();
// 停止所有输入提示任务
typingTasks.values().forEach(f -> f.cancel(false));
typingTasks.clear();
typingTickets.clear();
if (typingScheduler != null) {
typingScheduler.shutdownNow();
typingScheduler = null;
}
if (pollThread != null) {
pollThread.interrupt();
try {
pollThread.join(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
pollThread = null;
}
client = null;
uploadHttpClient = null;
log.info("[weixin] Channel stopped: {}", channelEntity.getName());
}
// ==================== 长轮询循环 ====================
private void pollLoop() {
log.info("[weixin] Poll thread started");
while (!stopSignal.get() && !Thread.currentThread().isInterrupted()) {
try {
Map data = client.getUpdates(cursor);
// RFC-024 Change 1: getUpdates 成功返回(哪怕没消息)= 连接活跃;
// 让 ChannelHealthMonitor 能准确识别"连接还在线"而非依赖用户发消息
touchActivity();
// RFC-024 Change 5: 成功即清零退避计数,下次故障仍从 3s 起步
pollBackoff.reset();
// 更新游标
Object newCursor = data.get("get_updates_buf");
if (newCursor != null) {
cursor = newCursor.toString();
}
// 处理消息
Object msgsObj = data.get("msgs");
if (msgsObj instanceof List> msgs) {
for (Object msgObj : msgs) {
if (msgObj instanceof Map, ?> msg) {
try {
@SuppressWarnings("unchecked")
Map msgMap = (Map) msg;
handleInboundMessage(msgMap);
} catch (Exception e) {
log.error("[weixin] Failed to handle message: {}", e.getMessage(), e);
}
}
}
}
// ret=-1 是正常的长轮询超时(无新消息)
Object retObj = data.get("ret");
int ret = retObj instanceof Number n ? n.intValue() : -1;
if (ret != 0 && (msgsObj == null || ((List>) msgsObj).isEmpty())) {
if (ret != -1) {
log.warn("[weixin] getUpdates non-zero ret={}, retry in 3s", ret);
Thread.sleep(3000);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (TokenExpiredException te) {
// RFC-024 Change 3: token 已过期 → 停止轮询、标记 ERROR,让 HealthMonitor 接手
// 不在 catch Exception 里被吞,避免"无限重试 + 日志淹没但用户不知道要重扫码"
log.error("[weixin] bot_token expired (HTTP {}) during {}; stopping poll loop — channel needs re-scan",
te.getHttpStatus(), te.getOperation());
connectionState.set(ConnectionState.ERROR);
lastError = "bot_token expired, please re-scan QR code";
break;
} catch (Exception e) {
if (!stopSignal.get()) {
// RFC-024 Change 5: 指数退避 + jitter(替代固定 5s),防止连锁故障时雷群效应
long delay = pollBackoff.nextDelayMs();
log.error("[weixin] Poll error (attempt {}), retry in {}ms: {}",
pollBackoff.getAttempts(), delay, e.getMessage());
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
log.info("[weixin] Poll thread stopped");
}
// ==================== RFC-024 Change 4: pollLoop watchdog ====================
/**
* 启动 pollLoop 监视器:每 30s 检查一次"距离上次活跃是否超过 {@value #POLL_STUCK_THRESHOLD_MS}ms"。
*
* getUpdates 最多 45s 就会返回(服务端 hold 35s + 少量网络延迟);若超过 90s 没有活动,
* 意味着 HTTP 客户端的长连接被代理 / NAT 静默 FIN 掉、pollLoop 卡在 read 上了。
* 此时主动把 state 置 ERROR,{@code ChannelHealthMonitor} 下一轮(1 分钟内)会重启本渠道,
* 缩短用户感知的"僵死时间"。
*
* 用虚拟线程 ScheduledExecutorService,开销极小;与 pollLoop 完全独立,失败隔离。
*/
private void startWatchdog() {
watchdogScheduler = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("weixin-watchdog-" + channelEntity.getId()).factory());
watchdogTask = watchdogScheduler.scheduleAtFixedRate(
this::watchdogTick,
WATCHDOG_INTERVAL_MS, WATCHDOG_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
private void watchdogTick() {
if (stopSignal.get()) return;
if (connectionState.get() != ConnectionState.CONNECTED) return; // 已 ERROR,等 HealthMonitor
long sinceLast = System.currentTimeMillis() - lastEventTimeMs.get();
if (sinceLast > POLL_STUCK_THRESHOLD_MS) {
log.warn("[weixin] Watchdog: poll thread appears stuck ({}s since last activity); " +
"setting ERROR state for HealthMonitor to restart", sinceLast / 1000);
connectionState.set(ConnectionState.ERROR);
lastError = "poll thread stuck, last activity " + (sinceLast / 1000) + "s ago";
}
}
private void stopWatchdog() {
if (watchdogTask != null) {
watchdogTask.cancel(false);
watchdogTask = null;
}
if (watchdogScheduler != null) {
watchdogScheduler.shutdownNow();
watchdogScheduler = null;
}
}
/**
* RFC-024 Change 2:微信是长轮询,代理/NAT 的 idle timeout 通常 2–5 分钟;
* 这里报告 5 分钟作为 stale 阈值,配合 {@code ChannelHealthMonitor} 1 分钟扫描,
* 断连后最多 5 分钟内被自动重启,而非原先的 60 分钟。
*/
@Override
public Duration stalenessThreshold() {
return Duration.ofMinutes(5);
}
// ==================== 入站消息处理 ====================
@SuppressWarnings("unchecked")
private void handleInboundMessage(Map msg) {
String fromUserId = getStr(msg, "from_user_id");
String toUserId = getStr(msg, "to_user_id");
String contextToken = getStr(msg, "context_token");
String groupId = getStr(msg, "group_id");
int msgType = msg.get("message_type") instanceof Number n ? n.intValue() : 0;
// 只处理用户→机器人消息 (message_type == 1)
if (msgType != 1) {
return;
}
// 去重
String dedupKey = !contextToken.isBlank() ? contextToken
: fromUserId + "_" + getStr(msg, "msg_id");
synchronized (processedIds) {
if (processedIds.containsKey(dedupKey)) {
log.debug("[weixin] Duplicate message skipped: {}", dedupKey.substring(0, Math.min(40, dedupKey.length())));
return;
}
processedIds.put(dedupKey, Boolean.TRUE);
}
// 解析消息内容
List contentParts = new ArrayList<>();
List textParts = new ArrayList<>();
boolean hasVoice = false;
List