diff --git a/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java b/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java index a2c887a8..51010b23 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java @@ -40,6 +40,12 @@ public class ChannelService { * Spring's eager constructor wiring. */ private final ObjectProvider 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 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) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolCallback.java b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolCallback.java new file mode 100644 index 00000000..65f18482 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolCallback.java @@ -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. + * + *

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 handler; + + public ChannelToolCallback(String name, + String description, + String inputSchema, + Function 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} + * suffix without having the provider know about per-instance names. + */ + public ToolCallback renamed(String actualName) { + return new ChannelToolCallback( + actualName, + definition.description(), + definition.inputSchema(), + handler); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolContext.java b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolContext.java new file mode 100644 index 00000000..5222628e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolContext.java @@ -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. + * + *

{@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 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(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolDescriptor.java b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolDescriptor.java new file mode 100644 index 00000000..20965809 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolDescriptor.java @@ -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. + * + *

"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} 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; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolProvider.java b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolProvider.java new file mode 100644 index 00000000..10eed429 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolProvider.java @@ -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. + * + *

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

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 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(...)}. + * + *

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 createTools(ChannelToolContext context); +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java new file mode 100644 index 00000000..2a9a2ad2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java @@ -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. + * + *

Three trigger points, all idempotent: + *

    + *
  1. Startup — {@link ApplicationRunner} runs once per node + * after the Spring context is ready
  2. + *
  3. Periodic (60s) — picks up changes made by another node + * (config rotated, channel enabled / disabled), per RFC v3 + * "reconcile-driven, not adapter-lifecycle-driven"
  4. + *
  5. Local CRUD — {@code ChannelService} calls + * {@link #syncNow()} after every channel mutation so the local + * node aligns instantly (other nodes wait at most one tick)
  6. + *
+ * + *

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

Tool names get a stable {@code _c} 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 providerBeans; + private final ChannelMapper channelMapper; + private final ToolMapper toolMapper; + private final ToolRegistry toolRegistry; + private final ObjectMapper objectMapper; + + /** Indexed at startup: channelType → provider. */ + private Map providersByType = Map.of(); + + /** Node-local registration state: channelId → list of actual tool names registered. */ + private final ConcurrentHashMap> registered = new ConcurrentHashMap<>(); + + /** channelId → the {@code update_time} value last seen on reconcile (config-change detection). */ + private final ConcurrentHashMap 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 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 enabled = channelMapper.selectList( + new LambdaQueryWrapper().eq(ChannelEntity::getEnabled, true)); + Map 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 descriptors = provider.describeTools(); + if (descriptors == null || descriptors.isEmpty()) { + log.debug("[channel-tool] Provider {} returned no descriptors; skipping channel {}", + provider.channelType(), ch.getId()); + return; + } + Map nameMap = upsertToolRows(ch, descriptors); + + ChannelToolContext context = new ChannelToolContext( + ch.getId(), ch.getName(), ch.getChannelType(), ch.getAgentId(), + parseConfig(ch.getConfigJson())); + List 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 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 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 upsertToolRows(ChannelEntity ch, List descriptors) { + Map 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().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().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().eq(ToolEntity::getName, actualName)); + return row != null && Boolean.TRUE.equals(row.getEnabled()); + } + + private Map 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 providersByTypeForTest() { return providersByType; } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java index 5eb1dc7a..91eaa6c2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java @@ -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 ""; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java index 27f08e5c..e16a6643 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/ToolEntity.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java index a39a68cf..d21cbd74 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java @@ -63,7 +63,14 @@ public class AvailableToolService { private void appendBuiltinTools(List 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)); + } } } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V119__channel_tool_support.sql b/mateclaw-server/src/main/resources/db/migration/h2/V119__channel_tool_support.sql new file mode 100644 index 00000000..dbbfc1c3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V119__channel_tool_support.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V119__channel_tool_support.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V119__channel_tool_support.sql new file mode 100644 index 00000000..3f40c35c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V119__channel_tool_support.sql @@ -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; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/tool/AvailableToolChannelSourceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/tool/AvailableToolChannelSourceTest.java new file mode 100644 index 00000000..3f3705ea --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/tool/AvailableToolChannelSourceTest.java @@ -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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolCallbackTest.java b/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolCallbackTest.java new file mode 100644 index 00000000..c85d4270 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolCallbackTest.java @@ -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} 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")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolDescriptorTest.java b/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolDescriptorTest.java new file mode 100644 index 00000000..d62bea35 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/tool/ChannelToolDescriptorTest.java @@ -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: + *

+ */ +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()); + } +}