From 1c19ec10223b502dd3cc1f419edc474dbe5453f2 Mon Sep 17 00:00:00 2001 From: BBQ <35603386+HoneyBBQ@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:06:50 +0800 Subject: [PATCH] feat(acl): source-aware chat trigger ACL (#252) --- apps/agent/src/models.ts | 6 +- apps/web/src/i18n/locales/en.json | 22 ++++-- apps/web/src/i18n/locales/zh.json | 22 ++++-- .../src/pages/bots/components/bot-access.vue | 74 +++++++++++++++++-- apps/web/src/pages/bots/detail.vue | 13 +++- db/queries/messages.sql | 47 ++++++++++-- internal/acl/service.go | 6 +- internal/db/sqlc/messages.sql.go | 49 +++++++++--- packages/agent/src/types/agent.ts | 6 +- 9 files changed, 190 insertions(+), 55 deletions(-) diff --git a/apps/agent/src/models.ts b/apps/agent/src/models.ts index 8758505e..d5966e9f 100644 --- a/apps/agent/src/models.ts +++ b/apps/agent/src/models.ts @@ -27,9 +27,9 @@ export const ModelConfigModel = z.object({ export const IdentityContextModel = z.object({ botId: z.string().min(1, 'Bot ID is required'), - containerId: z.string().min(1, 'Container ID is required'), - channelIdentityId: z.string().min(1, 'Channel identity ID is required'), - displayName: z.string().min(1, 'Display name is required'), + containerId: z.string().optional().default(''), + channelIdentityId: z.string().optional().default(''), + displayName: z.string().optional().default(''), currentPlatform: z.string().optional(), replyTarget: z.string().optional(), conversationType: z.string().optional(), diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 21e97925..a3d3dc5d 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -501,6 +501,11 @@ "editAvatarDescription": "Set the image URL for the bot avatar.", "avatarUpdateSuccess": "Avatar updated", "avatarUpdateFailed": "Failed to update avatar", + "typePlaceholder": "Select bot type", + "types": { + "personal": "Personal", + "public": "Public" + }, "active": "Active", "inactive": "Inactive", "lifecycle": { @@ -753,6 +758,7 @@ "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.", "loopDetectionTitle": "Detect and auto-block output loops", "searchModel": "Search models…", "noModel": "No models available", @@ -793,7 +799,9 @@ "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", @@ -808,12 +816,12 @@ "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.", + "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.", "conversationId": "Conversation ID", "conversationIdPlaceholder": "Enter conversation ID", "threadId": "Thread ID", @@ -847,8 +855,6 @@ "webhookCallback": "WebHook Callback URL", "webhookCallbackHint": "Use this URL as the event subscription request URL in Feishu/Lark.", "webhookCallbackPending": "Save this platform configuration to generate the callback URL.", - "feishuWebhookSecurityHint": "For security, webhook mode requires either an Encrypt Key or a Verification Token; an unprotected public callback URL should not be exposed.", - "feishuWebhookSecretRequired": "For security, configure at least one of Encrypt Key or Verification Token.", "noAvailableTypes": "All platform types have been configured", "types": { "feishu": "Feishu", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index bfabb038..b2704c18 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -497,6 +497,11 @@ "editAvatarDescription": "设置 Bot 头像图片的链接地址", "avatarUpdateSuccess": "头像已更新", "avatarUpdateFailed": "更新头像失败", + "typePlaceholder": "选择 Bot 类型", + "types": { + "personal": "个人", + "public": "公开" + }, "active": "运行中", "inactive": "未启用", "lifecycle": { @@ -749,6 +754,7 @@ "browserContext": "浏览器上下文", "browserContextPlaceholder": "选择浏览器上下文(未配置时不启用)", "allowGuest": "允许游客访问", + "allowGuestPersonalHint": "个人 Bot 不支持游客访问,请使用公开 Bot。", "loopDetectionTitle": "自动检测并阻止模型循环输出", "searchModel": "搜索模型…", "noModel": "暂无可选模型", @@ -789,7 +795,9 @@ "blacklistEmpty": "暂无黑名单规则", "validation": "请只填写一个主体:user_id 或 channel_identity_id", "validationConversationRequiresChannel": "按会话或线程限制时,请先选择来源平台", + "validationConversationRequiresGroupOrThread": "会话 ID 只支持用于群聊或线程规则", "validationThreadRequiresConversation": "填写线程 ID 前必须先填写会话 ID", + "validationThreadRequiresThreadType": "线程 ID 只支持用于线程规则", "whitelistSaved": "白名单已更新", "blacklistSaved": "黑名单已更新", "saveFailed": "保存访问规则失败", @@ -804,12 +812,12 @@ "privateConversationType": "私聊", "groupConversationType": "群聊", "threadConversationType": "线程", - "observedConversation": "历史会话", - "selectObservedConversation": "选择一个历史会话", - "searchObservedConversation": "搜索历史会话", - "noObservedConversations": "暂无历史会话", - "selectIdentityFirst": "请先选择平台身份以加载历史会话", - "observedConversationHint": "选择历史会话后会自动填充平台、会话 ID 和线程 ID。", + "observedConversation": "历史群聊或线程", + "selectObservedConversation": "选择一个历史群聊或线程", + "searchObservedConversation": "搜索历史群聊或线程", + "noObservedConversations": "暂无历史群聊或线程", + "selectIdentityFirst": "请先选择平台身份以加载历史群聊或线程", + "observedConversationHint": "这里只用于群聊或线程规则。私聊访问只需要通过会话类型控制。", "conversationId": "会话 ID", "conversationIdPlaceholder": "输入会话 ID", "threadId": "线程 ID", @@ -843,8 +851,6 @@ "webhookCallback": "WebHook 回调地址", "webhookCallbackHint": "将该地址配置到飞书/Lark 事件订阅的请求 URL。", "webhookCallbackPending": "保存平台配置后会生成回调地址。", - "feishuWebhookSecurityHint": "出于安全考虑,Webhook 模式必须配置 Encrypt Key 或 Verification Token 之一;未受保护的回调地址不应直接暴露在公网上。", - "feishuWebhookSecretRequired": "出于安全考虑,请至少配置 Encrypt Key 或 Verification Token 之一。", "noAvailableTypes": "所有平台类型均已配置", "types": { "feishu": "飞书", diff --git a/apps/web/src/pages/bots/components/bot-access.vue b/apps/web/src/pages/bots/components/bot-access.vue index 6c7c383a..6bc16733 100644 --- a/apps/web/src/pages/bots/components/bot-access.vue +++ b/apps/web/src/pages/bots/components/bot-access.vue @@ -18,16 +18,22 @@

{{ $t('bots.access.allowGuestDescription') }}

+

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

-
+
-
+
-
+
@@ -441,6 +460,7 @@
-
+
-
+
-
+
@@ -649,6 +681,7 @@ import ChannelBadge from '@/components/chat-list/channel-badge/index.vue' const props = defineProps<{ botId: string + botType?: string }>() const { t } = useI18n() @@ -656,6 +689,8 @@ const queryCache = useQueryCache() const deletingRuleId = ref('') const allowGuestDraft = ref(false) +const isPersonalBot = computed(() => props.botType === 'personal') + type RuleSelection = { userId: string channelIdentityId: string @@ -710,6 +745,16 @@ function bindSelectionWatchers(selection: RuleSelection) { selection.sourceThreadId = '' } }) + watch(() => selection.sourceConversationType, (value) => { + if (value === 'private') { + selection.observedConversationRouteId = '' + selection.sourceConversationId = '' + selection.sourceThreadId = '' + } + if (value !== 'thread') { + selection.sourceThreadId = '' + } + }) } bindSelectionWatchers(whitelistSelection) @@ -930,14 +975,23 @@ function buildSourceScope(selection: RuleSelection): AclSourceScope | undefined function normalizePayload(selection: RuleSelection): AclUpsertRuleRequest | null { const user_id = selection.userId.trim() const channel_identity_id = selection.channelIdentityId.trim() + const sourceConversationType = selection.sourceConversationType.trim() if ((user_id && channel_identity_id) || (!user_id && !channel_identity_id)) { toast.error(t('bots.access.validation')) return null } + if (selection.sourceConversationId.trim() && !['group', 'thread'].includes(sourceConversationType)) { + toast.error(t('bots.access.validationConversationRequiresGroupOrThread')) + return null + } if (selection.sourceThreadId.trim() && !selection.sourceConversationId.trim()) { toast.error(t('bots.access.validationThreadRequiresConversation')) return null } + if (selection.sourceThreadId.trim() && sourceConversationType !== 'thread') { + toast.error(t('bots.access.validationThreadRequiresThreadType')) + return null + } if ((selection.sourceConversationId.trim() || selection.sourceThreadId.trim()) && !selection.sourceChannel.trim()) { toast.error(t('bots.access.validationConversationRequiresChannel')) return null @@ -1030,6 +1084,10 @@ function clearSourceScope(selection: RuleSelection) { selection.sourceThreadId = '' } +function isObservedConversationLocked(selection: RuleSelection): boolean { + return !!selection.observedConversationRouteId +} + function formatRuleLabel(item: AclRule): string { if (item.subject_kind === 'user') { return item.user_display_name || item.user_username || item.user_id || '-' diff --git a/apps/web/src/pages/bots/detail.vue b/apps/web/src/pages/bots/detail.vue index a39d5b00..75afe32b 100644 --- a/apps/web/src/pages/bots/detail.vue +++ b/apps/web/src/pages/bots/detail.vue @@ -91,6 +91,7 @@ /> {{ statusLabel }} + {{ botTypeLabel }}
@@ -282,15 +283,15 @@ const tabList = computed(() => { { value: 'email', label: 'bots.tabs.email', component: BotEmail, params: { 'bot-id': bot_id } }, { value: 'container', label: 'bots.tabs.container', component: BotContainer, params: {} }, { value: 'terminal', label: 'bots.tabs.terminal', component: BotTerminal, params: { 'bot-id': bot_id } }, - { value: 'files', label: 'bots.tabs.files', component: BotFiles, params: { 'bot-id': bot_id } }, + { value: 'files', label: 'bots.tabs.files', component: BotFiles, params: { 'bot-id': bot_id, 'bot-type': bot.value?.type } }, { value: 'mcp', label: 'bots.tabs.mcp', component: BotMcp, params: { 'bot-id': bot_id } }, { value: 'subagents', label: 'bots.tabs.subagents', component: BotSubagents, params: { 'bot-id': bot_id } }, { value: 'heartbeat', label: 'bots.tabs.heartbeat', component: BotHeartbeat, params: { 'bot-id': bot_id } }, { value: 'schedule', label: 'bots.tabs.schedule', component: BotSchedule, params: { 'bot-id': bot_id } }, { value: 'history', label: 'bots.tabs.history', component: BotHistory, params: { 'bot-id': bot_id } }, { value: 'skills', label: 'bots.tabs.skills', component: BotSkills, params: { 'bot-id': bot_id } }, - { value: 'access', label: 'bots.tabs.access', component: BotAccess, params: { 'bot-id': bot_id } }, - { value: 'settings', label: 'bots.tabs.settings', component: BotSettings, params: { 'bot-id': bot_id } } + { value: 'access', label: 'bots.tabs.access', component: BotAccess, params: { 'bot-id': bot_id, 'bot-type': bot.value?.type } }, + { value: 'settings', label: 'bots.tabs.settings', component: BotSettings, params: { 'bot-id': bot_id, 'bot-type': bot.value?.type } } ] }) @@ -368,6 +369,12 @@ const { statusVariant, } = useBotStatusMeta(bot, t) +const botTypeLabel = computed(() => { + const type = bot.value?.type + if (type === 'personal' || type === 'public') return t('bots.types.' + type) + return type ?? '' +}) + const checks = ref([]) const checksLoading = ref(false) diff --git a/db/queries/messages.sql b/db/queries/messages.sql index 92f67ba0..4433160d 100644 --- a/db/queries/messages.sql +++ b/db/queries/messages.sql @@ -164,23 +164,54 @@ DELETE FROM bot_history_messages WHERE bot_id = sqlc.arg(bot_id); -- name: ListObservedConversationsByChannelIdentity :many +WITH observed_routes AS ( + SELECT + (i.header->>'route_id')::uuid AS route_id, + MAX(i.created_at)::timestamptz AS last_observed_at + FROM bot_inbox i + WHERE i.bot_id = sqlc.arg(bot_id) + AND i.header->>'channel-identity-id' = sqlc.arg(channel_identity_id)::text + AND COALESCE(i.header->>'route_id', '') != '' + GROUP BY (i.header->>'route_id')::uuid + + UNION ALL + + SELECT + m.route_id, + MAX(m.created_at)::timestamptz AS last_observed_at + FROM bot_history_messages m + WHERE m.bot_id = sqlc.arg(bot_id) + AND m.sender_channel_identity_id = sqlc.arg(channel_identity_id)::uuid + AND m.route_id IS NOT NULL + GROUP BY m.route_id +), +ranked_routes AS ( + SELECT + route_id, + MAX(last_observed_at)::timestamptz AS last_observed_at + FROM observed_routes + GROUP BY route_id +) SELECT r.id AS route_id, r.channel_type AS channel, - COALESCE(r.conversation_type, '') AS conversation_type, + CASE + WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('thread', 'topic') THEN 'thread' + ELSE 'group' + END AS conversation_type, r.external_conversation_id AS conversation_id, COALESCE(r.external_thread_id, '') AS thread_id, COALESCE(r.metadata->>'conversation_name', '')::text AS conversation_name, - MAX(m.created_at)::timestamptz AS last_observed_at -FROM bot_history_messages m -JOIN bot_channel_routes r ON r.id = m.route_id -WHERE m.bot_id = sqlc.arg(bot_id) - AND m.sender_channel_identity_id = sqlc.arg(channel_identity_id) + rr.last_observed_at +FROM ranked_routes rr +JOIN bot_channel_routes r ON r.id = rr.route_id +WHERE LOWER(COALESCE(r.conversation_type, '')) NOT IN ('', 'p2p', 'private', 'direct', 'dm') GROUP BY r.id, r.channel_type, r.conversation_type, r.external_conversation_id, r.external_thread_id, - r.metadata -ORDER BY MAX(m.created_at) DESC; + r.metadata, + rr.last_observed_at +ORDER BY rr.last_observed_at DESC; diff --git a/internal/acl/service.go b/internal/acl/service.go index 6a3af264..4d9ea291 100644 --- a/internal/acl/service.go +++ b/internal/acl/service.go @@ -199,13 +199,9 @@ func (s *Service) ListObservedConversationsByChannelIdentity(ctx context.Context if err != nil { return nil, err } - pgChannelIdentityID, err := db.ParseUUID(channelIdentityID) - if err != nil { - return nil, err - } rows, err := s.queries.ListObservedConversationsByChannelIdentity(ctx, sqlc.ListObservedConversationsByChannelIdentityParams{ BotID: pgBotID, - ChannelIdentityID: pgChannelIdentityID, + ChannelIdentityID: strings.TrimSpace(channelIdentityID), }) if err != nil { return nil, err diff --git a/internal/db/sqlc/messages.sql.go b/internal/db/sqlc/messages.sql.go index fbc17415..20032e70 100644 --- a/internal/db/sqlc/messages.sql.go +++ b/internal/db/sqlc/messages.sql.go @@ -545,31 +545,62 @@ func (q *Queries) ListMessagesSince(ctx context.Context, arg ListMessagesSincePa } const listObservedConversationsByChannelIdentity = `-- name: ListObservedConversationsByChannelIdentity :many +WITH observed_routes AS ( + SELECT + (i.header->>'route_id')::uuid AS route_id, + MAX(i.created_at)::timestamptz AS last_observed_at + FROM bot_inbox i + WHERE i.bot_id = $1 + AND i.header->>'channel-identity-id' = $2::text + AND COALESCE(i.header->>'route_id', '') != '' + GROUP BY (i.header->>'route_id')::uuid + + UNION ALL + + SELECT + m.route_id, + MAX(m.created_at)::timestamptz AS last_observed_at + FROM bot_history_messages m + WHERE m.bot_id = $1 + AND m.sender_channel_identity_id = $2::uuid + AND m.route_id IS NOT NULL + GROUP BY m.route_id +), +ranked_routes AS ( + SELECT + route_id, + MAX(last_observed_at)::timestamptz AS last_observed_at + FROM observed_routes + GROUP BY route_id +) SELECT r.id AS route_id, r.channel_type AS channel, - COALESCE(r.conversation_type, '') AS conversation_type, + CASE + WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('thread', 'topic') THEN 'thread' + ELSE 'group' + END AS conversation_type, r.external_conversation_id AS conversation_id, COALESCE(r.external_thread_id, '') AS thread_id, COALESCE(r.metadata->>'conversation_name', '')::text AS conversation_name, - MAX(m.created_at)::timestamptz AS last_observed_at -FROM bot_history_messages m -JOIN bot_channel_routes r ON r.id = m.route_id -WHERE m.bot_id = $1 - AND m.sender_channel_identity_id = $2 + rr.last_observed_at +FROM ranked_routes rr +JOIN bot_channel_routes r ON r.id = rr.route_id +WHERE LOWER(COALESCE(r.conversation_type, '')) NOT IN ('', 'p2p', 'private', 'direct', 'dm') GROUP BY r.id, r.channel_type, r.conversation_type, r.external_conversation_id, r.external_thread_id, - r.metadata -ORDER BY MAX(m.created_at) DESC + r.metadata, + rr.last_observed_at +ORDER BY rr.last_observed_at DESC ` type ListObservedConversationsByChannelIdentityParams struct { BotID pgtype.UUID `json:"bot_id"` - ChannelIdentityID pgtype.UUID `json:"channel_identity_id"` + ChannelIdentityID string `json:"channel_identity_id"` } type ListObservedConversationsByChannelIdentityRow struct { diff --git a/packages/agent/src/types/agent.ts b/packages/agent/src/types/agent.ts index 6eca5a2f..d6fb1da8 100644 --- a/packages/agent/src/types/agent.ts +++ b/packages/agent/src/types/agent.ts @@ -5,9 +5,9 @@ import { MCPConnection } from './mcp' export interface IdentityContext { botId: string - containerId: string - channelIdentityId: string - displayName: string + containerId?: string + channelIdentityId?: string + displayName?: string currentPlatform?: string replyTarget?: string conversationType?: string