feat(channel): proactive channel-session message push + cron delivery target picker

This commit is contained in:
matevip 2026-08-05 05:51:59 -04:00
parent 4c9394a86f
commit 9dd58053c0
19 changed files with 588 additions and 80 deletions

View File

@ -8,7 +8,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import vip.mate.channel.ChannelManager; import vip.mate.channel.ChannelManager;
import vip.mate.channel.ChannelSessionStore;
import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.model.ChannelSessionEntity;
import vip.mate.channel.service.ChannelService; import vip.mate.channel.service.ChannelService;
import vip.mate.channel.verifier.ChannelVerifierRegistry; import vip.mate.channel.verifier.ChannelVerifierRegistry;
import vip.mate.channel.verifier.VerificationRequest; import vip.mate.channel.verifier.VerificationRequest;
@ -18,7 +20,9 @@ import vip.mate.common.result.R;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.time.LocalDateTime;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -39,6 +43,7 @@ public class ChannelController {
private final ChannelService channelService; private final ChannelService channelService;
private final ChannelManager channelManager; private final ChannelManager channelManager;
private final ChannelSessionStore channelSessionStore;
private final AuditEventService auditEventService; private final AuditEventService auditEventService;
private final ChannelVerifierRegistry verifierRegistry; private final ChannelVerifierRegistry verifierRegistry;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -175,6 +180,32 @@ public class ChannelController {
return R.ok(channel); return R.ok(channel);
} }
@RequireWorkspaceRole("admin")
@Operation(summary = "获取渠道的会话列表(可作为主动推送 / 定时任务投递目标)")
@GetMapping("/{id}/sessions")
public R<List<ChannelSessionSummary>> sessions(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
ChannelEntity channel = channelService.getChannel(id);
verifyResourceWorkspace(channel.getWorkspaceId(), workspaceId);
return R.ok(channelSessionStore.listByChannelId(id).stream()
.sorted(Comparator.comparing(ChannelSessionEntity::getLastActiveTime,
Comparator.nullsLast(Comparator.reverseOrder())))
.map(ChannelSessionSummary::from)
.toList());
}
/**
* Slim projection of {@code mate_channel_session} for target pickers
* exposes only what the UI needs to render and bind a delivery target.
*/
public record ChannelSessionSummary(String conversationId, String channelType, String targetId,
String senderId, String senderName, LocalDateTime lastActiveTime) {
static ChannelSessionSummary from(ChannelSessionEntity s) {
return new ChannelSessionSummary(s.getConversationId(), s.getChannelType(), s.getTargetId(),
s.getSenderId(), s.getSenderName(), s.getLastActiveTime());
}
}
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
@Operation(summary = "获取渠道运行状态(全局系统视图,仅管理员可见)") @Operation(summary = "获取渠道运行状态(全局系统视图,仅管理员可见)")
@GetMapping("/status") @GetMapping("/status")

View File

