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') }}
+
allowGuestDraft = !!val"
/>
-
+
-
+
-
+
@@ -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