From 839e63acda882a3449cec11f5bccf8dd63e009c6 Mon Sep 17 00:00:00 2001
From: BBQ <35603386+HoneyBBQ@users.noreply.github.com>
Date: Sat, 14 Mar 2026 17:15:41 +0800
Subject: [PATCH] feat(access): add guest chat ACL (#235)
---
apps/web/src/i18n/locales/en.json | 60 +
apps/web/src/i18n/locales/zh.json | 60 +
.../src/pages/bots/components/bot-access.vue | 1170 +++++++++++++++++
.../pages/bots/components/bot-settings.vue | 19 -
apps/web/src/pages/bots/detail.vue | 2 +
cmd/agent/main.go | 11 +-
cmd/memoh/serve.go | 11 +-
db/migrations/0001_init.down.sql | 1 +
db/migrations/0001_init.up.sql | 65 +-
.../0031_chat_acl_remove_bot_members.down.sql | 43 +
.../0031_chat_acl_remove_bot_members.up.sql | 53 +
.../0032_source_aware_acl_scope.down.sql | 52 +
.../0032_source_aware_acl_scope.up.sql | 69 +
db/queries/acl.sql | 136 ++
db/queries/bots.sql | 39 +-
db/queries/channel_identities.sql | 26 +
db/queries/conversations.sql | 80 +-
db/queries/messages.sql | 22 +
db/queries/preauth.sql | 18 -
db/queries/settings.sql | 6 +-
db/queries/users.sql | 13 +
devenv/docker-compose.yml | 44 +-
internal/accounts/service.go | 22 +
internal/acl/service.go | 474 +++++++
internal/acl/service_test.go | 389 ++++++
internal/acl/types.go | 131 ++
internal/bots/service.go | 213 +--
internal/bots/service_test.go | 98 +-
internal/bots/types.go | 26 -
internal/channel/adapters/discord/discord.go | 4 +-
internal/channel/adapters/feishu/inbound.go | 22 +-
.../channel/adapters/feishu/sender_profile.go | 9 +-
internal/channel/adapters/qq/qq.go | 2 +-
internal/channel/adapters/qq/receive.go | 20 +-
internal/channel/adapters/qq/receive_test.go | 12 +-
.../channel/adapters/telegram/telegram.go | 18 +-
internal/channel/adapters/wecom/inbound.go | 4 +-
internal/channel/adapters/wecom/wecom.go | 2 +-
internal/channel/identities/service.go | 38 +
internal/channel/identities/types.go | 7 +
internal/channel/inbound/channel.go | 52 +-
internal/channel/inbound/channel_test.go | 197 ++-
internal/channel/inbound/identity.go | 116 +-
internal/channel/inbound/identity_test.go | 248 ++--
internal/channel/inbound_test.go | 4 +-
internal/channel/manager_integration_test.go | 2 +-
internal/channel/route/service.go | 29 +-
internal/channel/types.go | 29 +-
internal/command/handler.go | 9 +-
internal/command/handler_test.go | 21 +-
internal/conversation/service.go | 134 +-
internal/db/sqlc/acl.sql.go | 415 ++++++
internal/db/sqlc/bots.sql.go | 194 +--
internal/db/sqlc/channel_identities.sql.go | 80 ++
internal/db/sqlc/conversations.sql.go | 131 +-
internal/db/sqlc/messages.sql.go | 66 +
internal/db/sqlc/models.go | 35 +-
internal/db/sqlc/preauth.sql.go | 91 --
internal/db/sqlc/settings.sql.go | 36 +-
internal/db/sqlc/users.sql.go | 53 +
internal/handlers/acl.go | 322 +++++
internal/handlers/containerd.go | 6 +-
internal/handlers/heartbeat.go | 2 +-
internal/handlers/inbox.go | 2 +-
internal/handlers/local_channel.go | 6 +-
internal/handlers/mcp.go | 2 +-
internal/handlers/mcp_oauth.go | 2 +-
internal/handlers/memory.go | 2 +-
internal/handlers/message.go | 4 +-
internal/handlers/preauth.go | 72 -
internal/handlers/schedule.go | 2 +-
internal/handlers/settings.go | 2 +-
internal/handlers/subagent.go | 2 +-
internal/handlers/token_usage.go | 2 +-
internal/handlers/users.go | 108 +-
internal/policy/service.go | 41 +-
internal/preauth/service.go | 107 --
internal/preauth/types.go | 14 -
internal/settings/service.go | 55 +-
packages/sdk/src/@pinia/colada.gen.ts | 214 ++-
packages/sdk/src/index.ts | 4 +-
packages/sdk/src/sdk.gen.ts | 107 +-
packages/sdk/src/types.gen.ts | 605 ++++++---
spec/docs.go | 873 +++++++++---
spec/swagger.json | 873 +++++++++---
spec/swagger.yaml | 578 ++++++--
86 files changed, 6886 insertions(+), 2554 deletions(-)
create mode 100644 apps/web/src/pages/bots/components/bot-access.vue
create mode 100644 db/migrations/0031_chat_acl_remove_bot_members.down.sql
create mode 100644 db/migrations/0031_chat_acl_remove_bot_members.up.sql
create mode 100644 db/migrations/0032_source_aware_acl_scope.down.sql
create mode 100644 db/migrations/0032_source_aware_acl_scope.up.sql
create mode 100644 db/queries/acl.sql
delete mode 100644 db/queries/preauth.sql
create mode 100644 internal/acl/service.go
create mode 100644 internal/acl/service_test.go
create mode 100644 internal/acl/types.go
create mode 100644 internal/db/sqlc/acl.sql.go
delete mode 100644 internal/db/sqlc/preauth.sql.go
create mode 100644 internal/handlers/acl.go
delete mode 100644 internal/handlers/preauth.go
delete mode 100644 internal/preauth/service.go
delete mode 100644 internal/preauth/types.go
diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json
index 459ff9b5..326feabe 100644
--- a/apps/web/src/i18n/locales/en.json
+++ b/apps/web/src/i18n/locales/en.json
@@ -559,6 +559,7 @@
"skills": "Skills",
"email": "Email",
"files": "Files",
+ "access": "Access",
"terminal": "Terminal",
"settings": "Settings"
},
@@ -767,6 +768,65 @@
"deleteBotDescription": "Deleting this bot cannot be undone. Proceed with caution.",
"deleteBot": "Delete Bot"
},
+ "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",
+ "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",
+ "validationThreadRequiresConversation": "Thread ID requires a conversation ID",
+ "whitelistSaved": "Whitelist updated",
+ "blacklistSaved": "Blacklist updated",
+ "saveFailed": "Failed to save access rule",
+ "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.",
+ "sourceChannel": "Source Platform",
+ "anyChannel": "Any platform",
+ "conversationType": "Conversation Type",
+ "anyConversationType": "Any conversation type",
+ "privateConversationType": "Private",
+ "groupConversationType": "Group",
+ "threadConversationType": "Thread",
+ "observedConversation": "Observed Conversation",
+ "selectObservedConversation": "Select an observed conversation",
+ "searchObservedConversation": "Search observed conversations",
+ "noObservedConversations": "No observed conversations",
+ "selectIdentityFirst": "Select a platform identity first to load observed conversations",
+ "observedConversationHint": "Choosing an observed conversation auto-fills the platform, conversation ID, and thread ID.",
+ "conversationId": "Conversation ID",
+ "conversationIdPlaceholder": "Enter conversation ID",
+ "threadId": "Thread ID",
+ "threadIdPlaceholder": "Enter thread ID",
+ "clearScope": "Clear Scope",
+ "lastObserved": "Last seen"
+ },
"channels": {
"title": "Platforms",
"addChannel": "Add Platform",
diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json
index ac848fa2..165e3b61 100644
--- a/apps/web/src/i18n/locales/zh.json
+++ b/apps/web/src/i18n/locales/zh.json
@@ -555,6 +555,7 @@
"skills": "技能",
"email": "邮件",
"files": "文件",
+ "access": "访问控制",
"terminal": "终端",
"settings": "设置"
},
@@ -763,6 +764,65 @@
"deleteBotDescription": "删除此 Bot 后无法恢复,请谨慎操作。",
"deleteBot": "删除 Bot"
},
+ "access": {
+ "title": "访问控制",
+ "subtitle": "游客聊天触发权限由 allow guest、白名单和黑名单共同决定。",
+ "allowGuestDescription": "开启后,游客默认可触发聊天;黑名单仍然会优先阻止。",
+ "saveGuestAccess": "保存游客访问设置",
+ "guestAccessSaved": "游客访问设置已保存",
+ "guestRulesTitle": "规则说明",
+ "guestRulesDescription": "Owner 和成员始终可用;这里的规则只作用于游客的聊天触发。",
+ "whitelistTitle": "白名单",
+ "whitelistDescription": "当未开放游客时,白名单中的游客仍可触发聊天。",
+ "blacklistTitle": "黑名单",
+ "blacklistDescription": "即使已开放游客,黑名单中的游客也会被拒绝触发聊天。",
+ "userSelector": "选择用户",
+ "identitySelector": "选择平台身份",
+ "selectUser": "搜索并选择用户",
+ "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": "按会话或线程限制时,请先选择来源平台",
+ "validationThreadRequiresConversation": "填写线程 ID 前必须先填写会话 ID",
+ "whitelistSaved": "白名单已更新",
+ "blacklistSaved": "黑名单已更新",
+ "saveFailed": "保存访问规则失败",
+ "deleteSuccess": "规则已删除",
+ "deleteFailed": "删除规则失败",
+ "sourceScopeTitle": "来源范围",
+ "sourceScopeDescription": "可选地将规则限制到某个平台、会话类型或指定会话/线程。",
+ "sourceChannel": "来源平台",
+ "anyChannel": "任意平台",
+ "conversationType": "会话类型",
+ "anyConversationType": "任意会话类型",
+ "privateConversationType": "私聊",
+ "groupConversationType": "群聊",
+ "threadConversationType": "线程",
+ "observedConversation": "历史会话",
+ "selectObservedConversation": "选择一个历史会话",
+ "searchObservedConversation": "搜索历史会话",
+ "noObservedConversations": "暂无历史会话",
+ "selectIdentityFirst": "请先选择平台身份以加载历史会话",
+ "observedConversationHint": "选择历史会话后会自动填充平台、会话 ID 和线程 ID。",
+ "conversationId": "会话 ID",
+ "conversationIdPlaceholder": "输入会话 ID",
+ "threadId": "线程 ID",
+ "threadIdPlaceholder": "输入线程 ID",
+ "clearScope": "清空范围",
+ "lastObserved": "最近出现"
+ },
"channels": {
"title": "平台",
"addChannel": "添加平台",
diff --git a/apps/web/src/pages/bots/components/bot-access.vue b/apps/web/src/pages/bots/components/bot-access.vue
new file mode 100644
index 00000000..e7031676
--- /dev/null
+++ b/apps/web/src/pages/bots/components/bot-access.vue
@@ -0,0 +1,1170 @@
+
+
+
+
+ {{ $t('bots.access.title') }}
+
+
+ {{ $t('bots.access.subtitle') }}
+
+
+
+
+
+
+
+ {{ $t('bots.settings.allowGuest') }}
+
+
+ {{ $t('bots.access.allowGuestDescription') }}
+
+
+ {{ $t('bots.settings.allowGuestPersonalHint') }}
+
+
+
allowGuestDraft = !!val"
+ />
+
+
+
+
+
+
+
+
+ {{ $t('bots.access.guestRulesTitle') }}
+
+
+ {{ $t('bots.access.guestRulesDescription') }}
+
+
+
+
+
+
+ {{ $t('bots.access.whitelistTitle') }}
+
+
+ {{ $t('bots.access.whitelistDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ initials(option.label) }}
+
+
+
+
+ {{ option.label }}
+
+
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ initials(option.label) }}
+
+
+
+
+ {{ option.label }}
+
+
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.access.sourceScopeTitle') }}
+
+
+ {{ $t('bots.access.sourceScopeDescription') }}
+
+
+
+
+
+
+
+ {{ whitelistSelection.sourceChannel || $t('bots.access.anyChannel') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.access.selectIdentityFirst') }}
+
+
+
+ {{ $t('common.loading') }}
+
+
+ {{ $t('bots.access.observedConversationHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}
+
+
+ {{ $t('bots.access.whitelistEmpty') }}
+
+
+
+
+
+
+
+
+ {{ initials(formatRuleLabel(item)) }}
+
+
+
+
+
+
+ {{ formatRuleLabel(item) }}
+
+
+ {{ formatRuleMeta(item) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.access.blacklistTitle') }}
+
+
+ {{ $t('bots.access.blacklistDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ initials(option.label) }}
+
+
+
+
+ {{ option.label }}
+
+
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ initials(option.label) }}
+
+
+
+
+ {{ option.label }}
+
+
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.access.sourceScopeTitle') }}
+
+
+ {{ $t('bots.access.sourceScopeDescription') }}
+
+
+
+
+
+
+
+ {{ blacklistSelection.sourceChannel || $t('bots.access.anyChannel') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('bots.access.selectIdentityFirst') }}
+
+
+
+ {{ $t('common.loading') }}
+
+
+ {{ $t('bots.access.observedConversationHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}
+
+
+ {{ $t('bots.access.blacklistEmpty') }}
+
+
+
+
+
+
+
+
+ {{ initials(formatRuleLabel(item)) }}
+
+
+
+
+
+
+ {{ formatRuleLabel(item) }}
+
+
+ {{ formatRuleMeta(item) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/pages/bots/components/bot-settings.vue b/apps/web/src/pages/bots/components/bot-settings.vue
index 071d8053..c22fec3e 100644
--- a/apps/web/src/pages/bots/components/bot-settings.vue
+++ b/apps/web/src/pages/bots/components/bot-settings.vue
@@ -258,18 +258,6 @@
-
-
-
-
- form.allow_guest = !!val"
- />
-
-
-
-