sync: ChannelToolProvider SPI + node-local reconcile framework for channel-native tools

This commit is contained in:
matevip 2026-05-20 12:18:51 +08:00
parent a9fa8e7fb1
commit 85d7ee23c4
14 changed files with 882 additions and 1 deletions

View File

@ -40,6 +40,12 @@ public class ChannelService {
* Spring's eager constructor wiring.
*/
private final ObjectProvider<FeishuClientFactory> feishuClientFactoryProvider;
/**
* Reconcile hook for channel-native tools. {@link ObjectProvider}
* for the same reasons as above service still works in test
* contexts that don't load the channel-tool subsystem.
*/
private final ObjectProvider<vip.mate.channel.tool.ChannelToolService> channelToolServiceProvider;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/**
@ -171,6 +177,19 @@ public class ChannelService {
factory.evict(channelId);
}
}
// Trigger an immediate channel-tool reconcile on this node so
// newly-enabled / -disabled / -reconfigured channels' tool sets
// align before the next reconcile tick. Other nodes catch up
// within ChannelToolService.RECONCILE_INTERVAL_SECONDS.
vip.mate.channel.tool.ChannelToolService cts =
channelToolServiceProvider.getIfAvailable();
if (cts != null) {
try {
cts.syncNow();
} catch (Exception e) {
log.debug("[ChannelService] syncNow() failed (non-fatal): {}", e.getMessage());
}
}
}
private String enrichWebChatConfig(String incomingConfigJson, String existingConfigJson) {

View File

@ -0,0 +1,64 @@
package vip.mate.channel.tool;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.function.Function;
/**
* Convenience {@link ToolCallback} that wraps a {@code (name,
* description, schema, handler)} tuple saves channel-tool providers
* from having to spell out the whole {@code ToolCallback} interface
* for every handler.
*
* <p>Identical in spirit to the skill-runtime wrapper but kept in the
* channel-tool domain so cross-domain refactors don't accidentally
* couple the two.
*/
public class ChannelToolCallback implements ToolCallback {
private final ToolDefinition definition;
private final Function<String, String> handler;
public ChannelToolCallback(String name,
String description,
String inputSchema,
Function<String, String> handler) {
this.definition = ToolDefinition.builder()
.name(name)
.description(description)
.inputSchema(inputSchema)
.build();
this.handler = handler;
}
@Override
public ToolDefinition getToolDefinition() {
return definition;
}
@Override
public String call(String toolInput) {
return handler.apply(toolInput);
}
@Override
public String call(String toolInput, ToolContext toolContext) {
return handler.apply(toolInput);
}
/**
* Build a new callback that is identical in every way except it
* carries the supplied {@code actualName}. Used by
* {@link ChannelToolService} to apply the {@code _c<channelId>}
* suffix without having the provider know about per-instance names.
*/
public ToolCallback renamed(String actualName) {
return new ChannelToolCallback(
actualName,
definition.description(),
definition.inputSchema(),
handler);
}
}

View File

@ -0,0 +1,45 @@
package vip.mate.channel.tool;
import java.util.Map;
/**
* Context handed to {@link ChannelToolProvider#createTools(ChannelToolContext)}
* when materialising tools for one specific {@code mate_channel} row.
*
* <p>{@link #channelId()} alone is enough for the provider to call its
* SDK-client factory (e.g. {@code FeishuClientFactory.client(channelId)});
* the remaining fields are convenience for handlers that want to
* default any "who should I attribute this to" or "which app are we
* acting on behalf of" parameters.
*
* @param channelId {@code mate_channel.id} the only required field
* @param channelName display name of the channel row (for log clarity)
* @param channelType {@code mate_channel.channel_type} matches
* {@link ChannelToolProvider#channelType()}
* @param agentId the {@code mate_agent.id} this channel routes
* inbound messages to, may be null when the channel
* is unbound
* @param config parsed {@code mate_channel.config_json} as a map
* (e.g. {@code app_id}, {@code app_secret},
* {@code domain}). Provided so handlers don't each
* re-parse the JSON.
*/
public record ChannelToolContext(
Long channelId,
String channelName,
String channelType,
Long agentId,
Map<String, Object> config) {
public ChannelToolContext {
if (channelId == null) {
throw new IllegalArgumentException("ChannelToolContext.channelId must not be null");
}
if (channelType == null || channelType.isBlank()) {
throw new IllegalArgumentException("ChannelToolContext.channelType must be non-blank");
}
if (config == null) {
config = Map.of();
}
}
}

View File

@ -0,0 +1,59 @@
package vip.mate.channel.tool;
/**
* Static description of one channel-native tool what
* {@link ChannelToolProvider#describeTools()} returns. Used by
* {@link ChannelToolService} to upsert {@code mate_tool} rows and by
* the admin UI to render the tool in the Channel group before any
* specific channel instance is materialised.
*
* <p>"Static" here means: no credentials needed, no network call
* pure metadata about a tool that this channel type COULD expose if
* connected. The per-instance materialisation (binding the tool to a
* specific {@code mate_channel} row + its SDK client) is the second
* step performed by {@link ChannelToolProvider#createTools}.
*
* @param name tool base name (e.g.
* {@code "feishu_calendar_create_event"}).
* Per-instance materialisation prefixes
* {@code _c<channelId>} to keep the actual
* registered tool name stable across CRUD
* see {@link ChannelToolService}.
* @param displayName human-readable label for UI
* @param description short description LLM uses to decide whether
* to call this tool. Long-form usage notes
* belong in a companion skill package.
* @param inputSchema JSON Schema string describing the tool's args
* @param mutating {@code true} for write operations. The tool
* row defaults to {@code enabled=false} and
* {@link ChannelToolService} seeds a DB rule
* so that calls hit Guard / approval before
* executing.
* @param enabledByDefault should the {@code mate_tool} row be created
* with {@code enabled=true}? Forced to
* {@code false} when {@link #mutating()}.
*/
public record ChannelToolDescriptor(
String name,
String displayName,
String description,
String inputSchema,
boolean mutating,
boolean enabledByDefault) {
public ChannelToolDescriptor {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("ChannelToolDescriptor.name must be non-blank");
}
if (description == null || description.isBlank()) {
throw new IllegalArgumentException("ChannelToolDescriptor.description must be non-blank");
}
if (inputSchema == null || inputSchema.isBlank()) {
throw new IllegalArgumentException("ChannelToolDescriptor.inputSchema must be non-blank");
}
// Mutating tools always start disabled regardless of caller intent.
if (mutating && enabledByDefault) {
enabledByDefault = false;
}
}
}

