package vip.mate.channel; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import vip.mate.channel.dingtalk.DingTalkChannelAdapter; import vip.mate.channel.discord.DiscordChannelAdapter; import vip.mate.channel.feishu.FeishuChannelAdapter; import vip.mate.channel.leader.ChannelLeaderElection; import vip.mate.channel.leader.LeaderLease; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.qq.QQChannelAdapter; import vip.mate.channel.service.ChannelService; import vip.mate.channel.telegram.TelegramChannelAdapter; import vip.mate.channel.web.WebChannelAdapter; import vip.mate.channel.wecom.WeComChannelAdapter; import vip.mate.channel.weixin.WeixinChannelAdapter; import vip.mate.exception.MateClawException; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * 渠道管理器 *
* 实现渠道生命周期管理 + 热替换机制:
* - 管理所有渠道适配器的生命周期(启动/停止/热替换)
* - 维护渠道类型注册表,根据 channelType 创建对应适配器
* - 支持动态增删渠道(通过 API 启用/禁用时自动 start/stop)
* - 应用启动时自动加载并启动所有 enabled 渠道
* - activeAdapters 使用 ReadWriteLock 保护,读操作并发安全,热替换使用写锁
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChannelManager {
private final ChannelService channelService;
private final ChannelMessageRouter messageRouter;
private final ChannelSessionStore channelSessionStore;
private final ObjectMapper objectMapper;
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/**
* Approval notification renderer — used by WeCom adapter (PR-0
* threading; PR-1 wired the WeCom override to render a
* {@code button_interaction} card via this service's card builder).
* Other adapters keep using the text path on
* {@link AbstractChannelAdapter}, which calls
* {@code ApprovalNotificationService.staticBuildText} so this
* field is currently consumed only by WeCom.
*/
private final vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService;
/**
* WeCom interactive card dispatcher (PR-1). Drives the
* {@code button_interaction} approval card render + the inbound
* {@code template_card_event} routing.
*/
private final vip.mate.channel.wecom.cards.WeComCardDispatcher weComCardDispatcher;
/**
* WeCom keepalive scheduler (PR-1). Refreshes the "🤔 思考中..."
* placeholder every 20s and force-finishes after 180s so long-
* running agent tasks don't lose their stream slot.
*/
private final vip.mate.channel.wecom.WeComKeepaliveScheduler weComKeepaliveScheduler;
/**
* Distributed leader election. Channels whose adapter reports
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
* only one node opens the upstream WebSocket / long-poll at a time.
*/
private final ChannelLeaderElection leaderElection;
/** 运行中的渠道适配器:channelId -> adapter */
private final Map Caller must hold the adapter write lock.
*/
private void attemptLeaderStart(ChannelEntity channel, ChannelAdapter adapter) {
String key = channel.getChannelType() + ":" + channel.getId();
Optional Package-private for unit testing — callers should rely on the
* heartbeat scheduler invoking this on its tick.
*/
void reconcileChannel(Long channelId, String channelName) {
ChannelEntity current;
try {
current = channelService.getChannel(channelId);
} catch (MateClawException e) {
if (e.getMsgKey() != null && e.getMsgKey().startsWith("err.channel.not_found")) {
log.info("[reconcile] Channel id={} no longer exists — stopping local adapter and releasing lease",
channelId);
stopChannel(channelId);
} else {
log.debug("[reconcile] Channel lookup failed for id={}: {}", channelId, e.getMessage());
}
return;
} catch (Exception e) {
// Transient DB issue; skip this tick and try again on the next heartbeat.
log.debug("[reconcile] Channel lookup failed for id={}: {}", channelId, e.getMessage());
return;
}
if (!Boolean.TRUE.equals(current.getEnabled())) {
log.info("[reconcile] Channel {} (id={}) is now disabled — stopping local adapter",
channelName, channelId);
stopChannel(channelId);
return;
}
LocalDateTime previousSeen;
adapterLock.readLock().lock();
try {
previousSeen = lastSeenChannelUpdateTime.get(channelId);
} finally {
adapterLock.readLock().unlock();
}
LocalDateTime currentUpdateTime = current.getUpdateTime();
if (currentUpdateTime != null && previousSeen != null
&& currentUpdateTime.isAfter(previousSeen)) {
log.info("[reconcile] Channel {} (id={}) config changed ({} → {})",
channelName, channelId, previousSeen, currentUpdateTime);
applyConfigChange(channelId, current);
}
}
/**
* Apply a detected config change to a locally-running channel.
*
* The fast path is the in-place swap that preserves the lease —
* but it is only valid when we are the current leader (we already
* hold {@code activeLeases[channelId]}). Without that gate, a
* non-leader node observing a {@code webhook → websocket} flip
* would call {@code newAdapter.start()} directly inside the swap
* and open a duplicate upstream connection, defeating the leader
* election. Every other transition — including
* {@code non-leader → leader-required}, {@code leader-required →
* non-leader}, and plain non-leader config updates — must go
* through {@code stopChannel} + {@code startChannel} so the lease
* is correctly released or acquired and follower retry is
* scheduled when election is lost.
*
* Package-private for unit testing — see {@link #reconcileChannel}.
*/
void applyConfigChange(Long channelId, ChannelEntity newChannel) {
ChannelAdapter probe = createAdapter(newChannel);
boolean newRequiresLeader = probe.requiresSingleLeader();
boolean weHaveLease;
adapterLock.readLock().lock();
try {
weHaveLease = activeLeases.containsKey(channelId);
} finally {
adapterLock.readLock().unlock();
}
if (newRequiresLeader && weHaveLease) {
// Case A: same leader-required mode and we are the current leader
// — preserve the lease across the adapter swap.
swapAdapterPreservingLease(channelId, newChannel);
return;
}
// All other cases: tear down local state and route through
// startChannel so leader election runs, lease is released, or both.
log.info("[reconcile] Channel {} (id={}) config change (newRequiresLeader={}, weHaveLease={}) — stop+start",
newChannel.getName(), channelId, newRequiresLeader, weHaveLease);
stopChannel(channelId);
try {
startChannel(newChannel);
} catch (Exception e) {
log.error("[reconcile] Restart after config change failed for channel {} (id={}): {}",
newChannel.getName(), channelId, e.getMessage(), e);
}
}
/**
* Swap to a freshly-built adapter using the new config, while keeping
* the leadership lease and heartbeat in place. The lease is only
* released if the new adapter fails to start, in which case we fall
* back to follower mode so another node can try.
*/
private void swapAdapterPreservingLease(Long channelId, ChannelEntity newChannel) {
ChannelAdapter oldAdapter;
adapterLock.writeLock().lock();
try {
oldAdapter = activeAdapters.remove(channelId);
} finally {
adapterLock.writeLock().unlock();
}
if (oldAdapter != null) {
stopAdapterSafely(oldAdapter, "reconcile-swap");
}
ChannelAdapter newAdapter = createAdapter(newChannel);
boolean started = false;
Exception startError = null;
try {
newAdapter.start();
started = true;
} catch (Exception e) {
startError = e;
}
LeaderLease leaseToRelease = null;
adapterLock.writeLock().lock();
try {
if (started) {
activeAdapters.put(channelId, newAdapter);
lastSeenChannelUpdateTime.put(channelId, newChannel.getUpdateTime());
} else {
leaseToRelease = activeLeases.remove(channelId);
lastSeenChannelUpdateTime.remove(channelId);
cancelHeartbeatLocked(channelId);
scheduleFollowerRetryLocked(channelId);
}
} finally {
adapterLock.writeLock().unlock();
}
if (!started) {
log.error("[reconcile] New adapter start failed for channel {} (id={}): {} — released lease, entering follower mode",
newChannel.getName(), channelId,
startError != null ? startError.getMessage() : "unknown");
if (leaseToRelease != null) {
leaseToRelease.release();
}
}
}
/**
* Drop the local adapter (without releasing the already-lost lease)
* and start follower retry so we'll attempt to reclaim leadership
* once the current owner stops renewing.
*/
private void handleLeadershipLoss(Long channelId) {
ChannelAdapter local;
adapterLock.writeLock().lock();
try {
local = activeAdapters.remove(channelId);
activeLeases.remove(channelId); // already lost; do not call release()
cancelHeartbeatLocked(channelId);
scheduleFollowerRetryLocked(channelId);
} finally {
adapterLock.writeLock().unlock();
}
if (local != null) {
stopAdapterSafely(local, "leadership-loss");
}
}
/**
* Schedule periodic follower retry. Caller must hold the adapter
* write lock.
*/
private void scheduleFollowerRetryLocked(Long channelId) {
if (followerRetryFutures.containsKey(channelId)) {
return;
}
ScheduledFuture> f = leaderScheduler.scheduleAtFixedRate(
() -> followerRetry(channelId),
FOLLOWER_RETRY_INTERVAL_SECONDS, FOLLOWER_RETRY_INTERVAL_SECONDS, TimeUnit.SECONDS);
followerRetryFutures.put(channelId, f);
}
/**
* One follower-retry tick. Re-reads the channel from the DB (it may
* have been disabled or deleted) and attempts to start it. The retry
* cancels itself once we successfully become leader.
*/
/** Package-private for unit testing — see {@link #reconcileChannel}. */
void followerRetry(Long channelId) {
ChannelEntity current;
try {
current = channelService.getChannel(channelId);
} catch (MateClawException e) {
// Channel was deleted on another node — cancel the retry so we
// don't leak a scheduled task forever. Other exception codes
// (e.g. transient DB errors) fall through to the generic catch
// and let the retry continue.
if (e.getMsgKey() != null && e.getMsgKey().startsWith("err.channel.not_found")) {
log.info("[leader] Follower retry: channel id={} no longer exists — cancelling retry", channelId);
adapterLock.writeLock().lock();
try {
cancelFollowerRetryLocked(channelId);
} finally {
adapterLock.writeLock().unlock();
}
return;
}
log.debug("[leader] Follower retry: lookup failed for channel id={}: {}", channelId, e.getMessage());
return;
} catch (Exception e) {
log.debug("[leader] Follower retry: lookup failed for channel id={}: {}", channelId, e.getMessage());
return;
}
if (!Boolean.TRUE.equals(current.getEnabled())) {
adapterLock.writeLock().lock();
try {
cancelFollowerRetryLocked(channelId);
} finally {
adapterLock.writeLock().unlock();
}
return;
}
try {
startChannel(current);
} catch (Exception e) {
log.debug("[leader] Follower retry: startChannel failed for id={}: {}", channelId, e.getMessage());
}
}
/** Package-private for unit testing. */
boolean hasFollowerRetry(Long channelId) {
adapterLock.readLock().lock();
try {
return followerRetryFutures.containsKey(channelId);
} finally {
adapterLock.readLock().unlock();
}
}
private void cancelHeartbeatLocked(Long channelId) {
ScheduledFuture> f = heartbeatFutures.remove(channelId);
if (f != null) {
f.cancel(false);
}
}
private void cancelFollowerRetryLocked(Long channelId) {
ScheduledFuture> f = followerRetryFutures.remove(channelId);
if (f != null) {
f.cancel(false);
}
}
/**
* Schedule the cross-node reconcile ticker for a non-leader active
* adapter. Caller must hold the adapter write lock.
*/
private void scheduleReconcileLocked(Long channelId, String channelName) {
cancelReconcileLocked(channelId);
ScheduledFuture> f = leaderScheduler.scheduleAtFixedRate(
() -> reconcileChannel(channelId, channelName),
FOLLOWER_RETRY_INTERVAL_SECONDS, FOLLOWER_RETRY_INTERVAL_SECONDS, TimeUnit.SECONDS);
reconcileFutures.put(channelId, f);
}
private void cancelReconcileLocked(Long channelId) {
ScheduledFuture> f = reconcileFutures.remove(channelId);
if (f != null) {
f.cancel(false);
}
}
/**
* 热替换渠道(配置变更后调用)
*
* 热替换流程:
* 1. 用新配置创建并启动新 Adapter(在锁外完成,避免长时间持锁)
* 2. 新 Adapter 就绪后,加写锁替换 activeAdapters 中的引用
* 3. 释放锁后,异步停止旧 Adapter(给定超时)
* 4. 如果新 Adapter start() 失败,保留旧的不变
*
* @param channelId 渠道ID
*/
public void restartChannel(Long channelId) {
ChannelEntity channel = channelService.getChannel(channelId);
if (!Boolean.TRUE.equals(channel.getEnabled())) {
// 渠道已禁用,直接停止旧的
log.info("[hot-swap] Channel {} is disabled, stopping old adapter", channel.getName());
stopChannel(channelId);
return;
}
// Leader-required channels: if we don't already own the local adapter
// (i.e. we are a follower, or this is a brand-new channel), there is
// nothing to hot-swap. Fall through to startChannel which handles
// lease acquisition and follower retry. Hot-swap (which briefly opens
// a second upstream connection) is also avoided here so we don't
// double-occupy the bot's connection quota during a restart.
boolean weHaveAdapter;
boolean weHaveLease;
adapterLock.readLock().lock();
try {
weHaveAdapter = activeAdapters.containsKey(channelId);
weHaveLease = activeLeases.containsKey(channelId);
} finally {
adapterLock.readLock().unlock();
}
ChannelAdapter probe = createAdapter(channel);
if (probe.requiresSingleLeader()) {
log.info("[hot-swap] Channel {} requires single-leader; stop+start instead of hot-swap (weHaveAdapter={}, weHaveLease={})",
channel.getName(), weHaveAdapter, weHaveLease);
stopChannel(channelId);
startChannel(channel);
return;
}
// Mode flip: we currently hold a lease but the new config is no
// longer leader-required (e.g. Feishu WS → webhook). The lease,
// heartbeat, and lastSeenUpdateTime must all be torn down before
// the new non-leader adapter starts — the in-place hot-swap path
// below would leave them behind until the next heartbeat tick
// noticed and re-restarted, causing a redundant restart and a
// window where this node is silently holding a lease nobody else
// can grab. Stop+start handles all the cleanup in one shot.
if (weHaveLease) {
log.info("[hot-swap] Channel {} flipping leader-required → non-leader; stop+start to release lease",
channel.getName());
stopChannel(channelId);
startChannel(channel);
return;
}
if (!weHaveAdapter) {
log.info("[hot-swap] No local adapter for channel {}, delegating to startChannel", channel.getName());
startChannel(channel);
return;
}
log.info("[hot-swap] Starting hot-swap for channel: {} (type={}, id={})",
channel.getName(), channel.getChannelType(), channelId);
// Step 1: 在锁外创建并启动新 Adapter
ChannelAdapter newAdapter = createAdapter(channel);
try {
log.info("[hot-swap] Starting new adapter for channel: {}", channel.getName());
newAdapter.start();
log.info("[hot-swap] New adapter started successfully: {}", channel.getName());
} catch (Exception e) {
// 新 Adapter 启动失败,保留旧的不变
log.error("[hot-swap] New adapter failed to start for channel {}, keeping old adapter: {}",
channel.getName(), e.getMessage(), e);
return;
}
// Step 2: 加写锁,原子替换
ChannelAdapter oldAdapter;
adapterLock.writeLock().lock();
try {
oldAdapter = activeAdapters.put(channelId, newAdapter);
// Mark the version we've now applied so the reconcile ticker
// (running for non-leader adapters) doesn't immediately fire a
// redundant swap on its next tick.
lastSeenChannelUpdateTime.put(channelId, channel.getUpdateTime());
log.info("[hot-swap] Adapter reference swapped for channel: {} (old={})",
channel.getName(), oldAdapter != null ? "present" : "none");
} finally {
adapterLock.writeLock().unlock();
}
// Step 3: 锁外异步停止旧 Adapter
if (oldAdapter != null) {
log.info("[hot-swap] Stopping old adapter for channel: {}", channel.getName());
stopAdapterAsync(oldAdapter, channel.getName());
}
log.info("[hot-swap] Hot-swap completed for channel: {} (type={}, id={})",
channel.getName(), channel.getChannelType(), channelId);
}
/**
* 停止所有渠道
*/
public void stopAll() {
List