@ -282,8 +282,14 @@ public class CronJobRunner {
* conversation history is in scope, so the model must not assume * conversation history is in scope, so the model must not assume
* earlier context;</li> * earlier context;</li>
* <li>(channel-bound runs only) delivery back to the originating * <li>(channel-bound runs only) delivery back to the originating
* channel is framework-handled, so the model must not invent * channel is framework-handled, so the model must not re-send the
* CLI / shell / "send to WeChat" tool calls to deliver the result;</li> * final result to that same channel itself; pushing to a
* <em>different</em> conversation, when the task explicitly asks
* for it, goes through the {@code send_channel_message} tool;</li>
* <li>(non-channel-bound runs) nothing is auto-delivered to any IM
* channel if the task instructions require notifying a channel
* conversation, the model should use {@code list_channel_sessions}
* + {@code send_channel_message};</li>
* <li>when there is genuinely nothing to do or report, the model * <li>when there is genuinely nothing to do or report, the model
* should reply with exactly {@link #CRON_SILENT_MARKER} and nothing * should reply with exactly {@link #CRON_SILENT_MARKER} and nothing
* else, which suppresses delivery for this run.</li> * else, which suppresses delivery for this run.</li>
@ -298,8 +304,15 @@ public class CronJobRunner {
sb.append("- 请把下面的「任务指令」当作一个完整、独立的任务来执行;") sb.append("- 请把下面的「任务指令」当作一个完整、独立的任务来执行;")
.append("本次为隔离执行,没有此前的对话历史,不要假设存在上下文。\n"); .append("本次为隔离执行,没有此前的对话历史,不要假设存在上下文。\n");
if (channelBound) { if (channelBound) {
sb.append("- 执行结果会由系统自动投递回原渠道,你只需直接给出最终结果内容,") sb.append("- 执行结果会由系统自动投递回本任务绑定的渠道会话,你只需直接给出最终结果内容,")
.append("不要尝试调用 CLI / shell / \"发送到微信\"等工具自行投递。\n"); .append("不要再用工具把同样的结果重复发送到该会话;")
.append("仅当任务指令明确要求把消息发送到其它渠道会话时,")
.append("才先用 list_channel_sessions 查询目标会话,再用 send_channel_message 发送。\n");
} else {
sb.append("- 本任务未绑定渠道,执行结果只会写入任务会话,不会自动推送到任何 IM 渠道;")
.append("如任务指令要求把消息发送到某个渠道会话(如企业微信 / 飞书 / 钉钉),")
.append("请先用 list_channel_sessions 工具查询可用会话,")
.append("再用 send_channel_message 工具发送,不要尝试调用 CLI / shell 自行投递。\n");
} }
sb.append("- 如果确认本次确实无需执行、也没有新内容可汇报,") sb.append("- 如果确认本次确实无需执行、也没有新内容可汇报,")
.append("请仅回复 \"").append(CRON_SILENT_MARKER).append("\",不要附加任何其它文字。\n\n"); .append("请仅回复 \"").append(CRON_SILENT_MARKER).append("\",不要附加任何其它文字。\n\n");

View File

@ -0,0 +1,202 @@
package vip.mate.tool.builtin;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.channel.ChannelAdapter;
import vip.mate.channel.ChannelManager;
import vip.mate.channel.ChannelSessionStore;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.model.ChannelSessionEntity;
import vip.mate.channel.service.ChannelService;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Proactive one-way message push to an IM channel conversation.
*
* <p>Two-step workflow mirroring the {@code channel_message} skill:
* {@link #list_channel_sessions} discovers which conversations the bot can
* push to (a conversation becomes pushable once the bot has received at
* least one inbound message in it that inbound event is what populates
* {@code mate_channel_session} with the platform delivery handle), then
* {@link #send_channel_message} delivers through the same
* {@link ChannelManager#sendToChannel} outbound entry the cron delivery
* pipeline uses.
*
* <p>Sessions are scoped to the caller's workspace: only sessions whose
* bound channel belongs to the {@link ChatOrigin} workspace are listed or
* accepted as send targets, so an agent cannot push into another
* workspace's conversations.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChannelMessageTool {
/** Keep pushed messages within the same bound the cron channel renderer uses. */
private static final int MAX_MESSAGE_LENGTH = 4096;
/** Cap the session listing so a busy install doesn't flood the model context. */
private static final int MAX_LISTED_SESSIONS = 30;
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final ChannelSessionStore channelSessionStore;
private final ChannelManager channelManager;
private final ChannelService channelService;
@Tool(description = """
List IM channel conversations this bot can proactively push messages to \
(WeChat Work / DingTalk / Feishu / Telegram / Discord / QQ / Slack ...). \
Call this FIRST to discover the target conversation_id before using \
send_channel_message never guess a conversation_id. Only conversations \
where the bot has previously received a message are pushable. \
Optionally filter by channel type (e.g. "wecom", "feishu", "dingtalk").""")
public String list_channel_sessions(
@ToolParam(required = false,
description = "Optional channel type filter: wecom / dingtalk / feishu / telegram / discord / qq / slack / weixin")
String channelType,
@Nullable ToolContext ctx) {
Map<Long, ChannelEntity> channels = workspaceChannels(ctx);
if (channels.isEmpty()) {
return "No IM channels are configured in this workspace, so there are no conversations to push to.";
}
List<ChannelSessionEntity> sessions = channels.keySet().stream()
.flatMap(id -> channelSessionStore.listByChannelId(id).stream())
.filter(s -> channelType == null || channelType.isBlank()
|| channelType.trim().equalsIgnoreCase(s.getChannelType()))
.filter(s -> supportsProactive(s.getChannelId()))
.sorted(Comparator.comparing(ChannelSessionEntity::getLastActiveTime,
Comparator.nullsLast(Comparator.reverseOrder())))
.limit(MAX_LISTED_SESSIONS)
.toList();
if (sessions.isEmpty()) {
return "No pushable conversations found"
+ (channelType != null && !channelType.isBlank() ? " for channel type '" + channelType + "'" : "")
+ ". A conversation becomes pushable only after the bot has received at least one message in it.";
}
StringBuilder sb = new StringBuilder("Pushable conversations (most recently active first):\n");
for (ChannelSessionEntity s : sessions) {
ChannelEntity channel = channels.get(s.getChannelId());
sb.append("- conversation_id: ").append(s.getConversationId())
.append(" | channel: ").append(channel != null ? channel.getName() : "#" + s.getChannelId())
.append(" (").append(s.getChannelType()).append(")");
if (s.getSenderName() != null && !s.getSenderName().isBlank()) {
sb.append(" | user: ").append(s.getSenderName());
}
LocalDateTime lastActive = s.getLastActiveTime();
if (lastActive != null) {
sb.append(" | last_active: ").append(TIME_FORMAT.format(lastActive));
}
sb.append('\n');
}
sb.append("\nUse send_channel_message with the conversation_id to push a message. "
+ "When several conversations match, prefer the most recently active one.");
return sb.toString();
}
@Tool(description = """
Proactively push a one-way message to an IM channel conversation \
(WeChat Work / DingTalk / Feishu / Telegram / Discord / QQ / Slack ...). \
Use ONLY when the task explicitly requires notifying a channel conversation \
(alerts, reminders, async results) replying to the current conversation \
does NOT need this tool. Get the conversation_id from list_channel_sessions \
first; never guess it. This is a one-way push: no reply comes back.""")
public String send_channel_message(
@ToolParam(description = "Target conversation_id exactly as returned by list_channel_sessions")
String conversationId,
@ToolParam(description = "Message text to push (plain text / markdown, depending on the channel)")
String message,
@Nullable ToolContext ctx) {
if (conversationId == null || conversationId.isBlank()) {
return "[Error] conversation_id is required. Call list_channel_sessions first to find the target.";
}
if (message == null || message.isBlank()) {
return "[Error] message is required.";
}
ChannelSessionEntity session = channelSessionStore.getSession(conversationId.trim());
if (session == null) {
return "[Error] Unknown conversation_id: " + conversationId
+ ". Call list_channel_sessions to see the valid targets.";
}
if (session.getChannelId() == null) {
return "[Error] Conversation " + conversationId
+ " has no bound channel and cannot receive proactive messages.";
}
// Workspace boundary: the session's channel must belong to the caller's
// workspace, so an agent cannot push into another workspace's chats.
Map<Long, ChannelEntity> channels = workspaceChannels(ctx);
ChannelEntity channel = channels.get(session.getChannelId());
if (channel == null) {
return "[Error] Conversation " + conversationId + " does not belong to this workspace.";
}
ChannelAdapter adapter = channelManager.getAdapter(session.getChannelId()).orElse(null);
if (adapter == null) {
return "[Error] Channel '" + channel.getName() + "' is not running — enable it first.";
}
if (!adapter.supportsProactiveSend()) {
return "[Error] Channel '" + channel.getName() + "' (" + adapter.getChannelType()
+ ") does not support proactive push.";
}
String content = message.length() <= MAX_MESSAGE_LENGTH
? message
: message.substring(0, MAX_MESSAGE_LENGTH);
try {
channelManager.sendToChannel(session.getChannelId(), session.getTargetId(), content);
log.info("send_channel_message: pushed {} chars to {} via channel {}",
content.length(), conversationId, channel.getName());
return "Message sent to " + conversationId + " via channel '" + channel.getName()
+ "' (" + session.getChannelType() + ")."
+ (message.length() > MAX_MESSAGE_LENGTH
? " Note: message was truncated to " + MAX_MESSAGE_LENGTH + " chars." : "");
} catch (Exception e) {
log.warn("send_channel_message failed: conversation={}, channel={}, error={}",
conversationId, session.getChannelId(), e.getMessage());
return "[Error] Push failed: " + e.getMessage();
}
}
/**
* Channels visible to the calling agent, keyed by id. Scoped by the
* {@link ChatOrigin} workspace; origins without a workspace (legacy
* callers) fall back to the default workspace.
*/
private Map<Long, ChannelEntity> workspaceChannels(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
Long workspaceId = origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
return channelService.listChannelsByWorkspace(workspaceId).stream()
.collect(Collectors.toMap(ChannelEntity::getId, Function.identity(), (a, b) -> a));
}
private boolean supportsProactive(Long channelId) {
if (channelId == null) {
return false;
}
return channelManager.getAdapter(channelId)
.map(ChannelAdapter::supportsProactiveSend)
.orElse(false);
}
}

View File

@ -470,6 +470,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id) KEY (id)
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0);
-- Builtin tool: channel message push
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id) KEY (id)

View File

@ -514,6 +514,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- Builtin tool: channel message push
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)

View File

@ -509,6 +509,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- 内置工具:渠道消息推送
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)