View File

@ -0,0 +1,54 @@
package vip.mate.channel.tool;
import org.springframework.ai.tool.ToolCallback;
import java.util.List;
/**
* SPI implemented once per channel type that exposes platform-native
* capabilities (e.g. Feishu calendar / docx, WeCom approval, DingTalk
* task) as Agent tools avoiding the need for each user to also
* configure a separate MCP server with duplicate credentials.
*
* <p>Implementations are plain Spring beans. {@link ChannelToolService}
* collects every {@code ChannelToolProvider} bean at startup, indexed
* by {@link #channelType()}; channel CRUD then reconciles per
* {@code mate_channel} row by calling {@link #describeTools()} (for
* the static catalog DB upsert) and {@link #createTools} (for the
* per-instance ToolCallback materialisation).
*
* <p>Implementations must NOT touch credentials in
* {@link #describeTools()} that path is invoked even when no channel
* is configured. Credential access is restricted to
* {@link #createTools(ChannelToolContext)} which receives an already-
* validated context.
*/
public interface ChannelToolProvider {
/**
* Channel type this provider serves matches
* {@code ChannelAdapter.getChannelType()} (e.g. {@code "feishu"}).
* Used by {@link ChannelToolService} for routing.
*/
String channelType();
/**
* Static catalogue of every tool this channel type COULD expose.
* Pure metadata no credentials, no I/O. Called once at startup
* to seed {@code mate_tool} rows for the admin UI.
*/
List<ChannelToolDescriptor> describeTools();
/**
* Materialise per-instance {@link ToolCallback} instances for the
* given channel row. Invoked by {@link ChannelToolService} during
* reconcile; the returned callbacks are registered into
* {@code ToolRegistry} via
* {@code ToolRegistry.registerPluginTool(...)}.
*
* <p>Each returned callback's {@code getToolDefinition().name()}
* must match a descriptor in {@link #describeTools()} (the service
* will rename to the per-instance actual name).
*/
List<ToolCallback> createTools(ChannelToolContext context);
}

