mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.channel.ChannelManager;
|
import vip.mate.channel.ChannelManager;
|
||||||
|
import vip.mate.channel.ChannelSessionStore;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
import vip.mate.channel.model.ChannelSessionEntity;
|
||||||
import vip.mate.channel.service.ChannelService;
|
import vip.mate.channel.service.ChannelService;
|
||||||
import vip.mate.channel.verifier.ChannelVerifierRegistry;
|
import vip.mate.channel.verifier.ChannelVerifierRegistry;
|
||||||
import vip.mate.channel.verifier.VerificationRequest;
|
import vip.mate.channel.verifier.VerificationRequest;
|
||||||
@ -18,7 +20,9 @@ import vip.mate.common.result.R;
|
|||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -39,6 +43,7 @@ public class ChannelController {
|
|||||||
|
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
private final ChannelManager channelManager;
|
private final ChannelManager channelManager;
|
||||||
|
private final ChannelSessionStore channelSessionStore;
|
||||||
private final AuditEventService auditEventService;
|
private final AuditEventService auditEventService;
|
||||||
private final ChannelVerifierRegistry verifierRegistry;
|
private final ChannelVerifierRegistry verifierRegistry;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@ -175,6 +180,32 @@ public class ChannelController {
|
|||||||
return R.ok(channel);
|
return R.ok(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
@Operation(summary = "获取渠道的会话列表(可作为主动推送 / 定时任务投递目标)")
|
||||||
|
@GetMapping("/{id}/sessions")
|
||||||
|
public R<List<ChannelSessionSummary>> sessions(@PathVariable Long id,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
ChannelEntity channel = channelService.getChannel(id);
|
||||||
|
verifyResourceWorkspace(channel.getWorkspaceId(), workspaceId);
|
||||||
|
return R.ok(channelSessionStore.listByChannelId(id).stream()
|
||||||
|
.sorted(Comparator.comparing(ChannelSessionEntity::getLastActiveTime,
|
||||||
|
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||||
|
.map(ChannelSessionSummary::from)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slim projection of {@code mate_channel_session} for target pickers —
|
||||||
|
* exposes only what the UI needs to render and bind a delivery target.
|
||||||
|
*/
|
||||||
|
public record ChannelSessionSummary(String conversationId, String channelType, String targetId,
|
||||||
|
String senderId, String senderName, LocalDateTime lastActiveTime) {
|
||||||
|
static ChannelSessionSummary from(ChannelSessionEntity s) {
|
||||||
|
return new ChannelSessionSummary(s.getConversationId(), s.getChannelType(), s.getTargetId(),
|
||||||
|
s.getSenderId(), s.getSenderName(), s.getLastActiveTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@RequireWorkspaceRole("admin")
|
@RequireWorkspaceRole("admin")
|
||||||
@Operation(summary = "获取渠道运行状态(全局系统视图,仅管理员可见)")
|
@Operation(summary = "获取渠道运行状态(全局系统视图,仅管理员可见)")
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
|
|||||||
@ -282,8 +282,14 @@ public class CronJobRunner {
|
|||||||
* conversation history is in scope, so the model must not assume
|
* conversation history is in scope, so the model must not assume
|
||||||
* earlier context;</li>
|
* earlier context;</li>
|
||||||
* <li>(channel-bound runs only) delivery back to the originating
|
* <li>(channel-bound runs only) delivery back to the originating
|
||||||
* channel is framework-handled, so the model must not invent
|
* channel is framework-handled, so the model must not re-send the
|
||||||
* CLI / shell / "send to WeChat" tool calls to deliver the result;</li>
|
* final result to that same channel itself; pushing to a
|
||||||
|
* <em>different</em> conversation, when the task explicitly asks
|
||||||
|
* for it, goes through the {@code send_channel_message} tool;</li>
|
||||||
|
* <li>(non-channel-bound runs) nothing is auto-delivered to any IM
|
||||||
|
* channel — if the task instructions require notifying a channel
|
||||||
|
* conversation, the model should use {@code list_channel_sessions}
|
||||||
|
* + {@code send_channel_message};</li>
|
||||||
* <li>when there is genuinely nothing to do or report, the model
|
* <li>when there is genuinely nothing to do or report, the model
|
||||||
* should reply with exactly {@link #CRON_SILENT_MARKER} and nothing
|
* should reply with exactly {@link #CRON_SILENT_MARKER} and nothing
|
||||||
* else, which suppresses delivery for this run.</li>
|
* else, which suppresses delivery for this run.</li>
|
||||||
@ -298,8 +304,15 @@ public class CronJobRunner {
|
|||||||
sb.append("- 请把下面的「任务指令」当作一个完整、独立的任务来执行;")
|
sb.append("- 请把下面的「任务指令」当作一个完整、独立的任务来执行;")
|
||||||
.append("本次为隔离执行,没有此前的对话历史,不要假设存在上下文。\n");
|
.append("本次为隔离执行,没有此前的对话历史,不要假设存在上下文。\n");
|
||||||
if (channelBound) {
|
if (channelBound) {
|
||||||
sb.append("- 执行结果会由系统自动投递回原渠道,你只需直接给出最终结果内容,")
|
sb.append("- 执行结果会由系统自动投递回本任务绑定的渠道会话,你只需直接给出最终结果内容,")
|
||||||
.append("不要尝试调用 CLI / shell / \"发送到微信\"等工具自行投递。\n");
|
.append("不要再用工具把同样的结果重复发送到该会话;")
|
||||||
|
.append("仅当任务指令明确要求把消息发送到其它渠道会话时,")
|
||||||
|
.append("才先用 list_channel_sessions 查询目标会话,再用 send_channel_message 发送。\n");
|
||||||
|
} else {
|
||||||
|
sb.append("- 本任务未绑定渠道,执行结果只会写入任务会话,不会自动推送到任何 IM 渠道;")
|
||||||
|
.append("如任务指令要求把消息发送到某个渠道会话(如企业微信 / 飞书 / 钉钉),")
|
||||||
|
.append("请先用 list_channel_sessions 工具查询可用会话,")
|
||||||
|
.append("再用 send_channel_message 工具发送,不要尝试调用 CLI / shell 自行投递。\n");
|
||||||
}
|
}
|
||||||
sb.append("- 如果确认本次确实无需执行、也没有新内容可汇报,")
|
sb.append("- 如果确认本次确实无需执行、也没有新内容可汇报,")
|
||||||
.append("请仅回复 \"").append(CRON_SILENT_MARKER).append("\",不要附加任何其它文字。\n\n");
|
.append("请仅回复 \"").append(CRON_SILENT_MARKER).append("\",不要附加任何其它文字。\n\n");
|
||||||
|
|||||||
@ -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)
|
KEY (id)
|
||||||
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0);
|
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0);
|
||||||
|
|
||||||
|
-- Builtin tool: channel message push
|
||||||
|
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
|
KEY (id)
|
||||||
|
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);
|
||||||
|
|
||||||
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
|
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
|
||||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
KEY (id)
|
KEY (id)
|
||||||
|
|||||||
@ -514,6 +514,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
|
|||||||
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
|
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||||
|
|
||||||
|
-- Builtin tool: channel message push
|
||||||
|
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
|
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||||
|
|
||||||
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
|
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
|
||||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
|
VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
|
|||||||
@ -509,6 +509,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
|
|||||||
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
|
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||||
|
|
||||||
|
-- 内置工具:渠道消息推送
|
||||||
|
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
|
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话,send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||||
|
|
||||||
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
|
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
|
||||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
|
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
|
|||||||
@ -523,6 +523,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
|
|||||||
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
|
VALUES (1000000027, 'LocalShellTool', 'Local Shell', 'Execute shell commands on the user''s local desktop machine via the desktop tunnel. Requires native user approval.', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||||
|
|
||||||
|
-- Builtin tool: channel message push
|
||||||
|
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
|
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
|
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||||
|
|
||||||
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
|
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
|
||||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
|
VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
|
|||||||
@ -518,6 +518,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
|
|||||||
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
|
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||||
|
|
||||||
|
-- 内置工具:渠道消息推送
|
||||||
|
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
|
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话,send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
|
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||||
|
|
||||||
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
|
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
|
||||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
|
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||||
|
|||||||
@ -471,6 +471,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
|
|||||||
KEY (id)
|
KEY (id)
|
||||||
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0);
|
VALUES (1000000027, 'LocalShellTool', '本地命令执行', '通过桌面隧道在用户本机执行 Shell 命令(非服务器)。每次执行需用户在桌面端原生审批。', 'builtin', 'localShellTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0);
|
||||||
|
|
||||||
|
-- 内置工具:渠道消息推送
|
||||||
|
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
|
KEY (id)
|
||||||
|
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话,send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);
|
||||||
|
|
||||||
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
|
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
|
||||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
KEY (id)
|
KEY (id)
|
||||||
|
|||||||
@ -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
|
name: channel_message
|
||||||
version: "1.3.0"
|
version: "2.0.0"
|
||||||
description: "当需要主动向用户、会话或渠道单向推送消息时使用。适用于任务完成通知、定时提醒、异步结果回推等场景。"
|
description: "当需要主动向某个渠道会话单向推送消息时使用(企业微信、钉钉、飞书、Telegram、Discord、QQ、Slack 等)。适用于任务完成通知、定时提醒告警、异步结果回推等场景。先用 list_channel_sessions 查询目标会话,再用 send_channel_message 发送。"
|
||||||
dependencies:
|
dependencies:
|
||||||
tools:
|
tools:
|
||||||
- execute_shell_command
|
- list_channel_sessions
|
||||||
|
- send_channel_message
|
||||||
---
|
---
|
||||||
|
|
||||||
# 渠道消息推送
|
# 渠道消息推送
|
||||||
@ -20,97 +21,67 @@ dependencies:
|
|||||||
- 将后台任务结果推送回指定会话
|
- 将后台任务结果推送回指定会话
|
||||||
|
|
||||||
### 不应使用
|
### 不应使用
|
||||||
- 当前对话中的正常回复(直接回复即可)
|
- 当前对话中的正常回复(直接回复即可,不要重复推送)
|
||||||
- 需要等待用户回复的双向交互
|
- 需要等待用户回复的双向交互
|
||||||
- 目标渠道或会话不明确时(先询问用户)
|
- 目标渠道或会话不明确时(先询问用户)
|
||||||
|
|
||||||
## 支持渠道
|
## 支持渠道
|
||||||
|
|
||||||
`console`、`dingtalk`、`feishu`、`telegram`、`discord`、`qq`、`slack`
|
`wecom`(企业微信)、`dingtalk`、`feishu`、`telegram`、`discord`、`qq`、`slack`、`weixin`
|
||||||
|
|
||||||
|
> 注意:只有机器人**收到过消息**的会话才能主动推送——平台的推送句柄是在收到入站消息时记录的。如果目标会话不在列表里,需要先让对方在该会话中给机器人发一条消息。
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
### 第一步:查询目标会话
|
### 第一步:查询目标会话
|
||||||
|
|
||||||
**macOS / Linux:**
|
|
||||||
```
|
```
|
||||||
execute_shell_command(
|
list_channel_sessions(channelType="wecom")
|
||||||
command="mateclaw chats list --agent-id <agentId> --channel <channel>"
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows:**
|
- `channelType` 可选,不传则列出当前工作区所有可推送会话
|
||||||
```
|
- 返回每个会话的 `conversation_id`、渠道名称、用户名、最后活跃时间
|
||||||
execute_shell_command(
|
- 有多个候选会话时,优先选**最后活跃时间最近**的
|
||||||
command="mateclaw.exe chats list --agent-id <agentId> --channel <channel>"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
从返回结果中获取 `user_id` 和 `session_id`。有多个会话时,优先选 `updated_at` 最近的。
|
|
||||||
|
|
||||||
### 第二步:发送消息
|
### 第二步:发送消息
|
||||||
|
|
||||||
**macOS / Linux:**
|
|
||||||
```
|
```
|
||||||
execute_shell_command(
|
send_channel_message(
|
||||||
command="mateclaw channels send --agent-id <agentId> --channel <channel> --target-user <userId> --target-session <sessionId> --text \"消息内容\""
|
conversationId="wecom:xxxx",
|
||||||
|
message="✅ 数据分析已完成,结果已保存到 report.xlsx"
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows(PowerShell):**
|
- `conversationId` 必须来自 `list_channel_sessions` 的返回结果,**不要凭空猜测**
|
||||||
```
|
- `message` 为消息正文(纯文本 / Markdown,取决于渠道能力),超过 4096 字符会被截断
|
||||||
execute_shell_command(
|
|
||||||
command="mateclaw.exe channels send --agent-id <agentId> --channel <channel> --target-user <userId> --target-session <sessionId> --text '消息内容'"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 必填参数一览
|
|
||||||
|
|
||||||
| 参数 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `--agent-id` | 当前 Agent 的 ID |
|
|
||||||
| `--channel` | 目标渠道名称(见支持渠道列表) |
|
|
||||||
| `--target-user` | 目标用户 ID(从 `chats list` 获取) |
|
|
||||||
| `--target-session` | 目标会话 ID(从 `chats list` 获取) |
|
|
||||||
| `--text` | 消息内容 |
|
|
||||||
|
|
||||||
## 常见场景示例
|
## 常见场景示例
|
||||||
|
|
||||||
### 任务完成通知
|
### 温度告警推送到企业微信
|
||||||
|
|
||||||
```
|
```
|
||||||
execute_shell_command(
|
list_channel_sessions(channelType="wecom")
|
||||||
command="mateclaw chats list --agent-id task-bot --channel dingtalk"
|
# 从结果中选目标会话,例如 conversation_id 为 wecom:DeBaDe 的会话,然后:
|
||||||
)
|
send_channel_message(
|
||||||
# 从结果中取 user_id / session_id,然后:
|
conversationId="wecom:DeBaDe",
|
||||||
execute_shell_command(
|
message="【温度告警】中控测试会议室 当前温度 29.3℃,已超过 28℃,请及时处理"
|
||||||
command="mateclaw channels send --agent-id task-bot --channel dingtalk --target-user alice --target-session alice_dt_001 --text \"✅ 数据分析已完成,结果已保存到 report.xlsx\""
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 按用户筛选会话
|
### 任务完成通知到钉钉
|
||||||
|
|
||||||
```
|
```
|
||||||
execute_shell_command(
|
list_channel_sessions(channelType="dingtalk")
|
||||||
command="mateclaw chats list --agent-id notify-bot --user-id alice"
|
send_channel_message(
|
||||||
|
conversationId="dingtalk:sw:xxxx",
|
||||||
|
message="✅ 周报生成完成,已写入知识库"
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## mateclaw CLI 未安装时的降级处理
|
|
||||||
|
|
||||||
若 `mateclaw` 命令不可用:
|
|
||||||
|
|
||||||
1. 检测:
|
|
||||||
```
|
|
||||||
execute_shell_command(command="which mateclaw || where mateclaw")
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 如果未安装,告知用户:
|
|
||||||
> mateclaw CLI 未找到,无法主动推送消息。请确认 MateClaw 已正确安装并将 CLI 加入 PATH。安装后重试。
|
|
||||||
|
|
||||||
## 常见错误
|
## 常见错误
|
||||||
|
|
||||||
- **缺少必填参数**:5 个参数(agent-id、channel、target-user、target-session、text)缺一不可
|
- **没有先查会话就发送**:`conversationId` 必须先通过 `list_channel_sessions` 获取
|
||||||
- **没有先查 session 就发送**:不要猜 target-user 和 target-session,必须先查
|
|
||||||
- **把正常对话回复当成推送**:当前会话直接回复不需要用本技能
|
- **把正常对话回复当成推送**:当前会话直接回复不需要用本技能
|
||||||
- **期望收到回复**:`channels send` 是单向推送,不返回用户回复
|
- **期望收到回复**:`send_channel_message` 是单向推送,不返回用户回复
|
||||||
|
- **目标会话不存在**:说明机器人从未在该会话收到过消息,请先让用户在目标会话里给机器人发一条消息
|
||||||
|
- **渠道未启用 / 不支持主动推送**:按报错提示先在渠道管理中启用对应渠道
|
||||||
|
|||||||
@ -25,8 +25,12 @@ class CronJobRunnerPromptTest {
|
|||||||
"every scheduled run must carry the execution-context note");
|
"every scheduled run must carry the execution-context note");
|
||||||
assertTrue(prompt.contains("隔离执行"),
|
assertTrue(prompt.contains("隔离执行"),
|
||||||
"the note must tell the model this run has no prior history");
|
"the note must tell the model this run has no prior history");
|
||||||
assertFalse(prompt.contains("投递回原渠道"),
|
assertFalse(prompt.contains("自动投递回本任务绑定的渠道会话"),
|
||||||
"web-origin runs have no channel — the delivery clause must be omitted");
|
"web-origin runs have no channel — the auto-delivery clause must be omitted");
|
||||||
|
assertTrue(prompt.contains("本任务未绑定渠道"),
|
||||||
|
"non-channel runs must state that nothing is auto-delivered");
|
||||||
|
assertTrue(prompt.contains("send_channel_message"),
|
||||||
|
"non-channel runs must point at the channel-message tool for explicit sends");
|
||||||
assertTrue(prompt.contains(CronJobRunner.CRON_SILENT_MARKER),
|
assertTrue(prompt.contains(CronJobRunner.CRON_SILENT_MARKER),
|
||||||
"the no-op sentinel instruction must always be present");
|
"the no-op sentinel instruction must always be present");
|
||||||
assertTrue(prompt.endsWith(input),
|
assertTrue(prompt.endsWith(input),
|
||||||
@ -46,17 +50,19 @@ class CronJobRunnerPromptTest {
|
|||||||
String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin);
|
String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin);
|
||||||
|
|
||||||
assertTrue(prompt.contains("[定时任务执行说明]"));
|
assertTrue(prompt.contains("[定时任务执行说明]"));
|
||||||
assertTrue(prompt.contains("投递回原渠道"),
|
assertTrue(prompt.contains("自动投递回本任务绑定的渠道会话"),
|
||||||
"channel-bound runs must keep the framework-delivery clause");
|
"channel-bound runs must keep the framework-delivery clause");
|
||||||
assertTrue(prompt.contains("不要尝试调用 CLI"),
|
assertTrue(prompt.contains("不要再用工具把同样的结果重复发送"),
|
||||||
"the channel clause must forbid CLI / send-tool hallucination");
|
"the channel clause must forbid duplicate self-delivery to the bound conversation");
|
||||||
|
assertTrue(prompt.contains("send_channel_message"),
|
||||||
|
"cross-conversation sends must be routed through the channel-message tool");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void nullOrigin_stillProducesContextNote() {
|
void nullOrigin_stillProducesContextNote() {
|
||||||
String prompt = CronJobRunner.buildCronPrompt("hello", null);
|
String prompt = CronJobRunner.buildCronPrompt("hello", null);
|
||||||
assertTrue(prompt.contains("[定时任务执行说明]"));
|
assertTrue(prompt.contains("[定时任务执行说明]"));
|
||||||
assertFalse(prompt.contains("投递回原渠道"));
|
assertFalse(prompt.contains("自动投递回本任务绑定的渠道会话"));
|
||||||
assertTrue(prompt.endsWith("hello"));
|
assertTrue(prompt.endsWith("hello"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -516,6 +516,12 @@ export const channelApi = {
|
|||||||
health: (id: string | number) => http.get(`/channels/${id}/health`),
|
health: (id: string | number) => http.get(`/channels/${id}/health`),
|
||||||
/** Batch health for all channels in current workspace. */
|
/** Batch health for all channels in current workspace. */
|
||||||
healthAll: () => http.get('/channels/health'),
|
healthAll: () => http.get('/channels/health'),
|
||||||
|
/**
|
||||||
|
* List a channel's known conversations (proactive-push targets). Used by
|
||||||
|
* the cron delivery-target picker; a conversation appears here once the
|
||||||
|
* bot has received at least one inbound message in it.
|
||||||
|
*/
|
||||||
|
listSessions: (id: string | number) => http.get(`/channels/${id}/sessions`),
|
||||||
/**
|
/**
|
||||||
* Wizard Step 2 — validate a draft config without persisting.
|
* Wizard Step 2 — validate a draft config without persisting.
|
||||||
* Returns a VerificationResult: { ok, skipped, durationMs, headline,
|
* Returns a VerificationResult: { ok, skipped, durationMs, headline,
|
||||||
|
|||||||
@ -3110,7 +3110,16 @@ export default {
|
|||||||
cronExpressionPlaceholder: 'min hour day month weekday, e.g. 0 9 * * 1-5',
|
cronExpressionPlaceholder: 'min hour day month weekday, e.g. 0 9 * * 1-5',
|
||||||
timezone: 'Timezone',
|
timezone: 'Timezone',
|
||||||
enabled: 'Enable immediately',
|
enabled: 'Enable immediately',
|
||||||
|
deliveryChannel: 'Delivery Channel',
|
||||||
|
deliveryChannelNone: 'No delivery (task conversation only)',
|
||||||
|
deliveryChannelHint: 'When the job finishes, the final result is proactively pushed to the selected channel conversation',
|
||||||
|
targetSession: 'Target Conversation',
|
||||||
|
targetSessionPlaceholder: 'Select the conversation to deliver to',
|
||||||
|
targetSessionEmpty: 'No pushable conversations on this channel yet — a conversation appears here after the bot has received at least one message in it',
|
||||||
|
deliveryMode: 'Delivery Mode',
|
||||||
|
deliveryModeHint: 'In silent mode the job still runs and records its result, but nothing is pushed to the channel conversation',
|
||||||
},
|
},
|
||||||
|
deliveryModes: { deliver: 'Deliver result', silent: 'Silent (no delivery)' },
|
||||||
actions: { runNow: 'Run Now', edit: 'Edit', delete: 'Delete' },
|
actions: { runNow: 'Run Now', edit: 'Edit', delete: 'Delete' },
|
||||||
messages: {
|
messages: {
|
||||||
createSuccess: 'Cron job created',
|
createSuccess: 'Cron job created',
|
||||||
|
|||||||
@ -3122,7 +3122,16 @@ export default {
|
|||||||
cronExpressionPlaceholder: '分 时 日 月 周,如: 0 9 * * 1-5',
|
cronExpressionPlaceholder: '分 时 日 月 周,如: 0 9 * * 1-5',
|
||||||
timezone: '时区',
|
timezone: '时区',
|
||||||
enabled: '立即启用',
|
enabled: '立即启用',
|
||||||
|
deliveryChannel: '投递渠道',
|
||||||
|
deliveryChannelNone: '不投递(仅写入任务会话)',
|
||||||
|
deliveryChannelHint: '任务执行完成后,最终结果将主动推送到所选渠道的目标会话',
|
||||||
|
targetSession: '目标会话',
|
||||||
|
targetSessionPlaceholder: '选择要投递到的会话',
|
||||||
|
targetSessionEmpty: '该渠道暂无可投递会话:机器人在某个会话中收到过消息后,该会话才会出现在这里',
|
||||||
|
deliveryMode: '分发模式',
|
||||||
|
deliveryModeHint: '静默模式下任务照常执行并记录运行结果,但不把结果推送到渠道会话',
|
||||||
},
|
},
|
||||||
|
deliveryModes: { deliver: '投递结果', silent: '静默(不投递)' },
|
||||||
actions: { runNow: '立即执行', edit: '编辑', delete: '删除' },
|
actions: { runNow: '立即执行', edit: '编辑', delete: '删除' },
|
||||||
messages: {
|
messages: {
|
||||||
createSuccess: '定时任务创建成功',
|
createSuccess: '定时任务创建成功',
|
||||||
|
|||||||
@ -1102,9 +1102,19 @@ export interface CronJob {
|
|||||||
// channelId / deliveryConfig: round-trippable on create/update.
|
// channelId / deliveryConfig: round-trippable on create/update.
|
||||||
// lastDeliveryStatus / lastDeliveryError: read-only, populated by
|
// lastDeliveryStatus / lastDeliveryError: read-only, populated by
|
||||||
// selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend.
|
// selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend.
|
||||||
channelId?: number | null
|
// Runtime is always a string (global Long→String serialization); keep the
|
||||||
|
// union so pre-existing number literals in callers still type-check.
|
||||||
|
channelId?: string | number | null
|
||||||
channelName?: string | null
|
channelName?: string | null
|
||||||
deliveryConfig?: { targetId?: string | null; threadId?: string | null; accountId?: string | null } | null
|
deliveryConfig?: {
|
||||||
|
targetId?: string | null
|
||||||
|
threadId?: string | null
|
||||||
|
accountId?: string | null
|
||||||
|
/** IM senderId of the delivery target user — used for session matching. */
|
||||||
|
userId?: string | null
|
||||||
|
/** True = run the job but don't push the result to the channel. */
|
||||||
|
suppressAgentReply?: boolean | null
|
||||||
|
} | null
|
||||||
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
|
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
|
||||||
lastDeliveryError?: string | null
|
lastDeliveryError?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -280,6 +280,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Delivery binding: push the final run result to an IM channel
|
||||||
|
conversation. Writes the existing channelId + deliveryConfig
|
||||||
|
contract; empty channel = result stays in the task conversation. -->
|
||||||
|
<template v-if="form.taskType !== 'wiki_process'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ t('cronJobs.fields.deliveryChannel') }}</label>
|
||||||
|
<select v-model="form.channelIdStr" class="form-input" @change="onChannelChange">
|
||||||
|
<option value="">{{ t('cronJobs.fields.deliveryChannelNone') }}</option>
|
||||||
|
<option v-for="c in deliveryChannels" :key="String(c.id)" :value="String(c.id)">
|
||||||
|
{{ c.name }} ({{ c.channelType }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="form-hint">{{ t('cronJobs.fields.deliveryChannelHint') }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.channelIdStr" class="form-group">
|
||||||
|
<label class="form-label">{{ t('cronJobs.fields.targetSession') }} *</label>
|
||||||
|
<select v-model="form.targetConversationId" class="form-input">
|
||||||
|
<option value="" disabled>{{ t('cronJobs.fields.targetSessionPlaceholder') }}</option>
|
||||||
|
<option v-for="s in channelSessions" :key="s.conversationId" :value="s.conversationId">
|
||||||
|
{{ sessionLabel(s) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="sessionsLoaded && channelSessions.length === 0" class="form-hint">
|
||||||
|
{{ t('cronJobs.fields.targetSessionEmpty') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.channelIdStr" class="form-group">
|
||||||
|
<label class="form-label">{{ t('cronJobs.fields.deliveryMode') }}</label>
|
||||||
|
<select v-model="form.deliveryMode" class="form-input">
|
||||||
|
<option value="deliver">{{ t('cronJobs.deliveryModes.deliver') }}</option>
|
||||||
|
<option value="silent">{{ t('cronJobs.deliveryModes.silent') }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="form-hint">{{ t('cronJobs.fields.deliveryModeHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ t('cronJobs.fields.cronExpression') }} *</label>
|
<label class="form-label">{{ t('cronJobs.fields.cronExpression') }} *</label>
|
||||||
<CronExpressionField v-model="form.cronExpression" />
|
<CronExpressionField v-model="form.cronExpression" />
|
||||||
@ -318,7 +354,7 @@ import { mcToast } from '@/composables/useMcToast'
|
|||||||
import { mcConfirm } from '@/components/common/useConfirm'
|
import { mcConfirm } from '@/components/common/useConfirm'
|
||||||
import { useCronJobStore } from '@/stores/useCronJobStore'
|
import { useCronJobStore } from '@/stores/useCronJobStore'
|
||||||
import { useAgentStore } from '@/stores/useAgentStore'
|
import { useAgentStore } from '@/stores/useAgentStore'
|
||||||
import { wikiApi } from '@/api/index'
|
import { wikiApi, channelApi } from '@/api/index'
|
||||||
import type { CronJob } from '@/types/index'
|
import type { CronJob } from '@/types/index'
|
||||||
import CronExpressionField from '@/components/common/CronExpressionField.vue'
|
import CronExpressionField from '@/components/common/CronExpressionField.vue'
|
||||||
|
|
||||||
@ -327,6 +363,26 @@ interface WikiKbOption {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChannelOption {
|
||||||
|
id: number | string
|
||||||
|
name: string
|
||||||
|
channelType: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelSessionOption {
|
||||||
|
conversationId: string
|
||||||
|
channelType: string
|
||||||
|
targetId: string
|
||||||
|
senderId?: string | null
|
||||||
|
senderName?: string | null
|
||||||
|
lastActiveTime?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web-family channels have no proactive-push transport, so they are not
|
||||||
|
// offered as cron delivery targets.
|
||||||
|
const NON_PUSHABLE_CHANNEL_TYPES = new Set(['web', 'webchat'])
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useCronJobStore()
|
const store = useCronJobStore()
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
@ -341,6 +397,13 @@ const editing = ref<CronJob | null>(null)
|
|||||||
const detailJob = ref<CronJob | null>(null)
|
const detailJob = ref<CronJob | null>(null)
|
||||||
const wikiKbs = ref<WikiKbOption[]>([])
|
const wikiKbs = ref<WikiKbOption[]>([])
|
||||||
const wikiKbsLoaded = ref(false)
|
const wikiKbsLoaded = ref(false)
|
||||||
|
const channels = ref<ChannelOption[]>([])
|
||||||
|
const channelsLoaded = ref(false)
|
||||||
|
const channelSessions = ref<ChannelSessionOption[]>([])
|
||||||
|
const sessionsLoaded = ref(false)
|
||||||
|
|
||||||
|
const deliveryChannels = computed(() =>
|
||||||
|
channels.value.filter((c) => c.enabled !== false && !NON_PUSHABLE_CHANNEL_TYPES.has(c.channelType)))
|
||||||
|
|
||||||
// Weekday keys for the table's human-readable cron rendering.
|
// Weekday keys for the table's human-readable cron rendering.
|
||||||
const dayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
const dayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||||
@ -352,7 +415,13 @@ const timezones = [
|
|||||||
'Australia/Sydney',
|
'Australia/Sydney',
|
||||||
]
|
]
|
||||||
|
|
||||||
const defaultForm = (): Partial<CronJob> & { wikiKbId?: number | string; wikiForce?: boolean } => ({
|
const defaultForm = (): Partial<CronJob> & {
|
||||||
|
wikiKbId?: number | string
|
||||||
|
wikiForce?: boolean
|
||||||
|
channelIdStr: string
|
||||||
|
targetConversationId: string
|
||||||
|
deliveryMode: 'deliver' | 'silent'
|
||||||
|
} => ({
|
||||||
name: '',
|
name: '',
|
||||||
cronExpression: '0 9 * * *',
|
cronExpression: '0 9 * * *',
|
||||||
timezone: 'Asia/Shanghai',
|
timezone: 'Asia/Shanghai',
|
||||||
@ -363,6 +432,12 @@ const defaultForm = (): Partial<CronJob> & { wikiKbId?: number | string; wikiFor
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
wikiKbId: '',
|
wikiKbId: '',
|
||||||
wikiForce: false,
|
wikiForce: false,
|
||||||
|
// Delivery helpers (form-only, translated to channelId + deliveryConfig on
|
||||||
|
// save). channelIdStr stays a string end-to-end — Snowflake ids must never
|
||||||
|
// pass through Number.
|
||||||
|
channelIdStr: '',
|
||||||
|
targetConversationId: '',
|
||||||
|
deliveryMode: 'deliver',
|
||||||
})
|
})
|
||||||
const form = ref<any>(defaultForm())
|
const form = ref<any>(defaultForm())
|
||||||
|
|
||||||
@ -376,9 +451,60 @@ const canSave = computed(() => {
|
|||||||
if (form.value.taskType === 'wiki_process'
|
if (form.value.taskType === 'wiki_process'
|
||||||
&& (form.value.wikiKbId == null || form.value.wikiKbId === '')) return false
|
&& (form.value.wikiKbId == null || form.value.wikiKbId === '')) return false
|
||||||
if (!form.value.cronExpression?.trim()) return false
|
if (!form.value.cronExpression?.trim()) return false
|
||||||
|
// A bound delivery channel needs a target conversation, unless we're
|
||||||
|
// editing a job that already carries a target on the same channel (the
|
||||||
|
// original session may have aged out of the picker).
|
||||||
|
if (form.value.taskType !== 'wiki_process' && form.value.channelIdStr
|
||||||
|
&& !form.value.targetConversationId && !editingKeepsTarget()) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** True when the edited job already has a delivery target on the currently selected channel. */
|
||||||
|
function editingKeepsTarget(): boolean {
|
||||||
|
return !!(editing.value
|
||||||
|
&& String(editing.value.channelId ?? '') === form.value.channelIdStr
|
||||||
|
&& editing.value.deliveryConfig?.targetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChannels() {
|
||||||
|
if (channelsLoaded.value) return
|
||||||
|
try {
|
||||||
|
const res: any = await channelApi.list()
|
||||||
|
channels.value = (res?.data || []) as ChannelOption[]
|
||||||
|
} catch {
|
||||||
|
channels.value = []
|
||||||
|
} finally {
|
||||||
|
channelsLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessions(channelId: string) {
|
||||||
|
sessionsLoaded.value = false
|
||||||
|
channelSessions.value = []
|
||||||
|
if (!channelId) {
|
||||||
|
sessionsLoaded.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res: any = await channelApi.listSessions(channelId)
|
||||||
|
channelSessions.value = (res?.data || []) as ChannelSessionOption[]
|
||||||
|
} catch {
|
||||||
|
channelSessions.value = []
|
||||||
|
} finally {
|
||||||
|
sessionsLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChannelChange() {
|
||||||
|
form.value.targetConversationId = ''
|
||||||
|
loadSessions(form.value.channelIdStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionLabel(s: ChannelSessionOption): string {
|
||||||
|
const who = s.senderName || s.senderId || s.targetId
|
||||||
|
return who ? `${who} · ${s.conversationId}` : s.conversationId
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWikiKbs() {
|
async function loadWikiKbs() {
|
||||||
if (wikiKbsLoaded.value) return
|
if (wikiKbsLoaded.value) return
|
||||||
try {
|
try {
|
||||||
@ -429,6 +555,9 @@ watch(() => store.jobs.length, (n) => emit('count', n), { immediate: true })
|
|||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
form.value = defaultForm()
|
form.value = defaultForm()
|
||||||
|
channelSessions.value = []
|
||||||
|
sessionsLoaded.value = false
|
||||||
|
loadChannels()
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,6 +568,25 @@ function openEditModal(job: CronJob) {
|
|||||||
applyWikiProcessForm(form.value, job.requestBody)
|
applyWikiProcessForm(form.value, job.requestBody)
|
||||||
loadWikiKbs()
|
loadWikiKbs()
|
||||||
}
|
}
|
||||||
|
loadChannels()
|
||||||
|
// Rehydrate the delivery helpers from the persisted binding. IDs stay
|
||||||
|
// strings end-to-end (Snowflake precision).
|
||||||
|
form.value.channelIdStr = job.channelId != null ? String(job.channelId) : ''
|
||||||
|
form.value.deliveryMode = job.deliveryConfig?.suppressAgentReply ? 'silent' : 'deliver'
|
||||||
|
form.value.targetConversationId = ''
|
||||||
|
if (form.value.channelIdStr) {
|
||||||
|
loadSessions(form.value.channelIdStr).then(() => {
|
||||||
|
// Preselect the session that matches the stored delivery target; when
|
||||||
|
// it aged out of the store, the picker stays empty but saving keeps
|
||||||
|
// the original target (see buildDeliveryPatch).
|
||||||
|
const match = channelSessions.value.find(
|
||||||
|
(s) => s.targetId === job.deliveryConfig?.targetId)
|
||||||
|
if (match) form.value.targetConversationId = match.conversationId
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
channelSessions.value = []
|
||||||
|
sessionsLoaded.value = false
|
||||||
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,13 +631,18 @@ function closeDetailModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSavePayload() {
|
function buildSavePayload() {
|
||||||
// Strip the form-only wiki helpers and substitute them with the canonical
|
// Strip the form-only helpers (wiki + delivery) and substitute them with
|
||||||
// JSON request_body the backend expects for wiki_process. For every other
|
// the canonical fields the backend expects. For every other field the
|
||||||
// task type the payload is forwarded as-is.
|
// payload is forwarded as-is.
|
||||||
const { wikiKbId, wikiForce, ...rest } = form.value
|
const {
|
||||||
|
wikiKbId, wikiForce,
|
||||||
|
channelIdStr: _channelIdStr, targetConversationId: _targetConversationId, deliveryMode: _deliveryMode,
|
||||||
|
...rest
|
||||||
|
} = form.value
|
||||||
if (form.value.taskType === 'wiki_process') {
|
if (form.value.taskType === 'wiki_process') {
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
...buildDeliveryPatch(),
|
||||||
// Drop agent binding — server defaults to a 0 sentinel for system tasks.
|
// Drop agent binding — server defaults to a 0 sentinel for system tasks.
|
||||||
agentId: undefined,
|
agentId: undefined,
|
||||||
triggerMessage: '',
|
triggerMessage: '',
|
||||||
@ -501,7 +654,33 @@ function buildSavePayload() {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rest
|
return { ...rest, ...buildDeliveryPatch() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate the delivery helper fields into the persisted channelId +
|
||||||
|
* deliveryConfig contract. The channelId is sent as a string — the backend's
|
||||||
|
* Long coercion accepts textual IDs and Number() would truncate Snowflakes.
|
||||||
|
*/
|
||||||
|
function buildDeliveryPatch(): { channelId: string | null; deliveryConfig: CronJob['deliveryConfig'] } {
|
||||||
|
if (form.value.taskType === 'wiki_process' || !form.value.channelIdStr) {
|
||||||
|
return { channelId: null, deliveryConfig: null }
|
||||||
|
}
|
||||||
|
const prev = editing.value?.deliveryConfig
|
||||||
|
const sameChannel = !!editing.value
|
||||||
|
&& String(editing.value.channelId ?? '') === form.value.channelIdStr
|
||||||
|
const session = channelSessions.value.find(
|
||||||
|
(s) => s.conversationId === form.value.targetConversationId)
|
||||||
|
return {
|
||||||
|
channelId: form.value.channelIdStr,
|
||||||
|
deliveryConfig: {
|
||||||
|
targetId: session?.targetId ?? (sameChannel ? prev?.targetId ?? null : null),
|
||||||
|
userId: session ? session.senderId ?? null : (sameChannel ? prev?.userId ?? null : null),
|
||||||
|
threadId: sameChannel ? prev?.threadId ?? null : null,
|
||||||
|
accountId: sameChannel ? prev?.accountId ?? null : null,
|
||||||
|
suppressAgentReply: form.value.deliveryMode === 'silent',
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveJob() {
|
async function saveJob() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user