View File

@ -523,6 +523,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Builtin tool: channel message push
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard) -- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)

View File

@ -518,6 +518,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0) VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- 内置工具:渠道消息推送
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0) VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)

View File

@ -471,6 +471,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id) KEY (id)
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0); VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0);
-- 内置工具:渠道消息推送
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制) -- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id) KEY (id)

View File

@ -0,0 +1,14 @@
-- V175: Register the channel message push bean as a built-in tool so it shows
-- up in the tool picker and can be bound per-agent. The agent runtime already
-- discovers the @Tool bean live (auto-available even without a row), but the
-- picker / per-agent binding validation reads mate_tool — without this row
-- operators cannot grant proactive channel push to agents that use an explicit
-- tool allowlist. One row for the bean: the alias index resolves the class
-- simple name to both @Tool methods (list_channel_sessions,
-- send_channel_message), so binding 'ChannelMessageTool' grants the
-- discover-then-push workflow as one capability.
-- Idempotent: MERGE INTO updates the row when the id already matches.
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push one-way messages to IM channel conversations (WeChat Work / DingTalk / Feishu / Telegram / Discord / QQ / Slack). list_channel_sessions discovers pushable conversations; send_channel_message delivers — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);

View File

@ -0,0 +1,14 @@
-- V175: Register the channel message push bean as a built-in tool so it shows
-- up in the tool picker and can be bound per-agent. The agent runtime already
-- discovers the @Tool bean live (auto-available even without a row), but the
-- picker / per-agent binding validation reads mate_tool — without this row
-- operators cannot grant proactive channel push to agents that use an explicit
-- tool allowlist. One row for the bean: the alias index resolves the class
-- simple name to both @Tool methods (list_channel_sessions,
-- send_channel_message), so binding 'ChannelMessageTool' grants the
-- discover-then-push workflow as one capability.
-- Idempotent: ON CONFLICT keeps the row in sync if it already exists.
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push one-way messages to IM channel conversations (WeChat Work / DingTalk / Feishu / Telegram / Discord / QQ / Slack). list_channel_sessions discovers pushable conversations; send_channel_message delivers — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;