View File

@ -0,0 +1,331 @@
package vip.mate.channel.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.model.ToolEntity;
import vip.mate.tool.repository.ToolMapper;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* Node-local reconciler that keeps every {@link ChannelToolProvider}'s
* tool callbacks registered in {@link ToolRegistry} in sync with the
* currently-enabled {@code mate_channel} rows whose type has a
* registered provider.
*
* <p>Three trigger points, all idempotent:
* <ol>
* <li><b>Startup</b> {@link ApplicationRunner} runs once per node
* after the Spring context is ready</li>
* <li><b>Periodic (60s)</b> picks up changes made by another node
* (config rotated, channel enabled / disabled), per RFC v3
* "reconcile-driven, not adapter-lifecycle-driven"</li>
* <li><b>Local CRUD</b> {@code ChannelService} calls
* {@link #syncNow()} after every channel mutation so the local
* node aligns instantly (other nodes wait at most one tick)</li>
* </ol>
*
* <p>Decoupled from {@code ChannelManager} / adapter lifecycle / leader
* election: tools are pure OpenAPI calls keyed by channelId and never
* need the WebSocket or the leader lease. RFC §4.1 makes this explicit.
*
* <p>Tool names get a stable {@code _c<channelId>} suffix
* unconditionally the channelId is immutable for the channel's
* lifetime, so the registered tool name never drifts even when other
* channels of the same type are added / deleted (RFC v5 §4.3).
*/
@Slf4j
@Component
public class ChannelToolService {
/** Periodic reconcile cadence — RFC default. */
static final long RECONCILE_INTERVAL_SECONDS = 60;
/** Suffix prefix appended before the channelId on the actual tool name. */
public static final String INSTANCE_SUFFIX_PREFIX = "_c";
private final List<ChannelToolProvider> providerBeans;
private final ChannelMapper channelMapper;
private final ToolMapper toolMapper;
private final ToolRegistry toolRegistry;
private final ObjectMapper objectMapper;
/** Indexed at startup: channelType → provider. */
private Map<String, ChannelToolProvider> providersByType = Map.of();
/** Node-local registration state: channelId → list of actual tool names registered. */
private final ConcurrentHashMap<Long, List<String>> registered = new ConcurrentHashMap<>();
/** channelId → the {@code update_time} value last seen on reconcile (config-change detection). */
private final ConcurrentHashMap<Long, LocalDateTime> registeredUpdateTime = new ConcurrentHashMap<>();
/** Daemon scheduler for the periodic tick. */
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "channel-tool-reconcile");
t.setDaemon(true);
return t;
});
public ChannelToolService(List<ChannelToolProvider> providerBeans,
ChannelMapper channelMapper,
ToolMapper toolMapper,
ToolRegistry toolRegistry,
ObjectMapper objectMapper) {
this.providerBeans = providerBeans != null ? providerBeans : List.of();
this.channelMapper = channelMapper;
this.toolMapper = toolMapper;
this.toolRegistry = toolRegistry;
this.objectMapper = objectMapper;
}
@PostConstruct
void index() {
providersByType = providerBeans.stream()
.collect(Collectors.toUnmodifiableMap(
ChannelToolProvider::channelType,
p -> p,
(a, b) -> {
log.warn("[channel-tool] Duplicate ChannelToolProvider for type {} ({} vs {}); keeping first",
a.channelType(), a.getClass().getSimpleName(), b.getClass().getSimpleName());
return a;
}));
if (providersByType.isEmpty()) {
log.info("[channel-tool] No ChannelToolProvider beans wired — reconcile loop will be a no-op");
} else {
log.info("[channel-tool] Registered providers: {}", providersByType.keySet());
}
// Schedule the periodic tick. Startup reconcile runs separately
// via the ApplicationRunner bean below so the Spring context
// is fully ready (including any provider's transitive deps).
scheduler.scheduleAtFixedRate(this::tickQuietly,
RECONCILE_INTERVAL_SECONDS, RECONCILE_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
/**
* Initial reconcile after Spring is fully ready. Spring runs every
* {@link ApplicationRunner} bean after all {@code @PostConstruct}
* hooks but before serving traffic.
*/
@org.springframework.context.annotation.Bean
ApplicationRunner channelToolStartupReconcile() {
return args -> {
log.info("[channel-tool] Startup reconcile");
tickQuietly();
};
}
@PreDestroy
void shutdown() {
scheduler.shutdown();
}
/**
* Trigger an immediate reconcile from this node. Called by
* {@code ChannelService} after channel CRUD so the local node
* aligns within the same request; other nodes catch up at the
* next periodic tick.
*/
public void syncNow() {
tickQuietly();
}
private void tickQuietly() {
try {
reconcile();
} catch (Exception e) {
log.warn("[channel-tool] reconcile failed (will retry next tick): {}", e.getMessage(), e);
}
}
/**
* Diff the local registration against the expected set (enabled
* channels of types we have providers for) and apply just the
* deltas unregister stale, register new, rebuild on config change.
* Idempotent.
*/
synchronized void reconcile() {
if (providersByType.isEmpty()) {
return;
}
List<ChannelEntity> enabled = channelMapper.selectList(
new LambdaQueryWrapper<ChannelEntity>().eq(ChannelEntity::getEnabled, true));
Map<Long, ChannelEntity> desired = new HashMap<>();
for (ChannelEntity ch : enabled) {
if (providersByType.containsKey(ch.getChannelType())) {
desired.put(ch.getId(), ch);
}
}
// 1. Unregister channels no longer in the desired set
for (Long goneId : new ArrayList<>(registered.keySet())) {
if (!desired.containsKey(goneId)) {
unregisterChannel(goneId);
deleteToolRows(goneId);
}
}
// 2. Register / re-register changed or new channels
for (ChannelEntity ch : desired.values()) {
LocalDateTime seen = registeredUpdateTime.get(ch.getId());
if (seen != null && seen.equals(ch.getUpdateTime())) {
continue; // already registered + config unchanged
}
// Config changed drop the stale callbacks before re-registering
// so handler closures don't keep stale config.
unregisterChannel(ch.getId());
try {
registerChannel(ch);
} catch (Exception e) {
log.warn("[channel-tool] register failed for channel {} ({}): {}",
ch.getId(), ch.getChannelType(), e.getMessage());
}
}
}
private void registerChannel(ChannelEntity ch) {
ChannelToolProvider provider = providersByType.get(ch.getChannelType());
if (provider == null) return;
List<ChannelToolDescriptor> descriptors = provider.describeTools();
if (descriptors == null || descriptors.isEmpty()) {
log.debug("[channel-tool] Provider {} returned no descriptors; skipping channel {}",
provider.channelType(), ch.getId());
return;
}
Map<String, String> nameMap = upsertToolRows(ch, descriptors);
ChannelToolContext context = new ChannelToolContext(
ch.getId(), ch.getName(), ch.getChannelType(), ch.getAgentId(),
parseConfig(ch.getConfigJson()));
List<ToolCallback> callbacks;
try {
callbacks = provider.createTools(context);
} catch (Exception e) {
log.warn("[channel-tool] createTools failed for channel {} ({}): {}",
ch.getId(), provider.channelType(), e.getMessage());
return;
}
if (callbacks == null) return;
List<String> actualNames = new ArrayList<>();
for (ToolCallback cb : callbacks) {
String baseName = cb.getToolDefinition().name();
String actualName = nameMap.getOrDefault(baseName, baseName + INSTANCE_SUFFIX_PREFIX + ch.getId());
ToolCallback renamed = (cb instanceof ChannelToolCallback ctc)
? ctc.renamed(actualName)
: cb; // legacy callback (any other ToolCallback impl) registers under its own name
toolRegistry.registerPluginTool(renamed, () -> isToolRowEnabled(actualName));
actualNames.add(actualName);
}
registered.put(ch.getId(), actualNames);
registeredUpdateTime.put(ch.getId(), ch.getUpdateTime());
log.info("[channel-tool] Registered {} tool(s) for channel {} ({})",
actualNames.size(), ch.getId(), ch.getChannelType());
}
private void unregisterChannel(Long channelId) {
registeredUpdateTime.remove(channelId);
List<String> names = registered.remove(channelId);
if (names != null && !names.isEmpty()) {
names.forEach(toolRegistry::unregisterPluginTool);
log.info("[channel-tool] Unregistered {} tool(s) for channel {}", names.size(), channelId);
}
}
/**
* Upsert {@code mate_tool} rows for each descriptor; returns the
* baseName actual-name map the caller uses when renaming the
* provider's callbacks. DB-level uniqueness on {@code mate_tool.name}
* (introduced in V100) makes this safe under concurrent reconcile
* from multiple nodes.
*/
private Map<String, String> upsertToolRows(ChannelEntity ch, List<ChannelToolDescriptor> descriptors) {
Map<String, String> nameMap = new HashMap<>();
for (ChannelToolDescriptor d : descriptors) {
String actualName = d.name() + INSTANCE_SUFFIX_PREFIX + ch.getId();
nameMap.put(d.name(), actualName);
ToolEntity existing = toolMapper.selectOne(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getName, actualName));
if (existing == null) {
ToolEntity row = new ToolEntity();
row.setName(actualName);
row.setDisplayName(d.displayName() + " (" + ch.getName() + ")");
row.setDescription(d.description());
row.setToolType("channel");
row.setParamsSchema(d.inputSchema());
row.setEnabled(d.enabledByDefault());
row.setBuiltin(false);
row.setChannelId(ch.getId());
try {
toolMapper.insert(row);
} catch (org.springframework.dao.DuplicateKeyException race) {
// Another node beat us to the insert that's the
// whole point of the uk_mate_tool_name unique index.
log.debug("[channel-tool] tool row {} already inserted by another node", actualName);
}
} else {
// Refresh metadata that may have evolved between releases
// (description rewrites, schema updates) without clobbering
// the user's enable / disable preference.
boolean dirty = false;
String newDisplay = d.displayName() + " (" + ch.getName() + ")";
if (!newDisplay.equals(existing.getDisplayName())) { existing.setDisplayName(newDisplay); dirty = true; }
if (!d.description().equals(existing.getDescription())) { existing.setDescription(d.description()); dirty = true; }
if (!d.inputSchema().equals(existing.getParamsSchema())) { existing.setParamsSchema(d.inputSchema()); dirty = true; }
if (existing.getChannelId() == null) { existing.setChannelId(ch.getId()); dirty = true; }
if (!"channel".equals(existing.getToolType())) { existing.setToolType("channel"); dirty = true; }
if (dirty) toolMapper.updateById(existing);
}
}
return nameMap;
}
private void deleteToolRows(Long channelId) {
int deleted = toolMapper.delete(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getChannelId, channelId));
if (deleted > 0) {
log.info("[channel-tool] Deleted {} mate_tool row(s) for channel {}", deleted, channelId);
}
}
private boolean isToolRowEnabled(String actualName) {
ToolEntity row = toolMapper.selectOne(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getName, actualName));
return row != null && Boolean.TRUE.equals(row.getEnabled());
}
private Map<String, Object> parseConfig(String configJson) {
if (configJson == null || configJson.isBlank()) return Map.of();
try {
return objectMapper.readValue(configJson, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[channel-tool] Failed to parse configJson: {}", e.getMessage());
return Map.of();
}
}
// ---- test inspection ----
int registeredChannelCount() { return registered.size(); }
Map<String, ChannelToolProvider> providersByTypeForTest() { return providersByType; }
}

