diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index 5d30c771..c1c15ea4 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -233,9 +233,7 @@ public class ChannelManager { private static final long FOLLOWER_RETRY_INTERVAL_SECONDS = 30L; /** 支持的渠道类型 */ - private static final Set SUPPORTED_TYPES = Set.of( - "web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin", "slack", "webchat" - ); + private static final Set SUPPORTED_TYPES = ChannelTypes.SUPPORTED; /** * 应用启动完成后自动加载并启动所有已启用的渠道。 diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelTypes.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelTypes.java new file mode 100644 index 00000000..2580989e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelTypes.java @@ -0,0 +1,19 @@ +package vip.mate.channel; + +import java.util.Set; +import vip.mate.exception.MateClawException; + +/** Channel types with a runtime adapter. Validate before persisting configuration. */ +public final class ChannelTypes { + public static final Set SUPPORTED = Set.of( + "web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin", "slack", "webchat"); + + private ChannelTypes() {} + + public static void requireSupported(String type) { + if (type == null || !SUPPORTED.contains(type)) { + throw new MateClawException("err.channel.type_unsupported", 400, + "Unsupported channel type: " + type); + } + } +} 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 51010b23..3725b931 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 @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; import vip.mate.channel.feishu.FeishuClientFactory; +import vip.mate.channel.ChannelTypes; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.repository.ChannelMapper; import vip.mate.exception.MateClawException; @@ -117,6 +118,7 @@ public class ChannelService { if (channel.getChannelType() == null || channel.getChannelType().isBlank()) { throw new MateClawException("err.channel.type_required", "渠道类型不能为空"); } + ChannelTypes.requireSupported(channel.getChannelType()); if (channel.getEnabled() == null) { channel.setEnabled(false); } @@ -133,6 +135,11 @@ public class ChannelService { */ public ChannelEntity updateChannel(ChannelEntity channel) { ChannelEntity existing = getChannel(channel.getId()); + if (channel.getChannelType() != null) { + ChannelTypes.requireSupported(channel.getChannelType()); + } else if (Boolean.TRUE.equals(channel.getEnabled())) { + ChannelTypes.requireSupported(existing.getChannelType()); + } if ("webchat".equals(channel.getChannelType())) { channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson())); } @@ -157,6 +164,9 @@ public class ChannelService { */ public ChannelEntity toggleChannel(Long id, boolean enabled) { ChannelEntity channel = getChannel(id); + if (enabled) { + ChannelTypes.requireSupported(channel.getChannelType()); + } channel.setEnabled(enabled); channelMapper.updateById(channel); invalidateChannelCaches(id, channel.getChannelType()); diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index b9b95a06..f3e21e58 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -338,3 +338,5 @@ tool.send_file.success={0} \u5df2\u53d1\u9001\uff1a[{0}]({1})\uff08\u94fe\u63a5 err.settings.storage_root_invalid=\u5b58\u50a8\u8def\u5f84\u65e0\u6548 err.settings.storage_root_not_absolute=\u5b58\u50a8\u8def\u5f84\u5fc5\u987b\u4e3a\u7edd\u5bf9\u8def\u5f84 err.settings.storage_root_create_failed=\u65e0\u6cd5\u521b\u5efa\u5b58\u50a8\u76ee\u5f55\uff0c\u8bf7\u68c0\u67e5\u8def\u5f84\u4e0e\u5199\u5165\u6743\u9650 + +err.channel.type_unsupported=\u4e0d\u652f\u6301\u6b64\u6e20\u9053\u7c7b\u578b\uff0c\u8bf7\u4f7f\u7528\u53d7\u652f\u6301\u7684\u6e20\u9053\uff1b\u901a\u7528 HTTP \u63a5\u5165\u8bf7\u9009\u62e9 Web / API \u63a5\u5165\u3002 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 70cea359..065bdc0c 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -345,3 +345,5 @@ tool.send_file.success={0} sent: [{0}]({1}) (link valid for 10 minutes).\nIMPORT err.settings.storage_root_invalid=Invalid storage path err.settings.storage_root_not_absolute=Storage path must be an absolute path err.settings.storage_root_create_failed=Cannot create the storage directory; check the path and write permission + +err.channel.type_unsupported=Unsupported channel type. For generic HTTP access, use Web / API access. diff --git a/mateclaw-server/src/test/java/vip/mate/channel/service/ChannelTypeValidationTest.java b/mateclaw-server/src/test/java/vip/mate/channel/service/ChannelTypeValidationTest.java new file mode 100644 index 00000000..01246822 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/service/ChannelTypeValidationTest.java @@ -0,0 +1,108 @@ +package vip.mate.channel.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.ObjectProvider; +import vip.mate.channel.feishu.FeishuClientFactory; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.repository.ChannelMapper; +import vip.mate.channel.tool.ChannelToolService; +import vip.mate.exception.MateClawException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ChannelTypeValidationTest { + @Mock ChannelMapper mapper; + @Mock ObjectProvider feishu; + @Mock ObjectProvider tools; + private ChannelService service; + + @BeforeEach + void setUp() { + service = new ChannelService(mapper, new ObjectMapper(), feishu, tools); + } + + private ChannelEntity channel(String type) { + ChannelEntity channel = new ChannelEntity(); + channel.setName("Issue 654 regression"); + channel.setChannelType(type); + channel.setEnabled(true); + return channel; + } + + @ParameterizedTest + @ValueSource(strings = {"webhook", "unknown", "WEB", " web "}) + void rejectsUnsupportedCreateBeforeInsert(String type) { + MateClawException error = assertThrows(MateClawException.class, + () -> service.createChannel(channel(type))); + assertEquals(400, error.getCode()); + assertEquals("err.channel.type_unsupported", error.getMsgKey()); + verifyNoInteractions(mapper); + } + + @Test + void disabledWebhookCannotBypassValidation() { + ChannelEntity channel = channel("webhook"); + channel.setEnabled(false); + assertThrows(MateClawException.class, () -> service.createChannel(channel)); + verifyNoInteractions(mapper); + } + + @ParameterizedTest + @ValueSource(strings = {"web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin", "slack", "webchat"}) + void supportedChannelsCanStillBeCreated(String type) { + ChannelEntity channel = channel(type); + assertSame(channel, service.createChannel(channel)); + verify(mapper).insert(channel); + if ("webchat".equals(type)) { + assertTrue(channel.getConfigJson().contains("api_key")); + } + } + + @Test + void rejectsChangingExistingChannelToWebhookBeforeUpdate() { + when(mapper.selectById(1L)).thenReturn(channel("web")); + ChannelEntity update = channel("webhook"); + update.setId(1L); + assertThrows(MateClawException.class, () -> service.updateChannel(update)); + verify(mapper, never()).updateById(any(ChannelEntity.class)); + } + + @Test + void rejectsEnablingLegacyWebhookBeforeUpdate() { + ChannelEntity legacy = channel("webhook"); + legacy.setEnabled(false); + when(mapper.selectById(1L)).thenReturn(legacy); + assertThrows(MateClawException.class, () -> service.toggleChannel(1L, true)); + assertFalse(legacy.getEnabled()); + verify(mapper, never()).updateById(any(ChannelEntity.class)); + } + + @Test + void partialUpdateCannotEnableLegacyWebhook() { + when(mapper.selectById(1L)).thenReturn(channel("webhook")); + ChannelEntity update = new ChannelEntity(); + update.setId(1L); + update.setEnabled(true); + assertThrows(MateClawException.class, () -> service.updateChannel(update)); + verify(mapper, never()).updateById(any(ChannelEntity.class)); + } + + @Test + void legacyWebhookCanStillBeDisabledAndDeleted() { + ChannelEntity legacy = channel("webhook"); + when(mapper.selectById(1L)).thenReturn(legacy); + assertFalse(service.toggleChannel(1L, false).getEnabled()); + service.deleteChannel(1L); + verify(mapper).updateById(legacy); + verify(mapper).deleteById(1L); + } +} diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index 740470ee..7986560e 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -30,7 +30,6 @@ -
diff --git a/mateclaw-ui/src/components/channels/ChannelTypePicker.vue b/mateclaw-ui/src/components/channels/ChannelTypePicker.vue index a02e0ca6..96be5e1d 100644 --- a/mateclaw-ui/src/components/channels/ChannelTypePicker.vue +++ b/mateclaw-ui/src/components/channels/ChannelTypePicker.vue @@ -57,7 +57,7 @@ const { t } = useI18n() const groups = [ { key: 'im', types: ['telegram', 'discord', 'slack', 'qq'] }, { key: 'enterprise', types: ['wecom', 'weixin', 'feishu', 'dingtalk'] }, - { key: 'web', types: ['web', 'webchat', 'webhook'] }, + { key: 'web', types: ['web', 'webchat'] }, ] function pick(type: string) { diff --git a/mateclaw-ui/src/components/channels/__tests__/ChannelTypePicker.test.ts b/mateclaw-ui/src/components/channels/__tests__/ChannelTypePicker.test.ts new file mode 100644 index 00000000..bf8712a3 --- /dev/null +++ b/mateclaw-ui/src/components/channels/__tests__/ChannelTypePicker.test.ts @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createI18n } from 'vue-i18n' +import { expect, it, vi } from 'vitest' +import ChannelTypePicker from '../ChannelTypePicker.vue' + +it('offers supported HTTP access without the unimplemented webhook channel', () => { + const host = document.createElement('div') + const pick = vi.fn() + const app = createApp(ChannelTypePicker, { modelValue: true, onPick: pick }) + app.use(createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false, messages: { en: {} } })) + app.mount(host) + try { + expect(host.querySelector('img[alt="webhook"]')).toBeNull() + expect(host.querySelector('img[alt="web"]')).not.toBeNull() + const webchat = host.querySelector('img[alt="webchat"]') + expect(webchat).not.toBeNull() + webchat!.closest('button')!.click() + expect(pick).toHaveBeenCalledWith('webchat') + } finally { + app.unmount() + } +}) diff --git a/mateclaw-ui/src/views/Channels.vue b/mateclaw-ui/src/views/Channels.vue index 864a6658..33223faa 100644 --- a/mateclaw-ui/src/views/Channels.vue +++ b/mateclaw-ui/src/views/Channels.vue @@ -204,7 +204,7 @@ const wizardType = ref('') // existing channels (where 3-step would feel like ceremony). const WIZARD_TYPES = new Set([ 'telegram', 'discord', 'slack', 'qq', - 'web', 'webchat', 'webhook', + 'web', 'webchat', 'wecom', 'weixin', 'dingtalk', 'feishu', ])