View File

@ -0,0 +1,14 @@
-- V175: Register the channel message push bean as a built-in tool so it shows
-- up in the tool picker and can be bound per-agent. The agent runtime already
-- discovers the @Tool bean live (auto-available even without a row), but the
-- picker / per-agent binding validation reads mate_tool — without this row
-- operators cannot grant proactive channel push to agents that use an explicit
-- tool allowlist. One row for the bean: the alias index resolves the class
-- simple name to both @Tool methods (list_channel_sessions,
-- send_channel_message), so binding 'ChannelMessageTool' grants the
-- discover-then-push workflow as one capability.
-- Idempotent: ON DUPLICATE KEY UPDATE keeps the row in sync if it already exists.
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push one-way messages to IM channel conversations (WeChat Work / DingTalk / Feishu / Telegram / Discord / QQ / Slack). list_channel_sessions discovers pushable conversations; send_channel_message delivers — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);

View File

@ -1,10 +1,11 @@
--- ---
name: channel_message name: channel_message
version: "1.3.0" version: "2.0.0"
description: "当需要主动向用户、会话或渠道单向推送消息时使用。适用于任务完成通知、定时提醒、异步结果回推等场景。" description: "当需要主动向某个渠道会话单向推送消息时使用企业微信、钉钉、飞书、Telegram、Discord、QQ、Slack 等)。适用于任务完成通知、定时提醒告警、异步结果回推等场景。先用 list_channel_sessions 查询目标会话,再用 send_channel_message 发送。"
dependencies: dependencies:
tools: tools:
- execute_shell_command - list_channel_sessions
- send_channel_message
--- ---
# 渠道消息推送 # 渠道消息推送
@ -20,97 +21,67 @@ dependencies:
- 将后台任务结果推送回指定会话 - 将后台任务结果推送回指定会话
### 不应使用 ### 不应使用
- 当前对话中的正常回复(直接回复即可) - 当前对话中的正常回复(直接回复即可,不要重复推送
- 需要等待用户回复的双向交互 - 需要等待用户回复的双向交互
- 目标渠道或会话不明确时(先询问用户) - 目标渠道或会话不明确时(先询问用户)
## 支持渠道 ## 支持渠道
`console`、`dingtalk`、`feishu`、`telegram`、`discord`、`qq`、`slack` `wecom`(企业微信)、`dingtalk`、`feishu`、`telegram`、`discord`、`qq`、`slack`、`weixin`
> 注意:只有机器人**收到过消息**的会话才能主动推送——平台的推送句柄是在收到入站消息时记录的。如果目标会话不在列表里,需要先让对方在该会话中给机器人发一条消息。
## 工作流程 ## 工作流程
### 第一步:查询目标会话 ### 第一步:查询目标会话
**macOS / Linux**
``` ```
execute_shell_command( list_channel_sessions(channelType="wecom")
command="mateclaw chats list --agent-id <agentId> --channel <channel>"
)
``` ```
**Windows** - `channelType` 可选,不传则列出当前工作区所有可推送会话
``` - 返回每个会话的 `conversation_id`、渠道名称、用户名、最后活跃时间
execute_shell_command( - 有多个候选会话时,优先选**最后活跃时间最近**的
command="mateclaw.exe chats list --agent-id <agentId> --channel <channel>"
)
```
从返回结果中获取 `user_id``session_id`。有多个会话时,优先选 `updated_at` 最近的。
### 第二步:发送消息 ### 第二步:发送消息
**macOS / Linux**
``` ```
execute_shell_command( send_channel_message(
command="mateclaw channels send --agent-id <agentId> --channel <channel> --target-user <userId> --target-session <sessionId> --text \"消息内容\"" conversationId="wecom:xxxx",
message="✅ 数据分析已完成,结果已保存到 report.xlsx"
) )
``` ```
**WindowsPowerShell** - `conversationId` 必须来自 `list_channel_sessions` 的返回结果,**不要凭空猜测**
``` - `message` 为消息正文(纯文本 / Markdown取决于渠道能力超过 4096 字符会被截断
execute_shell_command(
command="mateclaw.exe channels send --agent-id <agentId> --channel <channel> --target-user <userId> --target-session <sessionId> --text '消息内容'"
)
```
### 必填参数一览
| 参数 | 说明 |
|------|------|
| `--agent-id` | 当前 Agent 的 ID |
| `--channel` | 目标渠道名称(见支持渠道列表) |
| `--target-user` | 目标用户 ID`chats list` 获取) |
| `--target-session` | 目标会话 ID`chats list` 获取) |
| `--text` | 消息内容 |
## 常见场景示例 ## 常见场景示例
### 任务完成通知 ### 温度告警推送到企业微信
``` ```
execute_shell_command( list_channel_sessions(channelType="wecom")
command="mateclaw chats list --agent-id task-bot --channel dingtalk" # 从结果中选目标会话,例如 conversation_id 为 wecom:DeBaDe 的会话,然后:
) send_channel_message(
# 从结果中取 user_id / session_id然后 conversationId="wecom:DeBaDe",
execute_shell_command( message="【温度告警】中控测试会议室 当前温度 29.3℃,已超过 28℃请及时处理"
command="mateclaw channels send --agent-id task-bot --channel dingtalk --target-user alice --target-session alice_dt_001 --text \"✅ 数据分析已完成,结果已保存到 report.xlsx\""
) )
``` ```
### 按用户筛选会话 ### 任务完成通知到钉钉
``` ```
execute_shell_command( list_channel_sessions(channelType="dingtalk")
command="mateclaw chats list --agent-id notify-bot --user-id alice" send_channel_message(
conversationId="dingtalk:sw:xxxx",
message="✅ 周报生成完成,已写入知识库"
) )
``` ```
## mateclaw CLI 未安装时的降级处理
`mateclaw` 命令不可用:
1. 检测:
```
execute_shell_command(command="which mateclaw || where mateclaw")
```
2. 如果未安装,告知用户:
> mateclaw CLI 未找到,无法主动推送消息。请确认 MateClaw 已正确安装并将 CLI 加入 PATH。安装后重试。
## 常见错误 ## 常见错误
- **缺少必填参数**5 个参数agent-id、channel、target-user、target-session、text缺一不可 - **没有先查会话就发送**`conversationId` 必须先通过 `list_channel_sessions` 获取
- **没有先查 session 就发送**:不要猜 target-user 和 target-session必须先查
- **把正常对话回复当成推送**:当前会话直接回复不需要用本技能 - **把正常对话回复当成推送**:当前会话直接回复不需要用本技能
- **期望收到回复**`channels send` 是单向推送,不返回用户回复 - **期望收到回复**`send_channel_message` 是单向推送,不返回用户回复
- **目标会话不存在**:说明机器人从未在该会话收到过消息,请先让用户在目标会话里给机器人发一条消息
- **渠道未启用 / 不支持主动推送**:按报错提示先在渠道管理中启用对应渠道

