mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(channel): proactive channel-session message push + cron delivery target picker
This commit is contained in:
parent
4c9394a86f
commit
9dd58053c0
@ -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<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")
|
||||
@Operation(summary = "获取渠道运行状态(全局系统视图,仅管理员可见)")
|
||||
@GetMapping("/status")
|
||||
|
||||
@ -282,8 +282,14 @@ public class CronJobRunner {
|
||||
* conversation history is in scope, so the model must not assume
|
||||
* earlier context;</li>
|
||||
* <li>(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;</li>
|
||||
* channel is framework-handled, so the model must not re-send the
|
||||
* 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
|
||||
* should reply with exactly {@link #CRON_SILENT_MARKER} and nothing
|
||||
* else, which suppresses delivery for this run.</li>
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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 <agentId> --channel <channel>"
|
||||
)
|
||||
list_channel_sessions(channelType="wecom")
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```
|
||||
execute_shell_command(
|
||||
command="mateclaw.exe chats list --agent-id <agentId> --channel <channel>"
|
||||
)
|
||||
```
|
||||
|
||||
从返回结果中获取 `user_id` 和 `session_id`。有多个会话时,优先选 `updated_at` 最近的。
|
||||
- `channelType` 可选,不传则列出当前工作区所有可推送会话
|
||||
- 返回每个会话的 `conversation_id`、渠道名称、用户名、最后活跃时间
|
||||
- 有多个候选会话时,优先选**最后活跃时间最近**的
|
||||
|
||||
### 第二步:发送消息
|
||||
|
||||
**macOS / Linux:**
|
||||
```
|
||||
execute_shell_command(
|
||||
command="mateclaw channels send --agent-id <agentId> --channel <channel> --target-user <userId> --target-session <sessionId> --text \"消息内容\""
|
||||
send_channel_message(
|
||||
conversationId="wecom:xxxx",
|
||||
message="✅ 数据分析已完成,结果已保存到 report.xlsx"
|
||||
)
|
||||
```
|
||||
|
||||
**Windows(PowerShell):**
|
||||
```
|
||||
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` | 消息内容 |
|
||||
- `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` 是单向推送,不返回用户回复
|
||||
- **目标会话不存在**:说明机器人从未在该会话收到过消息,请先让用户在目标会话里给机器人发一条消息
|
||||
- **渠道未启用 / 不支持主动推送**:按报错提示先在渠道管理中启用对应渠道
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '定时任务创建成功',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -280,6 +280,42 @@
|
||||
</div>
|
||||
</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">
|
||||
<label class="form-label">{{ t('cronJobs.fields.cronExpression') }} *</label>
|
||||
<CronExpressionField v-model="form.cronExpression" />
|
||||
@ -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<CronJob | null>(null)
|
||||
const detailJob = ref<CronJob | null>(null)
|
||||
const wikiKbs = ref<WikiKbOption[]>([])
|
||||
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.
|
||||
const dayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
@ -352,7 +415,13 @@ const timezones = [
|
||||
'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: '',
|
||||
cronExpression: '0 9 * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
@ -363,6 +432,12 @@ const defaultForm = (): Partial<CronJob> & { 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<any>(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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user