From 9dd58053c012635f043c7fea7789c61f295bf2ca Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 5 Aug 2026 05:51:59 -0400 Subject: [PATCH] feat(channel): proactive channel-session message push + cron delivery target picker --- .../channel/controller/ChannelController.java | 31 +++ .../vip/mate/cron/service/CronJobRunner.java | 21 +- .../mate/tool/builtin/ChannelMessageTool.java | 202 ++++++++++++++++++ .../src/main/resources/db/data-en.sql | 5 + .../main/resources/db/data-kingbase-en.sql | 5 + .../main/resources/db/data-kingbase-zh.sql | 5 + .../src/main/resources/db/data-mysql-en.sql | 5 + .../src/main/resources/db/data-mysql-zh.sql | 5 + .../src/main/resources/db/data-zh.sql | 5 + .../V175__register_channel_message_tool.sql | 14 ++ .../V175__register_channel_message_tool.sql | 14 ++ .../V175__register_channel_message_tool.sql | 14 ++ .../resources/skills/channel_message/SKILL.md | 93 +++----- .../cron/service/CronJobRunnerPromptTest.java | 18 +- mateclaw-ui/src/api/index.ts | 6 + mateclaw-ui/src/i18n/locales/en-US.ts | 9 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 9 + mateclaw-ui/src/types/index.ts | 14 +- .../src/views/Scheduler/CronJobsPanel.vue | 193 ++++++++++++++++- 19 files changed, 588 insertions(+), 80 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/ChannelMessageTool.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V175__register_channel_message_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V175__register_channel_message_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V175__register_channel_message_tool.sql diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java index 771e69d3..3d6b46a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java @@ -8,7 +8,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; 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 vip.mate.channel.verifier.ChannelVerifierRegistry; import vip.mate.channel.verifier.VerificationRequest; @@ -18,7 +20,9 @@ import vip.mate.common.result.R; import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import java.time.LocalDateTime; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; @@ -39,6 +43,7 @@ public class ChannelController { private final ChannelService channelService; private final ChannelManager channelManager; + private final ChannelSessionStore channelSessionStore; private final AuditEventService auditEventService; private final ChannelVerifierRegistry verifierRegistry; private final ObjectMapper objectMapper; @@ -175,6 +180,32 @@ public class ChannelController { return R.ok(channel); } + @RequireWorkspaceRole("admin") + @Operation(summary = "获取渠道的会话列表(可作为主动推送 / 定时任务投递目标)") + @GetMapping("/{id}/sessions") + public R> 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") @Operation(summary = "获取渠道运行状态(全局系统视图,仅管理员可见)") @GetMapping("/status") diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java index d8273750..133bce60 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java @@ -282,8 +282,14 @@ public class CronJobRunner { * conversation history is in scope, so the model must not assume * earlier context; *
  • (channel-bound runs only) delivery back to the originating - * channel is framework-handled, so the model must not invent - * CLI / shell / "send to WeChat" tool calls to deliver the result;
  • + * channel is framework-handled, so the model must not re-send the + * final result to that same channel itself; pushing to a + * different conversation, when the task explicitly asks + * for it, goes through the {@code send_channel_message} tool; + *
  • (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};
  • *
  • when there is genuinely nothing to do or report, the model * should reply with exactly {@link #CRON_SILENT_MARKER} and nothing * else, which suppresses delivery for this run.
  • @@ -298,8 +304,15 @@ public class CronJobRunner { sb.append("- 请把下面的「任务指令」当作一个完整、独立的任务来执行;") .append("本次为隔离执行,没有此前的对话历史,不要假设存在上下文。\n"); if (channelBound) { - sb.append("- 执行结果会由系统自动投递回原渠道,你只需直接给出最终结果内容,") - .append("不要尝试调用 CLI / shell / \"发送到微信\"等工具自行投递。\n"); + sb.append("- 执行结果会由系统自动投递回本任务绑定的渠道会话,你只需直接给出最终结果内容,") + .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("- 如果确认本次确实无需执行、也没有新内容可汇报,") .append("请仅回复 \"").append(CRON_SILENT_MARKER).append("\",不要附加任何其它文字。\n\n"); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChannelMessageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChannelMessageTool.java new file mode 100644 index 00000000..37d12ed7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChannelMessageTool.java @@ -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. + * + *

    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. + * + *

    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 channels = workspaceChannels(ctx); + if (channels.isEmpty()) { + return "No IM channels are configured in this workspace, so there are no conversations to push to."; + } + + List 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 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 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); + } +} diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index fac1f982..4d717f2f 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -470,6 +470,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, 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); +-- 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) MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) KEY (id) diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql index 0d661405..999c56af 100644 --- a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql +++ b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql @@ -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) 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) 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) diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql index 448517d8..d173527d 100644 --- a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql @@ -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) 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 审批控制) 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) diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index 0ac90976..15954fad 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -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) 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) 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) diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 7fa29f9a..b45ae676 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -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) 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 审批控制) 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) diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index 767ac254..9ed92ccb 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -471,6 +471,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) 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 审批控制) MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) KEY (id) diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V175__register_channel_message_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V175__register_channel_message_tool.sql new file mode 100644 index 00000000..6256a7eb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V175__register_channel_message_tool.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V175__register_channel_message_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V175__register_channel_message_tool.sql new file mode 100644 index 00000000..e359fd4d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V175__register_channel_message_tool.sql @@ -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; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V175__register_channel_message_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V175__register_channel_message_tool.sql new file mode 100644 index 00000000..651b0510 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V175__register_channel_message_tool.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/skills/channel_message/SKILL.md b/mateclaw-server/src/main/resources/skills/channel_message/SKILL.md index 5ea2050e..95c48922 100644 --- a/mateclaw-server/src/main/resources/skills/channel_message/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/channel_message/SKILL.md @@ -1,10 +1,11 @@ --- name: channel_message -version: "1.3.0" -description: "当需要主动向用户、会话或渠道单向推送消息时使用。适用于任务完成通知、定时提醒、异步结果回推等场景。" +version: "2.0.0" +description: "当需要主动向某个渠道会话单向推送消息时使用(企业微信、钉钉、飞书、Telegram、Discord、QQ、Slack 等)。适用于任务完成通知、定时提醒告警、异步结果回推等场景。先用 list_channel_sessions 查询目标会话,再用 send_channel_message 发送。" dependencies: 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( - command="mateclaw chats list --agent-id --channel " -) +list_channel_sessions(channelType="wecom") ``` -**Windows:** -``` -execute_shell_command( - command="mateclaw.exe chats list --agent-id --channel " -) -``` - -从返回结果中获取 `user_id` 和 `session_id`。有多个会话时,优先选 `updated_at` 最近的。 +- `channelType` 可选,不传则列出当前工作区所有可推送会话 +- 返回每个会话的 `conversation_id`、渠道名称、用户名、最后活跃时间 +- 有多个候选会话时,优先选**最后活跃时间最近**的 ### 第二步:发送消息 -**macOS / Linux:** ``` -execute_shell_command( - command="mateclaw channels send --agent-id --channel --target-user --target-session --text \"消息内容\"" +send_channel_message( + conversationId="wecom:xxxx", + message="✅ 数据分析已完成,结果已保存到 report.xlsx" ) ``` -**Windows(PowerShell):** -``` -execute_shell_command( - command="mateclaw.exe channels send --agent-id --channel --target-user --target-session --text '消息内容'" -) -``` - -### 必填参数一览 - -| 参数 | 说明 | -|------|------| -| `--agent-id` | 当前 Agent 的 ID | -| `--channel` | 目标渠道名称(见支持渠道列表) | -| `--target-user` | 目标用户 ID(从 `chats list` 获取) | -| `--target-session` | 目标会话 ID(从 `chats list` 获取) | -| `--text` | 消息内容 | +- `conversationId` 必须来自 `list_channel_sessions` 的返回结果,**不要凭空猜测** +- `message` 为消息正文(纯文本 / Markdown,取决于渠道能力),超过 4096 字符会被截断 ## 常见场景示例 -### 任务完成通知 +### 温度告警推送到企业微信 ``` -execute_shell_command( - command="mateclaw chats list --agent-id task-bot --channel dingtalk" -) -# 从结果中取 user_id / session_id,然后: -execute_shell_command( - command="mateclaw channels send --agent-id task-bot --channel dingtalk --target-user alice --target-session alice_dt_001 --text \"✅ 数据分析已完成,结果已保存到 report.xlsx\"" +list_channel_sessions(channelType="wecom") +# 从结果中选目标会话,例如 conversation_id 为 wecom:DeBaDe 的会话,然后: +send_channel_message( + conversationId="wecom:DeBaDe", + message="【温度告警】中控测试会议室 当前温度 29.3℃,已超过 28℃,请及时处理" ) ``` -### 按用户筛选会话 +### 任务完成通知到钉钉 ``` -execute_shell_command( - command="mateclaw chats list --agent-id notify-bot --user-id alice" +list_channel_sessions(channelType="dingtalk") +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)缺一不可 -- **没有先查 session 就发送**:不要猜 target-user 和 target-session,必须先查 +- **没有先查会话就发送**:`conversationId` 必须先通过 `list_channel_sessions` 获取 - **把正常对话回复当成推送**:当前会话直接回复不需要用本技能 -- **期望收到回复**:`channels send` 是单向推送,不返回用户回复 +- **期望收到回复**:`send_channel_message` 是单向推送,不返回用户回复 +- **目标会话不存在**:说明机器人从未在该会话收到过消息,请先让用户在目标会话里给机器人发一条消息 +- **渠道未启用 / 不支持主动推送**:按报错提示先在渠道管理中启用对应渠道 diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java index cb3ace85..800d014e 100644 --- a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java @@ -25,8 +25,12 @@ class CronJobRunnerPromptTest { "every scheduled run must carry the execution-context note"); assertTrue(prompt.contains("隔离执行"), "the note must tell the model this run has no prior history"); - assertFalse(prompt.contains("投递回原渠道"), - "web-origin runs have no channel — the delivery clause must be omitted"); + assertFalse(prompt.contains("自动投递回本任务绑定的渠道会话"), + "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), "the no-op sentinel instruction must always be present"); assertTrue(prompt.endsWith(input), @@ -46,17 +50,19 @@ class CronJobRunnerPromptTest { String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin); assertTrue(prompt.contains("[定时任务执行说明]")); - assertTrue(prompt.contains("投递回原渠道"), + assertTrue(prompt.contains("自动投递回本任务绑定的渠道会话"), "channel-bound runs must keep the framework-delivery clause"); - assertTrue(prompt.contains("不要尝试调用 CLI"), - "the channel clause must forbid CLI / send-tool hallucination"); + assertTrue(prompt.contains("不要再用工具把同样的结果重复发送"), + "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 void nullOrigin_stillProducesContextNote() { String prompt = CronJobRunner.buildCronPrompt("hello", null); assertTrue(prompt.contains("[定时任务执行说明]")); - assertFalse(prompt.contains("投递回原渠道")); + assertFalse(prompt.contains("自动投递回本任务绑定的渠道会话")); assertTrue(prompt.endsWith("hello")); } } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index cedafcc1..f4945d60 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -516,6 +516,12 @@ export const channelApi = { health: (id: string | number) => http.get(`/channels/${id}/health`), /** Batch health for all channels in current workspace. */ 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. * Returns a VerificationResult: { ok, skipped, durationMs, headline, diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 98b768fd..17d6c975 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -3110,7 +3110,16 @@ export default { cronExpressionPlaceholder: 'min hour day month weekday, e.g. 0 9 * * 1-5', timezone: 'Timezone', 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' }, messages: { createSuccess: 'Cron job created', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index d38ce858..2a294fba 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -3122,7 +3122,16 @@ export default { cronExpressionPlaceholder: '分 时 日 月 周,如: 0 9 * * 1-5', timezone: '时区', enabled: '立即启用', + deliveryChannel: '投递渠道', + deliveryChannelNone: '不投递(仅写入任务会话)', + deliveryChannelHint: '任务执行完成后,最终结果将主动推送到所选渠道的目标会话', + targetSession: '目标会话', + targetSessionPlaceholder: '选择要投递到的会话', + targetSessionEmpty: '该渠道暂无可投递会话:机器人在某个会话中收到过消息后,该会话才会出现在这里', + deliveryMode: '分发模式', + deliveryModeHint: '静默模式下任务照常执行并记录运行结果,但不把结果推送到渠道会话', }, + deliveryModes: { deliver: '投递结果', silent: '静默(不投递)' }, actions: { runNow: '立即执行', edit: '编辑', delete: '删除' }, messages: { createSuccess: '定时任务创建成功', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 9f33aa0a..86f20b18 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -1102,9 +1102,19 @@ export interface CronJob { // channelId / deliveryConfig: round-trippable on create/update. // lastDeliveryStatus / lastDeliveryError: read-only, populated by // 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 - 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' lastDeliveryError?: string | null } diff --git a/mateclaw-ui/src/views/Scheduler/CronJobsPanel.vue b/mateclaw-ui/src/views/Scheduler/CronJobsPanel.vue index 37d0a24e..26bbc222 100644 --- a/mateclaw-ui/src/views/Scheduler/CronJobsPanel.vue +++ b/mateclaw-ui/src/views/Scheduler/CronJobsPanel.vue @@ -280,6 +280,42 @@ + + +

    @@ -318,7 +354,7 @@ import { mcToast } from '@/composables/useMcToast' import { mcConfirm } from '@/components/common/useConfirm' import { useCronJobStore } from '@/stores/useCronJobStore' import { useAgentStore } from '@/stores/useAgentStore' -import { wikiApi } from '@/api/index' +import { wikiApi, channelApi } from '@/api/index' import type { CronJob } from '@/types/index' import CronExpressionField from '@/components/common/CronExpressionField.vue' @@ -327,6 +363,26 @@ interface WikiKbOption { 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 store = useCronJobStore() const agentStore = useAgentStore() @@ -341,6 +397,13 @@ const editing = ref(null) const detailJob = ref(null) const wikiKbs = ref([]) const wikiKbsLoaded = ref(false) +const channels = ref([]) +const channelsLoaded = ref(false) +const channelSessions = ref([]) +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. const dayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] @@ -352,7 +415,13 @@ const timezones = [ 'Australia/Sydney', ] -const defaultForm = (): Partial & { wikiKbId?: number | string; wikiForce?: boolean } => ({ +const defaultForm = (): Partial & { + wikiKbId?: number | string + wikiForce?: boolean + channelIdStr: string + targetConversationId: string + deliveryMode: 'deliver' | 'silent' +} => ({ name: '', cronExpression: '0 9 * * *', timezone: 'Asia/Shanghai', @@ -363,6 +432,12 @@ const defaultForm = (): Partial & { wikiKbId?: number | string; wikiFor enabled: true, wikiKbId: '', 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(defaultForm()) @@ -376,9 +451,60 @@ const canSave = computed(() => { if (form.value.taskType === 'wiki_process' && (form.value.wikiKbId == null || form.value.wikiKbId === '')) 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 }) +/** 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() { if (wikiKbsLoaded.value) return try { @@ -429,6 +555,9 @@ watch(() => store.jobs.length, (n) => emit('count', n), { immediate: true }) function openCreateModal() { editing.value = null form.value = defaultForm() + channelSessions.value = [] + sessionsLoaded.value = false + loadChannels() showModal.value = true } @@ -439,6 +568,25 @@ function openEditModal(job: CronJob) { applyWikiProcessForm(form.value, job.requestBody) 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 } @@ -483,13 +631,18 @@ function closeDetailModal() { } function buildSavePayload() { - // Strip the form-only wiki helpers and substitute them with the canonical - // JSON request_body the backend expects for wiki_process. For every other - // task type the payload is forwarded as-is. - const { wikiKbId, wikiForce, ...rest } = form.value + // Strip the form-only helpers (wiki + delivery) and substitute them with + // the canonical fields the backend expects. For every other field the + // payload is forwarded as-is. + const { + wikiKbId, wikiForce, + channelIdStr: _channelIdStr, targetConversationId: _targetConversationId, deliveryMode: _deliveryMode, + ...rest + } = form.value if (form.value.taskType === 'wiki_process') { return { ...rest, + ...buildDeliveryPatch(), // Drop agent binding — server defaults to a 0 sentinel for system tasks. agentId: undefined, 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() {