View File

@ -25,8 +25,12 @@ class CronJobRunnerPromptTest {
"every scheduled run must carry the execution-context note"); "every scheduled run must carry the execution-context note");
assertTrue(prompt.contains("隔离执行"), assertTrue(prompt.contains("隔离执行"),
"the note must tell the model this run has no prior history"); "the note must tell the model this run has no prior history");
assertFalse(prompt.contains("投递回原渠道"), assertFalse(prompt.contains("自动投递回本任务绑定的渠道会话"),
"web-origin runs have no channel — the delivery clause must be omitted"); "web-origin runs have no channel — the auto-delivery clause must be omitted");
assertTrue(prompt.contains("本任务未绑定渠道"),
"non-channel runs must state that nothing is auto-delivered");
assertTrue(prompt.contains("send_channel_message"),
"non-channel runs must point at the channel-message tool for explicit sends");
assertTrue(prompt.contains(CronJobRunner.CRON_SILENT_MARKER), assertTrue(prompt.contains(CronJobRunner.CRON_SILENT_MARKER),
"the no-op sentinel instruction must always be present"); "the no-op sentinel instruction must always be present");
assertTrue(prompt.endsWith(input), assertTrue(prompt.endsWith(input),
@ -46,17 +50,19 @@ class CronJobRunnerPromptTest {
String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin); String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin);
assertTrue(prompt.contains("[定时任务执行说明]")); assertTrue(prompt.contains("[定时任务执行说明]"));
assertTrue(prompt.contains("投递回原渠道"), assertTrue(prompt.contains("自动投递回本任务绑定的渠道会话"),
"channel-bound runs must keep the framework-delivery clause"); "channel-bound runs must keep the framework-delivery clause");
assertTrue(prompt.contains("不要尝试调用 CLI"), assertTrue(prompt.contains("不要再用工具把同样的结果重复发送"),
"the channel clause must forbid CLI / send-tool hallucination"); "the channel clause must forbid duplicate self-delivery to the bound conversation");
assertTrue(prompt.contains("send_channel_message"),
"cross-conversation sends must be routed through the channel-message tool");
} }
@Test @Test
void nullOrigin_stillProducesContextNote() { void nullOrigin_stillProducesContextNote() {
String prompt = CronJobRunner.buildCronPrompt("hello", null); String prompt = CronJobRunner.buildCronPrompt("hello", null);
assertTrue(prompt.contains("[定时任务执行说明]")); assertTrue(prompt.contains("[定时任务执行说明]"));
assertFalse(prompt.contains("投递回原渠道")); assertFalse(prompt.contains("自动投递回本任务绑定的渠道会话"));
assertTrue(prompt.endsWith("hello")); assertTrue(prompt.endsWith("hello"));
} }
} }

View File