View File

@ -93,4 +93,43 @@ public class AvailableToolDTO {
.unavailableReason(null)
.build();
}
/**
* Channel-native tool exposed by a
* {@link vip.mate.channel.tool.ChannelToolProvider} and registered
* by {@code ChannelToolService}. Grouped per owning channel so the
* picker shows "Channel · {channelName}" rather than mixing them
* into the generic Built-in bucket.
*/
public static AvailableToolDTO fromChannel(ToolEntity t) {
// displayName format set by ChannelToolService is "{base} ({channelName})";
// the channel name is what we surface in the picker group label.
String channelName = extractChannelName(t.getDisplayName());
String groupLabel = channelName.isEmpty() ? "Channel" : "Channel · " + channelName;
String groupKey = t.getChannelId() != null ? "channel:" + t.getChannelId() : "channel";
return AvailableToolDTO.builder()
.rowId("channel#" + t.getName())
.source("channel")
.providerId(t.getChannelId())
.providerName(channelName)
.name(t.getName())
.rawName(t.getName())
.description(t.getDescription() != null ? t.getDescription() : "")
.group(groupLabel)
.groupId(groupKey)
.stale(false)
.available(true)
.unavailableReason(null)
.build();
}
private static String extractChannelName(String displayName) {
if (displayName == null) return "";
int open = displayName.lastIndexOf('(');
int close = displayName.lastIndexOf(')');
if (open > 0 && close > open) {
return displayName.substring(open + 1, close).trim();
}
return "";
}
}

