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