@ -516,6 +516,12 @@ export const channelApi = {
health: (id: string | number) => http.get(`/channels/${id}/health`), health: (id: string | number) => http.get(`/channels/${id}/health`),
/** Batch health for all channels in current workspace. */ /** Batch health for all channels in current workspace. */
healthAll: () => http.get('/channels/health'), healthAll: () => http.get('/channels/health'),
/**
* List a channel's known conversations (proactive-push targets). Used by
* the cron delivery-target picker; a conversation appears here once the
* bot has received at least one inbound message in it.
*/
listSessions: (id: string | number) => http.get(`/channels/${id}/sessions`),
/** /**
* Wizard Step 2 validate a draft config without persisting. * Wizard Step 2 validate a draft config without persisting.
* Returns a VerificationResult: { ok, skipped, durationMs, headline, * Returns a VerificationResult: { ok, skipped, durationMs, headline,

View File

@ -3110,7 +3110,16 @@ export default {
cronExpressionPlaceholder: 'min hour day month weekday, e.g. 0 9 * * 1-5', cronExpressionPlaceholder: 'min hour day month weekday, e.g. 0 9 * * 1-5',
timezone: 'Timezone', timezone: 'Timezone',
enabled: 'Enable immediately', enabled: 'Enable immediately',
deliveryChannel: 'Delivery Channel',
deliveryChannelNone: 'No delivery (task conversation only)',
deliveryChannelHint: 'When the job finishes, the final result is proactively pushed to the selected channel conversation',
targetSession: 'Target Conversation',
targetSessionPlaceholder: 'Select the conversation to deliver to',
targetSessionEmpty: 'No pushable conversations on this channel yet — a conversation appears here after the bot has received at least one message in it',
deliveryMode: 'Delivery Mode',
deliveryModeHint: 'In silent mode the job still runs and records its result, but nothing is pushed to the channel conversation',
}, },
deliveryModes: { deliver: 'Deliver result', silent: 'Silent (no delivery)' },
actions: { runNow: 'Run Now', edit: 'Edit', delete: 'Delete' }, actions: { runNow: 'Run Now', edit: 'Edit', delete: 'Delete' },
messages: { messages: {
createSuccess: 'Cron job created', createSuccess: 'Cron job created',

View File

@ -3122,7 +3122,16 @@ export default {
cronExpressionPlaceholder: '分 时 日 月 周,如: 0 9 * * 1-5', cronExpressionPlaceholder: '分 时 日 月 周,如: 0 9 * * 1-5',
timezone: '时区', timezone: '时区',
enabled: '立即启用', enabled: '立即启用',
deliveryChannel: '投递渠道',
deliveryChannelNone: '不投递(仅写入任务会话)',
deliveryChannelHint: '任务执行完成后,最终结果将主动推送到所选渠道的目标会话',
targetSession: '目标会话',
targetSessionPlaceholder: '选择要投递到的会话',
targetSessionEmpty: '该渠道暂无可投递会话:机器人在某个会话中收到过消息后,该会话才会出现在这里',
deliveryMode: '分发模式',
deliveryModeHint: '静默模式下任务照常执行并记录运行结果,但不把结果推送到渠道会话',
}, },
deliveryModes: { deliver: '投递结果', silent: '静默(不投递)' },
actions: { runNow: '立即执行', edit: '编辑', delete: '删除' }, actions: { runNow: '立即执行', edit: '编辑', delete: '删除' },
messages: { messages: {
createSuccess: '定时任务创建成功', createSuccess: '定时任务创建成功',

View File

@ -1102,9 +1102,19 @@ export interface CronJob {
// channelId / deliveryConfig: round-trippable on create/update. // channelId / deliveryConfig: round-trippable on create/update.
// lastDeliveryStatus / lastDeliveryError: read-only, populated by // lastDeliveryStatus / lastDeliveryError: read-only, populated by
// selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend. // selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend.
channelId?: number | null // Runtime is always a string (global Long→String serialization); keep the
// union so pre-existing number literals in callers still type-check.
channelId?: string | number | null
channelName?: string | null channelName?: string | null
deliveryConfig?: { targetId?: string | null; threadId?: string | null; accountId?: string | null } | null deliveryConfig?: {
targetId?: string | null
threadId?: string | null
accountId?: string | null
/** IM senderId of the delivery target user — used for session matching. */
userId?: string | null
/** True = run the job but don't push the result to the channel. */
suppressAgentReply?: boolean | null
} | null
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED' lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
lastDeliveryError?: string | null lastDeliveryError?: string | null
} }

View File