View File

@ -49,6 +49,16 @@ public class ToolEntity {
/** 是否系统内置 */
private Boolean builtin;
/**
* For channel-native tools ({@code tool_type="channel"}), the
* {@code mate_channel.id} that materialised this tool. {@link
* vip.mate.channel.tool.ChannelToolService} uses this column to
* delete a channel's tool rows when its config row is removed and
* to detect "config changed → rebuild tool" cases. Null for
* built-in / MCP / skill tools.
*/
private Long channelId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -63,7 +63,14 @@ public class AvailableToolService {
private void appendBuiltinTools(List<AvailableToolDTO> out) {
for (ToolEntity t : toolService.listEnabledTools()) {
if (t == null || t.getName() == null || t.getName().isBlank()) continue;
out.add(AvailableToolDTO.fromBuiltin(t));
// Dispatch by toolType so channel-native tools (registered by
// ChannelToolService) land in their own picker group rather
// than getting lumped under "Built-in".
if ("channel".equals(t.getToolType())) {
out.add(AvailableToolDTO.fromChannel(t));
} else {
out.add(AvailableToolDTO.fromBuiltin(t));
}
}
}

View File

@ -0,0 +1,36 @@
-- V100__channel_tool_support.sql (H2 dialect)
--
-- Schema support for channel-native tools (e.g. Feishu calendar / docx
-- via the ChannelToolProvider SPI). Adds:
-- 1. mate_tool.channel_id — owning channel id for tool_type='channel'
-- 2. idx_mate_tool_channel — fast lookup by owning channel
-- 3. uk_mate_tool_name — name uniqueness so multi-node reconcile
-- can do DB-atomic upsert (MERGE INTO) without producing dupes
--
-- H2 supports CREATE INDEX / ALTER TABLE ... ADD COLUMN IF NOT EXISTS
-- natively, so the migration is just three statements.
ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS channel_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_mate_tool_channel ON mate_tool(channel_id);
-- Deduplicate any pre-existing same-name rows BEFORE adding the unique
-- index. Keep the most useful row per name: deleted=0 first, then most
-- recently updated, then largest id tie-break. Normal databases have
-- name already unique so this is a no-op; tail-noise from migration
-- aborts in earlier dev iterations gets cleaned out the same way.
DELETE FROM mate_tool WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY name
ORDER BY
CASE WHEN deleted = 0 THEN 0 ELSE 1 END,
update_time DESC NULLS LAST,
id DESC
) AS rn
FROM mate_tool
) ranked
WHERE rn > 1
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_mate_tool_name ON mate_tool(name);

