From 7f9d6e4aba06d79a6608ebcb3dae647c922d65f7 Mon Sep 17 00:00:00 2001 From: BBQ <35603386+HoneyBBQ@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:06:13 +0800 Subject: [PATCH] feat(acl): redesign ACL with conversation scope selector (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - New subject kinds: all / channel_identity / channel_type - Source scope fields on bot_acl_rules: source_channel, source_conversation_type, source_conversation_id, source_thread_id - Fix source_scope_check constraint: resolve source_channel server-side (channel_type → subject_channel_type; channel_identity → DB lookup) - Add GET /bots/:id/acl/channel-types/:type/conversations to list observed conversations by platform type - ListObservedConversations: include private/DM chats, normalise conversation_type; COALESCE(name, handle) for display name - enrichConversationAvatar: persist entry.Name → conversation_name (keeps Telegram group titles current on every message) - Unify Priority type to int32 across Go types to match DB INTEGER; remove all int/int32 casts in service layer - Fix duplicate nil guard in Evaluate; drop dead SourceScope.Channel field - Migration 0048_acl_redesign Frontend - Drag-and-drop rule priority reordering (SortableJS/useSortable); fix reorder: compute new order from oldIndex/newIndex directly, not from the array (which useSortable syncs after onEnd) - Conversation scope selector: searchable popover backed by observed conversations (by identity or platform type); collapsible manual-ID fallback - Display: name as primary label, stable channel·type·id always shown as subtitle for verification - bot-terminal: accessibility fix on close-tab button (keyboard events) - i18n: drag-to-reorder, conversation source, manual IDs (en/zh) Tests: update fakeChatACL to Evaluate interface; fix SourceScope literals. SDK/spec regenerated. --- apps/web/package.json | 2 + apps/web/src/i18n/locales/en.json | 92 +- apps/web/src/i18n/locales/zh.json | 90 +- .../src/pages/bots/components/bot-access.vue | 2078 ++++++++--------- .../pages/bots/components/bot-terminal.vue | 10 +- db/migrations/0048_acl_redesign.down.sql | 57 + db/migrations/0048_acl_redesign.up.sql | 80 + db/queries/acl.sql | 192 +- db/queries/messages.sql | 50 +- internal/acl/service.go | 664 +++--- internal/acl/service_test.go | 352 +-- internal/acl/types.go | 80 +- internal/channel/inbound/channel.go | 10 +- internal/channel/inbound/channel_test.go | 12 +- internal/command/settings.go | 11 +- internal/db/sqlc/acl.sql.go | 483 ++-- internal/db/sqlc/conversations.sql.go | 2 +- internal/db/sqlc/messages.sql.go | 94 +- internal/db/sqlc/models.go | 6 +- internal/handlers/acl.go | 283 ++- internal/settings/service.go | 41 +- internal/settings/types.go | 4 +- packages/sdk/src/@pinia/colada.gen.ts | 249 +- packages/sdk/src/index.ts | 4 +- packages/sdk/src/sdk.gen.ts | 135 +- packages/sdk/src/types.gen.ts | 576 +++-- pnpm-lock.yaml | 99 +- spec/docs.go | 893 ++++--- spec/swagger.json | 893 ++++--- spec/swagger.yaml | 613 ++--- 30 files changed, 4599 insertions(+), 3556 deletions(-) create mode 100644 db/migrations/0048_acl_redesign.down.sql create mode 100644 db/migrations/0048_acl_redesign.up.sql diff --git a/apps/web/package.json b/apps/web/package.json index bf6cdfa6..3278c7ea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.1", "@vueuse/core": "^14.1.0", + "@vueuse/integrations": "^14.2.1", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/xterm": "^6.0.0", @@ -38,6 +39,7 @@ "pinia-plugin-persistedstate": "^4.7.1", "qrcode": "^1.5.4", "shiki": "^3.21.0", + "sortablejs": "^1.15.7", "stream-markdown": "^0.0.13", "stream-monaco": "^0.0.18", "tailwindcss": "^4.2.2", diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 08737c77..166e05bf 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -34,7 +34,9 @@ "loadFailed": "Failed to load", "saveFailed": "Failed to save", "createdAt": "Created at", - "none": "None" + "none": "None", + "yes": "Yes", + "no": "No" }, "auth": { "welcome": "Welcome Back", @@ -821,8 +823,8 @@ "compactionModelPlaceholder": "Use chat model (default)", "browserContext": "Browser Context", "browserContextPlaceholder": "Select browser context (disabled if empty)", - "allowGuest": "Allow Guest Access", - "allowGuestPersonalHint": "Personal bots do not support guest access. Use a public bot instead.", + "allowGuest": "Default ACL Effect", + "allowGuestPersonalHint": "ACL rules apply to all bot types.", "loopDetectionTitle": "Detect and auto-block output loops", "searchModel": "Search models…", "noModel": "No models available", @@ -834,45 +836,47 @@ }, "access": { "title": "Access Control", - "subtitle": "Guest chat trigger access is controlled by allow guest, whitelist, and blacklist together.", - "allowGuestDescription": "When enabled, guests may trigger chat by default. Blacklist rules still take precedence.", - "saveGuestAccess": "Save Guest Access", - "guestAccessSaved": "Guest access updated", - "guestRulesTitle": "Rule Summary", - "guestRulesDescription": "Owners and members always bypass these rules. The rules below only apply to guest chat triggers.", - "whitelistTitle": "Whitelist", - "whitelistDescription": "Whitelisted guests can still trigger chat even when guest access is not open.", - "blacklistTitle": "Blacklist", - "blacklistDescription": "Blacklisted guests are denied chat trigger even when guest access is open.", - "userSelector": "Select User", - "identitySelector": "Select Platform Identity", - "selectUser": "Search and select a user", + "subtitle": "Priority-ordered rules control who can trigger this bot's chat. The first matching rule wins; when no rule matches, the default effect applies.", + "defaultEffectTitle": "Default Effect", + "defaultEffectDescription": "Applied when no rule matches an inbound message.", + "defaultEffectSaved": "Default effect updated", + "effectAllow": "Allow", + "effectDeny": "Deny", + "rulesTitle": "Rules", + "rulesDescription": "Drag by the handle to reorder. Lower numbers are evaluated first; the first matching rule's effect is applied.", + "addRule": "Add Rule", + "editRule": "Edit Rule", + "rulesEmpty": "No rules", + "rulesEmptyDescription": "Add a rule to control access beyond the default effect.", + "priority": "Priority", + "priorityPlaceholder": "e.g. 100", + "dragToReorder": "Drag to reorder", + "rulesReordered": "Rule order updated", + "reorderFailed": "Failed to update rule order", + "enabled": "Enabled", + "effect": "Effect", + "subjectKind": "Subject", + "subjectAll": "All", + "subjectChannelType": "Platform Type", + "subjectChannelIdentity": "Identity", + "subjectAllLabel": "Everyone", + "subjectChannelTypeLabel": "{channel} platform users", + "channelType": "Platform Type", + "channelTypePlaceholder": "Enter platform type (e.g. telegram)", + "identitySelector": "Platform Identity", "selectIdentity": "Search and select a platform identity", - "searchUser": "Search users", "searchIdentity": "Search platform identities", - "noUserCandidates": "No user candidates", "noIdentityCandidates": "No platform identity candidates", - "userId": "User ID", - "userIdPlaceholder": "Enter user ID", - "channelIdentityId": "Channel Identity ID", - "channelIdentityIdPlaceholder": "Enter channel identity ID", - "addWhitelist": "Add to Whitelist", - "addBlacklist": "Add to Blacklist", - "clearSelection": "Clear Selection", - "whitelistEmpty": "No whitelist rules yet", - "blacklistEmpty": "No blacklist rules yet", - "validation": "Fill exactly one subject: user_id or channel_identity_id", - "validationConversationRequiresChannel": "Select a source platform before restricting by conversation or thread ID", - "validationConversationRequiresGroupOrThread": "Conversation ID restrictions are only supported for group or thread rules", - "validationThreadRequiresConversation": "Thread ID requires a conversation ID", - "validationThreadRequiresThreadType": "Thread ID restrictions require conversation type to be Thread", - "whitelistSaved": "Whitelist updated", - "blacklistSaved": "Blacklist updated", - "saveFailed": "Failed to save access rule", + "description": "Description", + "descriptionPlaceholder": "Optional note", + "deleteConfirmTitle": "Delete Rule", + "deleteConfirmDescription": "Are you sure you want to delete this rule?", + "ruleSaved": "Rule saved", + "saveFailed": "Failed to save", "deleteSuccess": "Rule deleted", "deleteFailed": "Failed to delete rule", "sourceScopeTitle": "Source Scope", - "sourceScopeDescription": "Optionally restrict a rule to a platform, conversation type, or specific conversation/thread.", + "sourceScopeDescription": "Optionally restrict this rule to a specific platform, conversation type, or conversation/thread.", "sourceChannel": "Source Platform", "anyChannel": "Any platform", "conversationType": "Conversation Type", @@ -880,12 +884,16 @@ "privateConversationType": "Private", "groupConversationType": "Group", "threadConversationType": "Thread", - "observedConversation": "Observed Group Or Thread", - "selectObservedConversation": "Select an observed group or thread", - "searchObservedConversation": "Search observed groups or threads", - "noObservedConversations": "No observed groups or threads", - "selectIdentityFirst": "Select a platform identity first to load observed groups or threads", - "observedConversationHint": "Use this only for group or thread rules. Private access should be controlled by conversation type alone.", + "conversationSource": "Conversation", + "conversationSourceDescription": "Search by name, platform, or conversation ID. Lists group/thread routes this bot has seen — either for the selected identity, or for the selected platform type.", + "selectConversationSource": "Search or select a conversation", + "searchConversationSource": "Search conversations", + "noObservedConversations": "No matching conversations in history yet. Use manual IDs below, or ensure this bot has messages in those chats (including private).", + "manualConversationIds": "Manual conversation IDs", + "manualConversationIdsHint": "If the conversation is not listed, enter the raw conversation ID and thread ID from the platform.", + "pickIdentityForConversationSearch": "Choose a platform identity above to search conversations observed for this bot.", + "pickChannelTypeForConversationSearch": "Enter or choose a platform type above to search conversations this bot has seen on that platform.", + "conversationIdManualHint": "Manual entry only. Search requires a platform identity.", "conversationId": "Conversation ID", "conversationIdPlaceholder": "Enter conversation ID", "threadId": "Thread ID", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 723cda76..3fa8941b 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -34,7 +34,9 @@ "loadFailed": "加载失败", "saveFailed": "保存失败", "createdAt": "创建时间", - "none": "无" + "none": "无", + "yes": "是", + "no": "否" }, "auth": { "welcome": "欢迎回来", @@ -817,8 +819,8 @@ "compactionModelPlaceholder": "使用聊天模型(默认)", "browserContext": "浏览器上下文", "browserContextPlaceholder": "选择浏览器上下文(未配置时不启用)", - "allowGuest": "允许游客访问", - "allowGuestPersonalHint": "个人 Bot 不支持游客访问,请使用公开 Bot。", + "allowGuest": "ACL 默认行为", + "allowGuestPersonalHint": "ACL 规则适用于所有类型的 Bot。", "loopDetectionTitle": "自动检测并阻止模型循环输出", "searchModel": "搜索模型…", "noModel": "暂无可选模型", @@ -830,41 +832,43 @@ }, "access": { "title": "访问控制", - "subtitle": "游客聊天触发权限由 allow guest、白名单和黑名单共同决定。", - "allowGuestDescription": "开启后,游客默认可触发聊天;黑名单仍然会优先阻止。", - "saveGuestAccess": "保存游客访问设置", - "guestAccessSaved": "游客访问设置已保存", - "guestRulesTitle": "规则说明", - "guestRulesDescription": "Owner 和成员始终可用;这里的规则只作用于游客的聊天触发。", - "whitelistTitle": "白名单", - "whitelistDescription": "当未开放游客时,白名单中的游客仍可触发聊天。", - "blacklistTitle": "黑名单", - "blacklistDescription": "即使已开放游客,黑名单中的游客也会被拒绝触发聊天。", - "userSelector": "选择用户", - "identitySelector": "选择平台身份", - "selectUser": "搜索并选择用户", + "subtitle": "按优先级排序的规则控制谁可以触发此 Bot 的聊天。首条匹配规则生效;无规则匹配时采用默认行为。", + "defaultEffectTitle": "默认行为", + "defaultEffectDescription": "当没有规则匹配入站消息时应用此策略。", + "defaultEffectSaved": "默认行为已更新", + "effectAllow": "允许", + "effectDeny": "拒绝", + "rulesTitle": "规则", + "rulesDescription": "拖动左侧手柄调整顺序。数字越小越先评估,首条匹配规则的行为生效。", + "addRule": "添加规则", + "editRule": "编辑规则", + "rulesEmpty": "暂无规则", + "rulesEmptyDescription": "添加规则以在默认行为之外控制访问。", + "priority": "优先级", + "priorityPlaceholder": "例如 100", + "dragToReorder": "拖动排序", + "rulesReordered": "规则顺序已更新", + "reorderFailed": "更新规则顺序失败", + "enabled": "启用", + "effect": "行为", + "subjectKind": "主体", + "subjectAll": "所有人", + "subjectChannelType": "平台类型", + "subjectChannelIdentity": "身份", + "subjectAllLabel": "所有人", + "subjectChannelTypeLabel": "{channel} 平台用户", + "channelType": "平台类型", + "channelTypePlaceholder": "输入平台类型(如 telegram)", + "identitySelector": "平台身份", "selectIdentity": "搜索并选择平台身份", - "searchUser": "搜索用户", "searchIdentity": "搜索平台身份", - "noUserCandidates": "暂无可选用户", "noIdentityCandidates": "暂无可选平台身份", - "userId": "用户 ID", - "userIdPlaceholder": "输入用户 ID", - "channelIdentityId": "Channel Identity ID", - "channelIdentityIdPlaceholder": "输入 channel identity ID", - "addWhitelist": "添加到白名单", - "addBlacklist": "添加到黑名单", - "clearSelection": "清空选择", - "whitelistEmpty": "暂无白名单规则", - "blacklistEmpty": "暂无黑名单规则", - "validation": "请只填写一个主体:user_id 或 channel_identity_id", - "validationConversationRequiresChannel": "按会话或线程限制时,请先选择来源平台", - "validationConversationRequiresGroupOrThread": "会话 ID 只支持用于群聊或线程规则", - "validationThreadRequiresConversation": "填写线程 ID 前必须先填写会话 ID", - "validationThreadRequiresThreadType": "线程 ID 只支持用于线程规则", - "whitelistSaved": "白名单已更新", - "blacklistSaved": "黑名单已更新", - "saveFailed": "保存访问规则失败", + "description": "描述", + "descriptionPlaceholder": "可选备注", + "deleteConfirmTitle": "删除规则", + "deleteConfirmDescription": "确定要删除此规则吗?", + "ruleSaved": "规则已保存", + "saveFailed": "保存失败", "deleteSuccess": "规则已删除", "deleteFailed": "删除规则失败", "sourceScopeTitle": "来源范围", @@ -876,12 +880,16 @@ "privateConversationType": "私聊", "groupConversationType": "群聊", "threadConversationType": "线程", - "observedConversation": "历史群聊或线程", - "selectObservedConversation": "选择一个历史群聊或线程", - "searchObservedConversation": "搜索历史群聊或线程", - "noObservedConversations": "暂无历史群聊或线程", - "selectIdentityFirst": "请先选择平台身份以加载历史群聊或线程", - "observedConversationHint": "这里只用于群聊或线程规则。私聊访问只需要通过会话类型控制。", + "conversationSource": "会话", + "conversationSourceDescription": "按名称、平台或会话 ID 搜索。列表为本 Bot 曾出现过的群聊/线程路由:选「平台身份」时按该身份筛选;选「平台类型」时按该平台上的全部会话。", + "selectConversationSource": "搜索或选择会话", + "searchConversationSource": "搜索会话", + "noObservedConversations": "暂无符合的历史会话。可手动填写 ID,或确认该 Bot 在对应会话中已有消息(含私聊)。", + "manualConversationIds": "手动填写会话 ID", + "manualConversationIdsHint": "若列表中没有目标会话,请从平台复制原始会话 ID、线程 ID。", + "pickIdentityForConversationSearch": "请先在上方选择平台身份,才能按历史记录搜索会话。", + "pickChannelTypeForConversationSearch": "请先在上方填写或选择平台类型,才能按该平台上的历史会话搜索。", + "conversationIdManualHint": "当前仅能手动填写。搜索会话需先选择平台身份。", "conversationId": "会话 ID", "conversationIdPlaceholder": "输入会话 ID", "threadId": "线程 ID", diff --git a/apps/web/src/pages/bots/components/bot-access.vue b/apps/web/src/pages/bots/components/bot-access.vue index c2882359..59c48481 100644 --- a/apps/web/src/pages/bots/components/bot-access.vue +++ b/apps/web/src/pages/bots/components/bot-access.vue @@ -9,761 +9,584 @@

+ +
+
+

+ {{ $t('bots.access.defaultEffectTitle') }} +

+

+ {{ $t('bots.access.defaultEffectDescription') }} +

+
+
+ + + +
+
+ +
-
-
-

- {{ $t('bots.settings.allowGuest') }} -

+
+
+

+ {{ $t('bots.access.rulesTitle') }} +

- {{ $t('bots.access.allowGuestDescription') }} + {{ $t('bots.access.rulesDescription') }}

+
+ +
+ +
+ +
+ +
+
+ + + + + {{ rule.priority }} + + + + + {{ rule.effect === 'allow' ? $t('bots.access.effectAllow') : $t('bots.access.effectDeny') }} + + + +
+

+ {{ describeSubject(rule) }} +

+

+ {{ describeScope(rule.source_scope) }} +

+

+ {{ rule.description }} +

+
+ + + + + +
+ + + + +
+
+
+
+ + + + + + + {{ editingRule ? $t('bots.access.editRule') : $t('bots.access.addRule') }} + + + +
+
+ +
+ + {{ ruleForm.enabled ? $t('common.yes') : $t('common.no') }} +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ + +
+ + + + +
+ + +
+ + + {{ $t('bots.access.sourceScopeTitle') }} + +
+

+ {{ $t('bots.access.sourceScopeDescription') }} +

+ + +
+ +
+ +
+
+ + +
+ +

+ {{ $t('bots.access.conversationSourceDescription') }} +

+ + + + +
+ + + {{ $t('bots.access.manualConversationIds') }} + +

+ {{ $t('bots.access.manualConversationIdsHint') }} +

+
+
+ + +
+
+ + +
+
+
+
+ + + + + +
+
+ + +
+ + +
+

- {{ $t('bots.settings.allowGuestPersonalHint') }} + {{ formError }}

- - - -
- -
- -
-

- {{ $t('bots.access.guestRulesTitle') }} -

-

- {{ $t('bots.access.guestRulesDescription') }} -

-
- -
-
-

- {{ $t('bots.access.whitelistTitle') }} -

-

- {{ $t('bots.access.whitelistDescription') }} -

-
- -
-
- - - - - -
-
- - - - - -
-
- -
-
-

- {{ $t('bots.access.sourceScopeTitle') }} -

-

- {{ $t('bots.access.sourceScopeDescription') }} -

-
- -
-
- -
+
- +
- -
- - - - - - - -
- -
- - -
- {{ $t('bots.access.selectIdentityFirst') }} -
-

- - -

-
- -
- - -
- -
- - -
-
- -
- -
-
- -
- - -
- - - -
- {{ $t('common.loading') }} -
-
- {{ $t('bots.access.whitelistEmpty') }} -
-
-
-
-
- - - - {{ initials(formatRuleLabel(item)) }} - - - -
-
-
- {{ formatRuleLabel(item) }} -
-
- {{ formatRuleMeta(item) }} -
-
-
- -
-
-
- -
-
-

- {{ $t('bots.access.blacklistTitle') }} -

-

- {{ $t('bots.access.blacklistDescription') }} -

-
- -
-
- - - - - -
-
- - - - - -
-
- -
-
-

- {{ $t('bots.access.sourceScopeTitle') }} -

-

- {{ $t('bots.access.sourceScopeDescription') }} -

-
- -
-
- -
- {{ blacklistSelection.sourceChannel || $t('bots.access.anyChannel') }} -
- - - - -
- -
- - - - - - - -
- -
- - -
- {{ $t('bots.access.selectIdentityFirst') }} -
-

- - -

-
- -
- - -
- -
- - -
-
- -
- -
-
- -
- - -
- - - -
- {{ $t('common.loading') }} -
-
- {{ $t('bots.access.blacklistEmpty') }} -
-
-
-
-
- - - - {{ initials(formatRuleLabel(item)) }} - - - -
-
-
- {{ formatRuleLabel(item) }} -
-
- {{ formatRuleMeta(item) }} -
-
-
- -
-
-
+ {{ $t('common.save') }} + + + +
+
diff --git a/apps/web/src/pages/bots/components/bot-terminal.vue b/apps/web/src/pages/bots/components/bot-terminal.vue index 2540272a..13a9c42b 100644 --- a/apps/web/src/pages/bots/components/bot-terminal.vue +++ b/apps/web/src/pages/bots/components/bot-terminal.vue @@ -388,13 +388,17 @@ onBeforeUnmount(() => { }" /> {{ tab.label }} - +