@ -280,6 +280,42 @@
</div> </div>
</template> </template>
<!-- Delivery binding: push the final run result to an IM channel
conversation. Writes the existing channelId + deliveryConfig
contract; empty channel = result stays in the task conversation. -->
<template v-if="form.taskType !== 'wiki_process'">
<div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.deliveryChannel') }}</label>
<select v-model="form.channelIdStr" class="form-input" @change="onChannelChange">
<option value="">{{ t('cronJobs.fields.deliveryChannelNone') }}</option>
<option v-for="c in deliveryChannels" :key="String(c.id)" :value="String(c.id)">
{{ c.name }} ({{ c.channelType }})
</option>
</select>
<p class="form-hint">{{ t('cronJobs.fields.deliveryChannelHint') }}</p>
</div>
<div v-if="form.channelIdStr" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.targetSession') }} *</label>
<select v-model="form.targetConversationId" class="form-input">
<option value="" disabled>{{ t('cronJobs.fields.targetSessionPlaceholder') }}</option>
<option v-for="s in channelSessions" :key="s.conversationId" :value="s.conversationId">
{{ sessionLabel(s) }}
</option>
</select>
<p v-if="sessionsLoaded && channelSessions.length === 0" class="form-hint">
{{ t('cronJobs.fields.targetSessionEmpty') }}
</p>
</div>
<div v-if="form.channelIdStr" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.deliveryMode') }}</label>
<select v-model="form.deliveryMode" class="form-input">
<option value="deliver">{{ t('cronJobs.deliveryModes.deliver') }}</option>
<option value="silent">{{ t('cronJobs.deliveryModes.silent') }}</option>
</select>
<p class="form-hint">{{ t('cronJobs.fields.deliveryModeHint') }}</p>
</div>
</template>
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronExpression') }} *</label> <label class="form-label">{{ t('cronJobs.fields.cronExpression') }} *</label>
<CronExpressionField v-model="form.cronExpression" /> <CronExpressionField v-model="form.cronExpression" />
@ -318,7 +354,7 @@ import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm' import { mcConfirm } from '@/components/common/useConfirm'
import { useCronJobStore } from '@/stores/useCronJobStore' import { useCronJobStore } from '@/stores/useCronJobStore'
import { useAgentStore } from '@/stores/useAgentStore' import { useAgentStore } from '@/stores/useAgentStore'
import { wikiApi } from '@/api/index' import { wikiApi, channelApi } from '@/api/index'
import type { CronJob } from '@/types/index' import type { CronJob } from '@/types/index'
import CronExpressionField from '@/components/common/CronExpressionField.vue' import CronExpressionField from '@/components/common/CronExpressionField.vue'
@ -327,6 +363,26 @@ interface WikiKbOption {
name: string name: string
} }
interface ChannelOption {
id: number | string
name: string
channelType: string
enabled?: boolean
}
interface ChannelSessionOption {
conversationId: string
channelType: string
targetId: string
senderId?: string | null
senderName?: string | null
lastActiveTime?: string | null
}
// Web-family channels have no proactive-push transport, so they are not
// offered as cron delivery targets.
const NON_PUSHABLE_CHANNEL_TYPES = new Set(['web', 'webchat'])
const { t } = useI18n() const { t } = useI18n()
const store = useCronJobStore() const store = useCronJobStore()
const agentStore = useAgentStore() const agentStore = useAgentStore()
@ -341,6 +397,13 @@ const editing = ref<CronJob | null>(null)
const detailJob = ref<CronJob | null>(null) const detailJob = ref<CronJob | null>(null)
const wikiKbs = ref<WikiKbOption[]>([]) const wikiKbs = ref<WikiKbOption[]>([])
const wikiKbsLoaded = ref(false) const wikiKbsLoaded = ref(false)
const channels = ref<ChannelOption[]>([])
const channelsLoaded = ref(false)
const channelSessions = ref<ChannelSessionOption[]>([])
const sessionsLoaded = ref(false)
const deliveryChannels = computed(() =>
channels.value.filter((c) => c.enabled !== false && !NON_PUSHABLE_CHANNEL_TYPES.has(c.channelType)))
// Weekday keys for the table's human-readable cron rendering. // Weekday keys for the table's human-readable cron rendering.
const dayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] const dayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
@ -352,7 +415,13 @@ const timezones = [
'Australia/Sydney', 'Australia/Sydney',
] ]
const defaultForm = (): Partial<CronJob> & { wikiKbId?: number | string; wikiForce?: boolean } => ({ const defaultForm = (): Partial<CronJob> & {
wikiKbId?: number | string
wikiForce?: boolean
channelIdStr: string
targetConversationId: string
deliveryMode: 'deliver' | 'silent'
} => ({
name: '', name: '',
cronExpression: '0 9 * * *', cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
@ -363,6 +432,12 @@ const defaultForm = (): Partial<CronJob> & { wikiKbId?: number | string; wikiFor
enabled: true, enabled: true,
wikiKbId: '', wikiKbId: '',
wikiForce: false, wikiForce: false,
// Delivery helpers (form-only, translated to channelId + deliveryConfig on
// save). channelIdStr stays a string end-to-end Snowflake ids must never
// pass through Number.
channelIdStr: '',
targetConversationId: '',
deliveryMode: 'deliver',
}) })
const form = ref<any>(defaultForm()) const form = ref<any>(defaultForm())
@ -376,9 +451,60 @@ const canSave = computed(() => {
if (form.value.taskType === 'wiki_process' if (form.value.taskType === 'wiki_process'
&& (form.value.wikiKbId == null || form.value.wikiKbId === '')) return false && (form.value.wikiKbId == null || form.value.wikiKbId === '')) return false
if (!form.value.cronExpression?.trim()) return false if (!form.value.cronExpression?.trim()) return false
// A bound delivery channel needs a target conversation, unless we're
// editing a job that already carries a target on the same channel (the
// original session may have aged out of the picker).
if (form.value.taskType !== 'wiki_process' && form.value.channelIdStr
&& !form.value.targetConversationId && !editingKeepsTarget()) return false
return true return true
}) })
/** True when the edited job already has a delivery target on the currently selected channel. */
function editingKeepsTarget(): boolean {
return !!(editing.value
&& String(editing.value.channelId ?? '') === form.value.channelIdStr
&& editing.value.deliveryConfig?.targetId)
}
async function loadChannels() {
if (channelsLoaded.value) return
try {
const res: any = await channelApi.list()
channels.value = (res?.data || []) as ChannelOption[]
} catch {
channels.value = []
} finally {
channelsLoaded.value = true
}
}
async function loadSessions(channelId: string) {
sessionsLoaded.value = false
channelSessions.value = []
if (!channelId) {
sessionsLoaded.value = true
return
}
try {
const res: any = await channelApi.listSessions(channelId)
channelSessions.value = (res?.data || []) as ChannelSessionOption[]
} catch {
channelSessions.value = []
} finally {
sessionsLoaded.value = true
}
}
function onChannelChange() {
form.value.targetConversationId = ''
loadSessions(form.value.channelIdStr)
}
function sessionLabel(s: ChannelSessionOption): string {
const who = s.senderName || s.senderId || s.targetId
return who ? `${who} · ${s.conversationId}` : s.conversationId
}
async function loadWikiKbs() { async function loadWikiKbs() {
if (wikiKbsLoaded.value) return if (wikiKbsLoaded.value) return
try { try {
@ -429,6 +555,9 @@ watch(() => store.jobs.length, (n) => emit('count', n), { immediate: true })
function openCreateModal() { function openCreateModal() {
editing.value = null editing.value = null
form.value = defaultForm() form.value = defaultForm()
channelSessions.value = []
sessionsLoaded.value = false
loadChannels()
showModal.value = true showModal.value = true
} }
@ -439,6 +568,25 @@ function openEditModal(job: CronJob) {
applyWikiProcessForm(form.value, job.requestBody) applyWikiProcessForm(form.value, job.requestBody)
loadWikiKbs() loadWikiKbs()
} }
loadChannels()
// Rehydrate the delivery helpers from the persisted binding. IDs stay
// strings end-to-end (Snowflake precision).
form.value.channelIdStr = job.channelId != null ? String(job.channelId) : ''
form.value.deliveryMode = job.deliveryConfig?.suppressAgentReply ? 'silent' : 'deliver'
form.value.targetConversationId = ''
if (form.value.channelIdStr) {
loadSessions(form.value.channelIdStr).then(() => {
// Preselect the session that matches the stored delivery target; when
// it aged out of the store, the picker stays empty but saving keeps
// the original target (see buildDeliveryPatch).
const match = channelSessions.value.find(
(s) => s.targetId === job.deliveryConfig?.targetId)
if (match) form.value.targetConversationId = match.conversationId
})
} else {
channelSessions.value = []
sessionsLoaded.value = false
}
showModal.value = true showModal.value = true
} }
@ -483,13 +631,18 @@ function closeDetailModal() {
} }
function buildSavePayload() { function buildSavePayload() {
// Strip the form-only wiki helpers and substitute them with the canonical // Strip the form-only helpers (wiki + delivery) and substitute them with
// JSON request_body the backend expects for wiki_process. For every other // the canonical fields the backend expects. For every other field the
// task type the payload is forwarded as-is. // payload is forwarded as-is.
const { wikiKbId, wikiForce, ...rest } = form.value const {
wikiKbId, wikiForce,
channelIdStr: _channelIdStr, targetConversationId: _targetConversationId, deliveryMode: _deliveryMode,
...rest
} = form.value
if (form.value.taskType === 'wiki_process') { if (form.value.taskType === 'wiki_process') {
return { return {
...rest, ...rest,
...buildDeliveryPatch(),
// Drop agent binding server defaults to a 0 sentinel for system tasks. // Drop agent binding server defaults to a 0 sentinel for system tasks.
agentId: undefined, agentId: undefined,
triggerMessage: '', triggerMessage: '',
@ -501,7 +654,33 @@ function buildSavePayload() {
}), }),
} }
} }
return rest return { ...rest, ...buildDeliveryPatch() }
}
/**
* Translate the delivery helper fields into the persisted channelId +
* deliveryConfig contract. The channelId is sent as a string the backend's
* Long coercion accepts textual IDs and Number() would truncate Snowflakes.
*/
function buildDeliveryPatch(): { channelId: string | null; deliveryConfig: CronJob['deliveryConfig'] } {
if (form.value.taskType === 'wiki_process' || !form.value.channelIdStr) {
return { channelId: null, deliveryConfig: null }
}
const prev = editing.value?.deliveryConfig
const sameChannel = !!editing.value
&& String(editing.value.channelId ?? '') === form.value.channelIdStr
const session = channelSessions.value.find(
(s) => s.conversationId === form.value.targetConversationId)
return {
channelId: form.value.channelIdStr,
deliveryConfig: {
targetId: session?.targetId ?? (sameChannel ? prev?.targetId ?? null : null),
userId: session ? session.senderId ?? null : (sameChannel ? prev?.userId ?? null : null),
threadId: sameChannel ? prev?.threadId ?? null : null,
accountId: sameChannel ? prev?.accountId ?? null : null,
suppressAgentReply: form.value.deliveryMode === 'silent',
},
}
} }
async function saveJob() { async function saveJob() {