View File

@ -0,0 +1,60 @@
-- V100__channel_tool_support.sql (MySQL dialect)
--
-- Mirror of the H2 V100, adapted for MySQL 8.0 — no "IF NOT EXISTS"
-- on ADD COLUMN / CREATE INDEX, so we guard with INFORMATION_SCHEMA
-- + prepared-statement so this migration is idempotent (essential
-- for desktop installs that re-apply migrations after upgrade).
-- 1. Add channel_id column if missing
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_tool'
AND COLUMN_NAME = 'channel_id'
);
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_tool ADD COLUMN channel_id BIGINT NULL COMMENT ''owning mate_channel.id for tool_type=channel''',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
-- 2. Add channel-id index if missing
SET @idx_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_tool'
AND INDEX_NAME = 'idx_mate_tool_channel'
);
SET @stmt := IF(@idx_exists = 0,
'CREATE INDEX idx_mate_tool_channel ON mate_tool(channel_id)',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
-- 3. Deduplicate same-name rows before adding the unique index — keep
-- deleted=0 first, then most recently updated, then largest id.
DELETE FROM mate_tool
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY name
ORDER BY
CASE WHEN deleted = 0 THEN 0 ELSE 1 END,
update_time DESC,
id DESC
) AS rn
FROM mate_tool
) ranked
WHERE rn > 1
);
-- 4. Add unique index on name if missing
SET @uk_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_tool'
AND INDEX_NAME = 'uk_mate_tool_name'
);
SET @stmt := IF(@uk_exists = 0,
'CREATE UNIQUE INDEX uk_mate_tool_name ON mate_tool(name)',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;

View File

@ -0,0 +1,52 @@
package vip.mate.channel.tool;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.model.ToolEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Pin the {@code source="channel"} classification the picker UI
* shows "Channel · {channelName}" only when this branch fires; a
* regression here silently lumps channel tools into the Built-in
* bucket.
*/
class AvailableToolChannelSourceTest {
@Test
@DisplayName("fromChannel sets source=channel + group label includes the channel name")
void fromChannelClassifiesAsChannel() {
ToolEntity row = new ToolEntity();
row.setName("feishu_calendar_list_events_c99");
row.setDisplayName("List calendar events (My Feishu Bot)");
row.setDescription("List events on the user's Feishu calendar");
row.setToolType("channel");
row.setChannelId(99L);
row.setEnabled(true);
AvailableToolDTO dto = AvailableToolDTO.fromChannel(row);
assertEquals("channel", dto.getSource());
assertEquals(99L, dto.getProviderId());
assertEquals("My Feishu Bot", dto.getProviderName());
assertEquals("Channel · My Feishu Bot", dto.getGroup());
assertEquals("channel:99", dto.getGroupId());
assertEquals("feishu_calendar_list_events_c99", dto.getName());
}
@Test
@DisplayName("fromChannel handles missing channel name suffix gracefully (no NPE, default group)")
void fromChannelHandlesMissingChannelName() {
ToolEntity row = new ToolEntity();
row.setName("feishu_x");
row.setDisplayName("display without parens");
row.setDescription("desc");
row.setToolType("channel");
row.setChannelId(null);
AvailableToolDTO dto = AvailableToolDTO.fromChannel(row);
assertEquals("channel", dto.getSource());
assertEquals("Channel", dto.getGroup());
assertEquals("channel", dto.getGroupId());
}
}

View File

@ -0,0 +1,39 @@
package vip.mate.channel.tool;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
/**
* Pin the {@code renamed(actualName)} contract {@link ChannelToolService}
* relies on it to apply the {@code _c<channelId>} suffix without
* forcing providers to know per-instance names.
*/
class ChannelToolCallbackTest {
@Test
@DisplayName("call delegates to the supplied handler")
void callDelegates() {
ChannelToolCallback cb = new ChannelToolCallback(
"test_tool", "test description", "{\"type\":\"object\"}",
in -> "echo:" + in);
assertEquals("echo:hello", cb.call("hello"));
assertEquals("echo:world", cb.call("world", null));
}
@Test
@DisplayName("renamed returns a new callback carrying the same handler + description + schema")
void renamedKeepsBehaviorWithNewName() {
ChannelToolCallback original = new ChannelToolCallback(
"feishu_doc_read", "read doc", "{}", in -> "READ:" + in);
ToolCallback renamed = original.renamed("feishu_doc_read_c42");
assertNotSame(original, renamed);
assertEquals("feishu_doc_read_c42", renamed.getToolDefinition().name());
assertEquals("read doc", renamed.getToolDefinition().description());
assertEquals("READ:abc", renamed.call("abc"));
}
}

View File

@ -0,0 +1,66 @@
package vip.mate.channel.tool;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pin the descriptor's compact-record validation:
* <ul>
* <li>blank name / description / schema IllegalArgumentException</li>
* <li>mutating implies enabledByDefault=false regardless of caller intent</li>
* </ul>
*/
class ChannelToolDescriptorTest {
@Test
@DisplayName("non-mutating tool keeps the caller's enabledByDefault")
void readToolHonorsEnabledByDefault() {
ChannelToolDescriptor enabled = new ChannelToolDescriptor(
"feishu_calendar_list", "List calendars", "List user's Feishu calendars",
"{\"type\":\"object\"}", false, true);
assertTrue(enabled.enabledByDefault());
ChannelToolDescriptor disabled = new ChannelToolDescriptor(
"feishu_calendar_list", "List", "List", "{\"type\":\"object\"}", false, false);
assertFalse(disabled.enabledByDefault());
}
@Test
@DisplayName("mutating tool always lands disabled even when caller asks for enabled")
void mutatingForcesDisabledDefault() {
ChannelToolDescriptor d = new ChannelToolDescriptor(
"feishu_calendar_create_event", "Create event",
"Creates an event on the user's calendar",
"{\"type\":\"object\"}", true, true);
assertFalse(d.enabledByDefault(),
"mutating tools must start disabled — write surfaces should require explicit opt-in");
assertTrue(d.mutating());
}
@Test
@DisplayName("blank name / description / inputSchema rejected at construction")
void blankFieldsRejected() {
assertThrows(IllegalArgumentException.class,
() -> new ChannelToolDescriptor("", "x", "x", "{}", false, true));
assertThrows(IllegalArgumentException.class,
() -> new ChannelToolDescriptor("x", "x", "", "{}", false, true));
assertThrows(IllegalArgumentException.class,
() -> new ChannelToolDescriptor("x", "x", "x", " ", false, true));
}
@Test
@DisplayName("equals / hashCode are record-default (value semantics)")
void valueSemantics() {
ChannelToolDescriptor a = new ChannelToolDescriptor(
"a", "A", "desc", "{}", false, true);
ChannelToolDescriptor b = new ChannelToolDescriptor(
"a", "A", "desc", "{}", false, true);
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
}
}