package vip.mate.channel; import vip.mate.channel.health.ChannelHealth; import vip.mate.workspace.conversation.model.MessageContentPart; import java.time.Duration; import java.util.List; /** * 渠道适配器接口 *
* 所有 IM 渠道(钉钉、飞书、企业微信等)均需实现此接口。 * 统一生命周期管理 + 消息收发抽象。 * * @author MateClaw Team */ public interface ChannelAdapter { // ==================== 生命周期 ==================== /** * 启动渠道(建立长连接、注册 Webhook 等) * 启动失败应抛出异常,不影响其他渠道 */ void start(); /** * 停止渠道(断开连接、清理资源) */ void stop(); /** * 渠道是否正在运行 */ boolean isRunning(); // ==================== 消息收发 ==================== /** * 处理来自渠道的入站消息 *
* 由渠道实现类在收到消息后调用(Webhook 回调 / 长连接推送), * 通常内部会调用 {@link ChannelMessageRouter} 路由到 Agent 处理。 * * @param message 渠道消息(已转换为统一格式) */ void onMessage(ChannelMessage message); /** * 向渠道发送消息(主动推送) *
* 用于 Agent 回复、定时任务结果推送等场景。 * * @param targetId 目标标识(如 openId、chatId、sessionWebhook 等) * @param content 消息内容(Markdown 格式,具体渠道可自行渲染) */ void sendMessage(String targetId, String content); /** * 发送结构化内容(多模态:文本 + 图片 + 文件等)。 *
* 默认实现提取纯文本后退化为 sendMessage;各渠道可覆写此方法
* 调用平台对应的富媒体 API 发送图片、文件等。
*
* @param targetId 目标标识
* @param parts 结构化内容片段
*/
default void sendContentParts(String targetId, List
* 默认实现直接调用 sendMessage(不做渲染);
* AbstractChannelAdapter 覆写此方法读取 configJson 中的渲染配置。
*
* @param targetId 目标标识
* @param content 原始消息内容
*/
default void renderAndSend(String targetId, String content) {
sendMessage(targetId, content);
}
/**
* Extended render-and-send overload that carries an optional
* {@link SendContext} side-channel (e.g. WeCom AI Bot
* {@code feedback.id} for like/dislike collection).
*
* Default implementation ignores {@code ctx} and falls back to
* {@link #renderAndSend(String, String)}, so existing channel
* adapters and callers see no behavior change. Channels that want
* to consume {@code SendContext} fields override this overload.
*
* This was introduced as part of PR-0 (RFC-32 §2.0.3) to give
* {@code ChannelMessageRouter} a way to thread the pre-allocated
* feedback id (registered against the persisted
* {@code mate_message.id}) down to the WeCom adapter without
* widening the legacy two-arg signature.
*/
default void renderAndSend(String targetId, String content, SendContext ctx) {
renderAndSend(targetId, content);
}
/**
* Render and deliver an approval notice. Channels that support a
* native interactive surface (WeCom {@code button_interaction},
* DingTalk {@code ActionCard}, etc.) override this to skip the
* text path entirely.
*
* Primary implementation lives on
* {@link AbstractChannelAdapter}, which keeps the bytewise
* fallback (markdown text → {@link #sendMessage}). Adapters that
* inherit from {@code AbstractChannelAdapter} can call
* {@code super.sendApprovalNotice(...)} to fall back; the default
* here is just a safety net for adapters that, for some reason,
* implement {@link ChannelAdapter} directly.
*
* Introduced in PR-0 (RFC-32 §2.0.3) so the router does not
* need to know which channel renders cards vs text:
* Controls {@code ChannelMessageRouter}'s "non-approval message →
* auto-cancel pending" heuristic. The heuristic was designed for
* the text flow where the user is expected to type
* {@code /approve} and anything else is an implicit "I changed my
* mind". With interactive cards the user clicks a button, and
* unrelated chat messages during the wait window must NOT
* auto-cancel the pending. Default false (text flow); WeCom +
* Feishu (when card dispatcher is wired) override to true.
*/
default boolean usesInteractiveApprovalCards() {
return false;
}
// ==================== 主动推送 ====================
/**
* 主动发送消息到指定目标(不依赖 Webhook 回调上下文)
*
* 与 sendMessage 的区别:sendMessage 通常在 Webhook 回调链路中使用,
* targetId 来自 replyToken(如钉钉的 sessionWebhook)。
* proactiveSend 用于无回调上下文的主动推送场景(如定时任务),
* targetId 为平台的用户/群组/频道标识。
*
* 不支持主动推送的渠道(如 Web)默认抛出 UnsupportedOperationException。
*
* @param targetId 目标标识(用户ID / 群组ID / 频道ID,因渠道而异)
* @param content 消息内容(Markdown 格式)
*/
default void proactiveSend(String targetId, String content) {
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
}
/**
* RFC-063r §2.10: extended overload that accepts a
* {@link DeliveryOptions} Parameter Object carrying optional hints
* (thread id, multi-bot account id, future ext fields).
*
* Default implementation delegates to {@link #proactiveSend(String, String)},
* dropping hints — concrete adapters (Slack, Telegram) override this
* variant to read {@code threadId} and route into the threading API.
*/
default void proactiveSend(String targetId, String content, DeliveryOptions options) {
proactiveSend(targetId, content);
}
/**
* 当前渠道是否支持主动推送
*
* @return true 表示支持 proactiveSend
*/
default boolean supportsProactiveSend() {
return false;
}
// ==================== 元信息 ====================
/**
* 获取渠道类型标识
*
* @return 渠道类型,如 "web", "dingtalk", "feishu", "telegram"
*/
String getChannelType();
/**
* 获取渠道显示名称
*/
default String getDisplayName() {
return getChannelType();
}
/**
* Whether this adapter must run on exactly one node in a multi-instance
* deployment.
*
* Return {@code true} when the underlying transport rejects multiple
* concurrent connections from the same credentials — e.g. a bot WebSocket
* gateway that enforces a per-app connection cap, or a long-polling
* endpoint where multiple consumers would steal updates from each other.
* The channel manager will gate {@link #start()} on a distributed lease
* so only one node connects at a time, and failover to another node when
* the lease holder dies.
*
* Webhook-based channels (DingTalk, WeCom, Slack, …) should leave this
* at the default {@code false}: inbound HTTP traffic is fanned out by the
* load balancer, so every node may safely subscribe.
*
* Scope: this hook is honored by the framework for DB-backed
* channels registered via {@code ChannelManager.startChannel}. For
* plugin-registered channels the framework can only gate the initial
* register attempt — there is no follower retry, no hot-swap, and no
* disable-detection (plugins have a register/unregister lifecycle, not
* a DB-driven one). Plugin authors needing full single-leader semantics
* should depend on {@code ChannelLeaderElection} directly.
*/
default boolean requiresSingleLeader() {
return false;
}
/**
* RFC-024 Change 2:本 adapter 认为"多久没活动就视作 stale 需要重启"的阈值。
*
* 通用默认 60 分钟;长轮询类渠道(如 iLink 微信)应覆盖为 5 分钟,
* 这样代理/NAT 侧 2–5 分钟 idle-close 把连接切断后,
* {@code ChannelHealthMonitor} 能在几分钟内触发自动重启,而非静默等整小时。 This is the source of truth the frontend "connected" green dot
* should bind to — {@code mate_channel.enabled} only records the
* user's intent to run the channel, not whether the underlying
* transport (WebSocket / webhook subscription / API token) is
* actually healthy.
*
* Default returns {@code OUT_OF_SERVICE} when {@link #isRunning()}
* is false and {@code UP} otherwise. Concrete adapters override to
* surface RECONNECTING / DOWN with specific reasons (auth failure,
* staleness exceeded, etc).
*/
default ChannelHealth health() {
return isRunning()
? ChannelHealth.up(getChannelType(), null, java.time.Instant.now())
: ChannelHealth.outOfService(getChannelType(), null);
}
// ==================== Lifecycle hooks ====================
/**
* Fires after the router has successfully delivered the agent's reply
* for the given inbound message. Channels that want to acknowledge
* completion (e.g. Feishu adds a ✅ reaction on the user's original
* message) override this; the default is a no-op so the router can
* call it unconditionally without checking adapter type.
*
* Called only on the happy path — error replies, approval-pending
* branches, and stream exceptions skip this hook.
*
* Implementations MUST be cheap and non-blocking; they run on the
* router's processing thread. Use a background thread for any
* platform API call.
*/
default void onAgentCompleted(ChannelMessage inboundMessage) {
// no-op; opt-in per adapter
}
}
* ApprovalNotice notice = approvalNotificationService.buildNotice(pending);
* adapter.sendApprovalNotice(replyTarget, notice);
*
*/
default void sendApprovalNotice(String targetId,
vip.mate.channel.notification.ApprovalNotice notice) {
sendMessage(targetId,
vip.mate.channel.notification.ApprovalNotificationService.staticBuildText(notice));
}
/**
* Does this adapter deliver approval decisions through an
* interactive card (button click → card.action callback) rather
* than the text-command flow ({@code /approve