mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
sync: ChannelToolProvider SPI + node-local reconcile framework for channel-native tools
This commit is contained in:
parent
a9fa8e7fb1
commit
85d7ee23c4
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user