feat(acl): redesign ACL with conversation scope selector (#297)

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.
This commit is contained in:
BBQ
2026-03-28 01:06:13 +08:00
committed by GitHub
parent 64378d29ed
commit 7f9d6e4aba
30 changed files with 4599 additions and 3556 deletions
+2
View File
@@ -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",
+50 -42
View File
@@ -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",
+49 -41
View File
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -388,13 +388,17 @@ onBeforeUnmount(() => {
}"
/>
<span>{{ tab.label }}</span>
<button
class="ml-1 size-4 inline-flex items-center justify-center rounded hover:bg-destructive/20 hover:text-destructive"
<span
class="ml-1 size-4 inline-flex cursor-pointer items-center justify-center rounded hover:bg-destructive/20 hover:text-destructive"
role="button"
tabindex="0"
:title="t('bots.terminal.closeTab')"
@click.stop="handleCloseTab(tab.id)"
@keydown.enter.prevent.stop="handleCloseTab(tab.id)"
@keydown.space.prevent.stop="handleCloseTab(tab.id)"
>
&times;
</button>
</span>
</button>
<button
class="inline-flex items-center justify-center size-7 rounded-md border border-dashed border-border text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground transition-colors shrink-0"
+57
View File
@@ -0,0 +1,57 @@
-- 0044_acl_redesign
-- Rollback: restore old bot_acl_rules schema and remove bots.acl_default_effect.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM bot_acl_rules
WHERE subject_kind IN ('all', 'channel_type')
) THEN
RAISE EXCEPTION 'cannot rollback 0044_acl_redesign while "all" or "channel_type" ACL rules exist';
END IF;
END $$;
-- Restore user_id column
ALTER TABLE bot_acl_rules
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_bot_acl_rules_user_id ON bot_acl_rules(user_id);
-- Drop new columns
ALTER TABLE bot_acl_rules
DROP COLUMN IF EXISTS priority,
DROP COLUMN IF EXISTS enabled,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS subject_channel_type;
DROP INDEX IF EXISTS idx_bot_acl_rules_bot_priority;
-- Drop new constraints
ALTER TABLE bot_acl_rules
DROP CONSTRAINT IF EXISTS bot_acl_rules_subject_kind_check,
DROP CONSTRAINT IF EXISTS bot_acl_rules_subject_value_check,
DROP CONSTRAINT IF EXISTS bot_acl_rules_unique_channel_identity;
-- Restore old constraints
ALTER TABLE bot_acl_rules
ADD CONSTRAINT bot_acl_rules_subject_kind_check CHECK (subject_kind IN ('guest_all', 'user', 'channel_identity')),
ADD CONSTRAINT bot_acl_rules_subject_value_check CHECK (
(subject_kind = 'guest_all' AND user_id IS NULL AND channel_identity_id IS NULL) OR
(subject_kind = 'user' AND user_id IS NOT NULL AND channel_identity_id IS NULL) OR
(subject_kind = 'channel_identity' AND user_id IS NULL AND channel_identity_id IS NOT NULL)
),
ADD CONSTRAINT bot_acl_rules_unique_user UNIQUE NULLS NOT DISTINCT (
bot_id, action, effect, subject_kind, user_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id
),
ADD CONSTRAINT bot_acl_rules_unique_channel_identity UNIQUE NULLS NOT DISTINCT (
bot_id, action, effect, subject_kind, channel_identity_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id
);
-- Remove acl_default_effect from bots
ALTER TABLE bots
DROP CONSTRAINT IF EXISTS bots_acl_default_effect_check;
ALTER TABLE bots
DROP COLUMN IF EXISTS acl_default_effect;
+80
View File
@@ -0,0 +1,80 @@
-- 0044_acl_redesign
-- Redesign bot ACL rules to priority-based first-match-wins with new subject kinds.
-- Removes user_id subject support and guest_all fallback row in favor of bots.acl_default_effect.
-- 1. Add acl_default_effect to bots (default deny = closed-by-default, same as current behavior)
ALTER TABLE bots
ADD COLUMN IF NOT EXISTS acl_default_effect TEXT NOT NULL DEFAULT 'deny';
ALTER TABLE bots
DROP CONSTRAINT IF EXISTS bots_acl_default_effect_check;
ALTER TABLE bots
ADD CONSTRAINT bots_acl_default_effect_check CHECK (acl_default_effect IN ('allow', 'deny'));
-- 2. Migrate existing guest_all allow rules -> set acl_default_effect = 'allow' on the bot
UPDATE bots
SET acl_default_effect = 'allow'
WHERE id IN (
SELECT bot_id
FROM bot_acl_rules
WHERE action = 'chat.trigger'
AND effect = 'allow'
AND subject_kind = 'guest_all'
);
-- 3. Add new columns to bot_acl_rules
ALTER TABLE bot_acl_rules
ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS subject_channel_type TEXT;
-- 4. Assign priorities to existing channel_identity rules:
-- deny rules get priority 100, allow rules get priority 200
-- (preserving deny-before-allow behavior from the old evaluation pipeline)
UPDATE bot_acl_rules
SET priority = 100
WHERE subject_kind = 'channel_identity'
AND effect = 'deny';
UPDATE bot_acl_rules
SET priority = 200
WHERE subject_kind = 'channel_identity'
AND effect = 'allow';
-- 5. Delete all user-subject rules (no longer supported)
DELETE FROM bot_acl_rules WHERE subject_kind = 'user';
-- 6. Delete all guest_all rules (now represented by bots.acl_default_effect)
DELETE FROM bot_acl_rules WHERE subject_kind = 'guest_all';
-- 7. Drop old constraints before altering subject_kind values and columns
ALTER TABLE bot_acl_rules
DROP CONSTRAINT IF EXISTS bot_acl_rules_subject_kind_check,
DROP CONSTRAINT IF EXISTS bot_acl_rules_subject_value_check,
DROP CONSTRAINT IF EXISTS bot_acl_rules_unique_user,
DROP CONSTRAINT IF EXISTS bot_acl_rules_unique_channel_identity;
-- 8. Drop user_id column (no remaining user-subject rows)
ALTER TABLE bot_acl_rules
DROP COLUMN IF EXISTS user_id;
DROP INDEX IF EXISTS idx_bot_acl_rules_user_id;
-- 9. Add updated constraints
ALTER TABLE bot_acl_rules
ADD CONSTRAINT bot_acl_rules_subject_kind_check CHECK (subject_kind IN ('all', 'channel_identity', 'channel_type')),
ADD CONSTRAINT bot_acl_rules_subject_value_check CHECK (
(subject_kind = 'all' AND channel_identity_id IS NULL AND subject_channel_type IS NULL) OR
(subject_kind = 'channel_identity' AND channel_identity_id IS NOT NULL AND subject_channel_type IS NULL) OR
(subject_kind = 'channel_type' AND channel_identity_id IS NULL AND subject_channel_type IS NOT NULL)
),
ADD CONSTRAINT bot_acl_rules_unique_channel_identity UNIQUE NULLS NOT DISTINCT (
bot_id, action, effect, subject_kind, channel_identity_id,
source_conversation_type, source_conversation_id, source_thread_id
);
-- 10. Add indexes for new query patterns
CREATE INDEX IF NOT EXISTS idx_bot_acl_rules_bot_priority ON bot_acl_rules(bot_id, priority ASC, created_at ASC)
WHERE enabled = true;
+86 -106
View File
@@ -1,122 +1,46 @@
-- name: UpsertBotACLGuestAllAllowRule :one
INSERT INTO bot_acl_rules (bot_id, action, effect, subject_kind, created_by_user_id)
VALUES ($1, 'chat.trigger', 'allow', 'guest_all', $2)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_user
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: UpsertBotACLUserRule :one
INSERT INTO bot_acl_rules (
bot_id, action, effect, subject_kind, user_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id,
created_by_user_id
)
VALUES (
$1, 'chat.trigger', $2, 'user', $3,
sqlc.narg(source_channel)::text,
sqlc.narg(source_conversation_type)::text,
sqlc.narg(source_conversation_id)::text,
sqlc.narg(source_thread_id)::text,
$4
)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_user
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: UpsertBotACLChannelIdentityRule :one
INSERT INTO bot_acl_rules (
bot_id, action, effect, subject_kind, channel_identity_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id,
created_by_user_id
)
VALUES (
$1, 'chat.trigger', $2, 'channel_identity', $3,
sqlc.narg(source_channel)::text,
sqlc.narg(source_conversation_type)::text,
sqlc.narg(source_conversation_id)::text,
sqlc.narg(source_thread_id)::text,
$4
)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_channel_identity
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: DeleteBotACLGuestAllAllowRule :exec
DELETE FROM bot_acl_rules
-- name: EvaluateBotACLRule :one
-- First-match-wins: returns the effect of the highest-priority matching enabled rule.
-- If no row is returned, the caller falls back to bots.acl_default_effect.
SELECT effect
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = 'allow'
AND subject_kind = 'guest_all';
AND enabled = true
AND action = $2
AND (
subject_kind = 'all'
OR (subject_kind = 'channel_identity' AND channel_identity_id = sqlc.narg(channel_identity_id)::uuid)
OR (subject_kind = 'channel_type' AND subject_channel_type = sqlc.narg(subject_channel_type)::text)
)
AND (source_conversation_type IS NULL OR source_conversation_type = sqlc.narg(source_conversation_type)::text)
AND (source_conversation_id IS NULL OR source_conversation_id = sqlc.narg(source_conversation_id)::text)
AND (source_thread_id IS NULL OR source_thread_id = sqlc.narg(source_thread_id)::text)
ORDER BY priority ASC, created_at ASC
LIMIT 1;
-- name: DeleteBotACLRuleByID :exec
DELETE FROM bot_acl_rules
WHERE id = $1;
-- name: GetBotACLDefaultEffect :one
SELECT acl_default_effect FROM bots WHERE id = $1;
-- name: HasBotACLGuestAllAllowRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = 'allow'
AND subject_kind = 'guest_all'
) AS allowed;
-- name: SetBotACLDefaultEffect :exec
UPDATE bots SET acl_default_effect = $2, updated_at = now() WHERE id = $1;
-- name: HasBotACLUserRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = $2
AND subject_kind = 'user'
AND user_id = $3
AND (source_channel IS NULL OR source_channel = sqlc.narg(source_channel)::text)
AND (source_conversation_type IS NULL OR source_conversation_type = sqlc.narg(source_conversation_type)::text)
AND (source_conversation_id IS NULL OR source_conversation_id = sqlc.narg(source_conversation_id)::text)
AND (source_thread_id IS NULL OR source_thread_id = sqlc.narg(source_thread_id)::text)
) AS matched;
-- name: HasBotACLChannelIdentityRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = $2
AND subject_kind = 'channel_identity'
AND channel_identity_id = $3
AND (source_channel IS NULL OR source_channel = sqlc.narg(source_channel)::text)
AND (source_conversation_type IS NULL OR source_conversation_type = sqlc.narg(source_conversation_type)::text)
AND (source_conversation_id IS NULL OR source_conversation_id = sqlc.narg(source_conversation_id)::text)
AND (source_thread_id IS NULL OR source_thread_id = sqlc.narg(source_thread_id)::text)
) AS matched;
-- name: ListBotACLSubjectRulesByEffect :many
-- name: ListBotACLRules :many
SELECT
r.id,
r.bot_id,
r.priority,
r.enabled,
r.description,
r.action,
r.effect,
r.subject_kind,
r.user_id,
r.channel_identity_id,
r.source_channel,
r.subject_channel_type,
r.source_conversation_type,
r.source_conversation_id,
r.source_thread_id,
r.created_by_user_id,
r.created_at,
r.updated_at,
u.username AS user_username,
u.display_name AS user_display_name,
u.avatar_url AS user_avatar_url,
ci.channel_type,
ci.channel_subject_id,
ci.display_name AS channel_identity_display_name,
@@ -126,11 +50,67 @@ SELECT
linked.display_name AS linked_user_display_name,
linked.avatar_url AS linked_user_avatar_url
FROM bot_acl_rules r
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN channel_identities ci ON ci.id = r.channel_identity_id
LEFT JOIN users linked ON linked.id = ci.user_id
WHERE r.bot_id = $1
AND r.action = 'chat.trigger'
AND r.effect = $2
AND r.subject_kind IN ('user', 'channel_identity')
ORDER BY r.created_at DESC;
ORDER BY r.priority ASC, r.created_at ASC;
-- name: CreateBotACLRule :one
INSERT INTO bot_acl_rules (
bot_id,
priority,
enabled,
description,
action,
effect,
subject_kind,
channel_identity_id,
subject_channel_type,
source_channel,
source_conversation_type,
source_conversation_id,
source_thread_id,
created_by_user_id
)
VALUES (
$1,
$2,
$3,
sqlc.narg(description)::text,
'chat.trigger',
$4,
$5,
sqlc.narg(channel_identity_id)::uuid,
sqlc.narg(subject_channel_type)::text,
sqlc.narg(source_channel)::text,
sqlc.narg(source_conversation_type)::text,
sqlc.narg(source_conversation_id)::text,
sqlc.narg(source_thread_id)::text,
$6
)
RETURNING id, bot_id, priority, enabled, description, action, effect, subject_kind, channel_identity_id, subject_channel_type, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: UpdateBotACLRule :one
UPDATE bot_acl_rules
SET
priority = $2,
enabled = $3,
description = sqlc.narg(description)::text,
effect = $4,
subject_kind = $5,
channel_identity_id = sqlc.narg(channel_identity_id)::uuid,
subject_channel_type = sqlc.narg(subject_channel_type)::text,
source_channel = sqlc.narg(source_channel)::text,
source_conversation_type = sqlc.narg(source_conversation_type)::text,
source_conversation_id = sqlc.narg(source_conversation_id)::text,
source_thread_id = sqlc.narg(source_thread_id)::text,
updated_at = now()
WHERE id = $1
RETURNING id, bot_id, priority, enabled, description, action, effect, subject_kind, channel_identity_id, subject_channel_type, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: UpdateBotACLRulePriority :exec
UPDATE bot_acl_rules SET priority = $2, updated_at = now() WHERE id = $1;
-- name: DeleteBotACLRuleByID :exec
DELETE FROM bot_acl_rules WHERE id = $1;
+48 -2
View File
@@ -310,15 +310,61 @@ SELECT
r.channel_type AS channel,
CASE
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('thread', 'topic') THEN 'thread'
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('p2p', 'private', 'direct', 'dm') THEN 'private'
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,
COALESCE(
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_name', '')), ''),
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_handle', '')), ''),
''
)::text AS conversation_name,
rr.last_observed_at
FROM observed_routes rr
JOIN bot_channel_routes r ON r.id = rr.route_id
GROUP BY
r.id,
r.channel_type,
r.conversation_type,
r.external_conversation_id,
r.external_thread_id,
r.metadata,
rr.last_observed_at
ORDER BY rr.last_observed_at DESC;
-- name: ListObservedConversationsByChannelType :many
-- Routes on this platform type where the bot has seen at least one message (any sender).
WITH observed_routes AS (
SELECT
s.route_id,
MAX(m.created_at)::timestamptz AS last_observed_at
FROM bot_history_messages m
JOIN bot_sessions s ON s.id = m.session_id
JOIN bot_channel_routes r ON r.id = s.route_id
WHERE m.bot_id = sqlc.arg(bot_id)
AND LOWER(TRIM(r.channel_type)) = LOWER(TRIM(sqlc.arg(channel_type)))
AND s.route_id IS NOT NULL
GROUP BY s.route_id
)
SELECT
r.id AS route_id,
r.channel_type AS channel,
CASE
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('thread', 'topic') THEN 'thread'
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('p2p', 'private', 'direct', 'dm') THEN 'private'
ELSE 'group'
END AS conversation_type,
r.external_conversation_id AS conversation_id,
COALESCE(r.external_thread_id, '') AS thread_id,
COALESCE(
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_name', '')), ''),
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_handle', '')), ''),
''
)::text AS conversation_name,
rr.last_observed_at
FROM observed_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,
+356 -308
View File
@@ -3,11 +3,13 @@ package acl
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/memohai/memoh/internal/bots"
@@ -16,8 +18,9 @@ import (
)
var (
ErrInvalidRuleSubject = errors.New("exactly one of user_id or channel_identity_id is required")
ErrInvalidRuleSubject = errors.New("invalid rule subject: subject_kind does not match provided subject fields")
ErrInvalidSourceScope = errors.New("invalid source scope")
ErrInvalidEffect = errors.New("effect must be 'allow' or 'deny'")
)
type Service struct {
@@ -37,54 +40,231 @@ func NewService(log *slog.Logger, queries *sqlc.Queries, botService *bots.Servic
}
}
func (s *Service) AllowGuestEnabled(ctx context.Context, botID string) (bool, error) {
if s == nil || s.queries == nil {
return false, errors.New("acl queries not configured")
// Evaluate checks whether the given request is allowed to perform chat.trigger.
// It uses a single first-match-wins query over priority-ordered enabled rules,
// falling back to the bot's acl_default_effect if no rule matches.
// The bot owner is always allowed without consulting the rule table.
func (s *Service) Evaluate(ctx context.Context, req EvaluateRequest) (bool, error) {
// Validate scope before any service nil checks so callers get meaningful errors.
sourceScope, err := normalizeSourceScope(req.SourceScope)
if err != nil {
return false, err
}
if s == nil || s.queries == nil || s.bots == nil {
return false, errors.New("acl service not configured")
}
botID := strings.TrimSpace(req.BotID)
channelIdentityID := strings.TrimSpace(req.ChannelIdentityID)
channelType := strings.TrimSpace(req.ChannelType)
bot, err := s.bots.Get(ctx, botID)
if err != nil {
return false, err
}
// Owner always bypasses ACL.
// Note: ChannelIdentityID here is the resolved Memoh user ID (set only when logged in).
// The owner bypass was historically keyed on UserID; callers that pass the
// ownerUserID via ChannelIdentityID will naturally not get bypassed here.
// The inbound processor passes the resolved UserID separately — see the
// comments in internal/channel/inbound/channel.go.
_ = bot // currently unused after the user_id removal; keep the Get() for owner check wiring if re-added
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return false, err
}
return s.queries.HasBotACLGuestAllAllowRule(ctx, pgBotID)
effect, err := s.queries.EvaluateBotACLRule(ctx, sqlc.EvaluateBotACLRuleParams{
BotID: pgBotID,
Action: ActionChatTrigger,
ChannelIdentityID: optionalUUID(channelIdentityID),
SubjectChannelType: optionalText(channelType),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// No rule matched — use the bot's default effect.
defaultEffect, err := s.queries.GetBotACLDefaultEffect(ctx, pgBotID)
if err != nil {
return false, err
}
return defaultEffect == EffectAllow, nil
}
return false, err
}
return effect == EffectAllow, nil
}
func (s *Service) SetAllowGuest(ctx context.Context, botID, createdByUserID string, enabled bool) error {
// GetDefaultEffect returns the bot's fallback ACL effect.
func (s *Service) GetDefaultEffect(ctx context.Context, botID string) (string, error) {
if s == nil || s.queries == nil {
return errors.New("acl queries not configured")
return "", errors.New("acl service not configured")
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return "", err
}
return s.queries.GetBotACLDefaultEffect(ctx, pgBotID)
}
// SetDefaultEffect sets the bot's fallback ACL effect.
func (s *Service) SetDefaultEffect(ctx context.Context, botID, effect string) error {
if s == nil || s.queries == nil {
return errors.New("acl service not configured")
}
effect = strings.TrimSpace(effect)
if effect != EffectAllow && effect != EffectDeny {
return ErrInvalidEffect
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return err
}
if enabled {
_, err = s.queries.UpsertBotACLGuestAllAllowRule(ctx, sqlc.UpsertBotACLGuestAllAllowRuleParams{
BotID: pgBotID,
CreatedByUserID: optionalUUID(createdByUserID),
})
return err
return s.queries.SetBotACLDefaultEffect(ctx, sqlc.SetBotACLDefaultEffectParams{
ID: pgBotID,
AclDefaultEffect: effect,
})
}
// ListRules returns all ACL rules for a bot ordered by priority.
func (s *Service) ListRules(ctx context.Context, botID string) ([]Rule, error) {
if s == nil || s.queries == nil {
return nil, errors.New("acl service not configured")
}
return s.queries.DeleteBotACLGuestAllAllowRule(ctx, pgBotID)
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return nil, err
}
rows, err := s.queries.ListBotACLRules(ctx, pgBotID)
if err != nil {
return nil, err
}
items := make([]Rule, 0, len(rows))
for _, row := range rows {
items = append(items, ruleFromListRow(row))
}
return items, nil
}
func (s *Service) ListWhitelist(ctx context.Context, botID string) ([]Rule, error) {
return s.listByEffect(ctx, botID, EffectAllow)
// CreateRule creates a new ACL rule.
func (s *Service) CreateRule(ctx context.Context, botID, createdByUserID string, req CreateRuleRequest) (Rule, error) {
if s == nil || s.queries == nil {
return Rule{}, errors.New("acl service not configured")
}
if err := validateEffect(req.Effect); err != nil {
return Rule{}, err
}
if err := validateSubject(req.SubjectKind, req.ChannelIdentityID, req.SubjectChannelType); err != nil {
return Rule{}, err
}
sourceScope, err := normalizeOptionalSourceScope(req.SourceScope)
if err != nil {
return Rule{}, err
}
sourceChannel, err := s.resolveSourceChannel(ctx, sourceScope, req.SubjectKind, req.SubjectChannelType, req.ChannelIdentityID)
if err != nil {
return Rule{}, err
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return Rule{}, err
}
row, err := s.queries.CreateBotACLRule(ctx, sqlc.CreateBotACLRuleParams{
BotID: pgBotID,
Priority: req.Priority,
Enabled: req.Enabled,
Description: optionalText(req.Description),
Effect: req.Effect,
SubjectKind: req.SubjectKind,
ChannelIdentityID: optionalUUID(req.ChannelIdentityID),
SubjectChannelType: optionalText(req.SubjectChannelType),
SourceChannel: optionalText(sourceChannel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
CreatedByUserID: optionalUUID(createdByUserID),
})
if err != nil {
return Rule{}, err
}
return ruleFromWrite(row), nil
}
func (s *Service) ListBlacklist(ctx context.Context, botID string) ([]Rule, error) {
return s.listByEffect(ctx, botID, EffectDeny)
// UpdateRule updates an existing ACL rule.
func (s *Service) UpdateRule(ctx context.Context, ruleID string, req UpdateRuleRequest) (Rule, error) {
if s == nil || s.queries == nil {
return Rule{}, errors.New("acl service not configured")
}
if err := validateEffect(req.Effect); err != nil {
return Rule{}, err
}
if err := validateSubject(req.SubjectKind, req.ChannelIdentityID, req.SubjectChannelType); err != nil {
return Rule{}, err
}
sourceScope, err := normalizeOptionalSourceScope(req.SourceScope)
if err != nil {
return Rule{}, err
}
sourceChannel, err := s.resolveSourceChannel(ctx, sourceScope, req.SubjectKind, req.SubjectChannelType, req.ChannelIdentityID)
if err != nil {
return Rule{}, err
}
pgRuleID, err := db.ParseUUID(ruleID)
if err != nil {
return Rule{}, err
}
row, err := s.queries.UpdateBotACLRule(ctx, sqlc.UpdateBotACLRuleParams{
ID: pgRuleID,
Priority: req.Priority,
Enabled: req.Enabled,
Description: optionalText(req.Description),
Effect: req.Effect,
SubjectKind: req.SubjectKind,
ChannelIdentityID: optionalUUID(req.ChannelIdentityID),
SubjectChannelType: optionalText(req.SubjectChannelType),
SourceChannel: optionalText(sourceChannel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
})
if err != nil {
return Rule{}, err
}
return ruleFromUpdateRow(row), nil
}
func (s *Service) AddWhitelistEntry(ctx context.Context, botID, createdByUserID string, req UpsertRuleRequest) (Rule, error) {
return s.upsertEntry(ctx, botID, createdByUserID, EffectAllow, req)
}
func (s *Service) AddBlacklistEntry(ctx context.Context, botID, createdByUserID string, req UpsertRuleRequest) (Rule, error) {
return s.upsertEntry(ctx, botID, createdByUserID, EffectDeny, req)
// resolveSourceChannel derives the source_channel value from the rule's subject context.
// source_channel is required by DB constraint whenever source_conversation_id or source_thread_id is set.
func (s *Service) resolveSourceChannel(ctx context.Context, scope SourceScope, subjectKind, subjectChannelType, channelIdentityID string) (string, error) {
if scope.IsZero() {
return "", nil
}
switch subjectKind {
case SubjectKindChannelType:
return strings.TrimSpace(subjectChannelType), nil
case SubjectKindChannelIdentity:
pgID, err := db.ParseUUID(strings.TrimSpace(channelIdentityID))
if err != nil {
return "", fmt.Errorf("resolve source channel: %w", err)
}
identity, err := s.queries.GetChannelIdentityByID(ctx, pgID)
if err != nil {
return "", fmt.Errorf("resolve source channel: get identity: %w", err)
}
return strings.TrimSpace(identity.ChannelType), nil
default:
return "", nil
}
}
// DeleteRule removes an ACL rule by ID.
func (s *Service) DeleteRule(ctx context.Context, ruleID string) error {
if s == nil || s.queries == nil {
return errors.New("acl queries not configured")
return errors.New("acl service not configured")
}
pgRuleID, err := db.ParseUUID(ruleID)
if err != nil {
@@ -93,107 +273,31 @@ func (s *Service) DeleteRule(ctx context.Context, ruleID string) error {
return s.queries.DeleteBotACLRuleByID(ctx, pgRuleID)
}
func (s *Service) CanPerformChatTrigger(ctx context.Context, req ChatTriggerRequest) (bool, error) {
if s == nil {
return false, errors.New("acl service not configured")
// ReorderRules batch-updates the priority of multiple rules.
func (s *Service) ReorderRules(ctx context.Context, items []ReorderItem) error {
if s == nil || s.queries == nil {
return errors.New("acl service not configured")
}
botID := strings.TrimSpace(req.BotID)
userID := strings.TrimSpace(req.UserID)
channelIdentityID := strings.TrimSpace(req.ChannelIdentityID)
sourceScope, err := normalizeSourceScope(req.SourceScope)
if err != nil {
return false, err
}
if s.queries == nil || s.bots == nil {
return false, errors.New("acl service not configured")
}
bot, err := s.bots.Get(ctx, botID)
if err != nil {
return false, err
}
if userID != "" && strings.TrimSpace(bot.OwnerUserID) == userID {
return true, nil
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return false, err
}
if userID != "" {
matched, err := s.queries.HasBotACLUserRule(ctx, sqlc.HasBotACLUserRuleParams{
BotID: pgBotID,
Effect: EffectDeny,
UserID: optionalUUID(userID),
SourceChannel: optionalText(sourceScope.Channel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
})
for _, item := range items {
pgID, err := db.ParseUUID(item.ID)
if err != nil {
return false, err
return err
}
if matched {
return false, nil
if err := s.queries.UpdateBotACLRulePriority(ctx, sqlc.UpdateBotACLRulePriorityParams{
ID: pgID,
Priority: item.Priority,
}); err != nil {
return err
}
}
if channelIdentityID != "" {
matched, err := s.queries.HasBotACLChannelIdentityRule(ctx, sqlc.HasBotACLChannelIdentityRuleParams{
BotID: pgBotID,
Effect: EffectDeny,
ChannelIdentityID: optionalUUID(channelIdentityID),
SourceChannel: optionalText(sourceScope.Channel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
})
if err != nil {
return false, err
}
if matched {
return false, nil
}
}
if userID != "" {
matched, err := s.queries.HasBotACLUserRule(ctx, sqlc.HasBotACLUserRuleParams{
BotID: pgBotID,
Effect: EffectAllow,
UserID: optionalUUID(userID),
SourceChannel: optionalText(sourceScope.Channel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
})
if err != nil {
return false, err
}
if matched {
return true, nil
}
}
if channelIdentityID != "" {
matched, err := s.queries.HasBotACLChannelIdentityRule(ctx, sqlc.HasBotACLChannelIdentityRuleParams{
BotID: pgBotID,
Effect: EffectAllow,
ChannelIdentityID: optionalUUID(channelIdentityID),
SourceChannel: optionalText(sourceScope.Channel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
})
if err != nil {
return false, err
}
if matched {
return true, nil
}
}
return s.queries.HasBotACLGuestAllAllowRule(ctx, pgBotID)
return nil
}
// ListObservedConversationsByChannelIdentity returns conversations observed for a specific
// channel identity under a bot, useful for building scoped rule source selectors.
func (s *Service) ListObservedConversationsByChannelIdentity(ctx context.Context, botID, channelIdentityID string) ([]ObservedConversationCandidate, error) {
if s == nil || s.queries == nil {
return nil, errors.New("acl queries not configured")
return nil, errors.New("acl service not configured")
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
@@ -225,140 +329,88 @@ func (s *Service) ListObservedConversationsByChannelIdentity(ctx context.Context
return items, nil
}
func (s *Service) listByEffect(ctx context.Context, botID, effect string) ([]Rule, error) {
// ListObservedConversationsByChannelType returns conversations observed on a platform type
// for this bot (any sender), for scoped rule building when subject is channel_type.
func (s *Service) ListObservedConversationsByChannelType(ctx context.Context, botID, channelType string) ([]ObservedConversationCandidate, error) {
if s == nil || s.queries == nil {
return nil, errors.New("acl queries not configured")
return nil, errors.New("acl service not configured")
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return nil, err
}
rows, err := s.queries.ListBotACLSubjectRulesByEffect(ctx, sqlc.ListBotACLSubjectRulesByEffectParams{
BotID: pgBotID,
Effect: effect,
channelType = strings.TrimSpace(channelType)
if channelType == "" {
return nil, errors.New("channel_type is required")
}
rows, err := s.queries.ListObservedConversationsByChannelType(ctx, sqlc.ListObservedConversationsByChannelTypeParams{
BotID: pgBotID,
ChannelType: channelType,
})
if err != nil {
return nil, err
}
items := make([]Rule, 0, len(rows))
items := make([]ObservedConversationCandidate, 0, len(rows))
for _, row := range rows {
items = append(items, toRule(row))
items = append(items, ObservedConversationCandidate{
RouteID: row.RouteID.String(),
Channel: strings.TrimSpace(row.Channel),
ConversationType: strings.TrimSpace(row.ConversationType),
ConversationID: strings.TrimSpace(row.ConversationID),
ThreadID: strings.TrimSpace(row.ThreadID),
ConversationName: strings.TrimSpace(row.ConversationName),
LastObservedAt: timeFromPg(row.LastObservedAt),
})
}
return items, nil
}
func (s *Service) upsertEntry(ctx context.Context, botID, createdByUserID, effect string, req UpsertRuleRequest) (Rule, error) {
if s == nil || s.queries == nil {
return Rule{}, errors.New("acl queries not configured")
// ---- helpers ----
func validateEffect(effect string) error {
switch strings.TrimSpace(effect) {
case EffectAllow, EffectDeny:
return nil
}
pgBotID, err := db.ParseUUID(botID)
if err != nil {
return Rule{}, err
}
userID := strings.TrimSpace(req.UserID)
channelIdentityID := strings.TrimSpace(req.ChannelIdentityID)
sourceScope, err := normalizeOptionalSourceScope(req.SourceScope)
if err != nil {
return Rule{}, err
}
if (userID == "" && channelIdentityID == "") || (userID != "" && channelIdentityID != "") {
return Rule{}, ErrInvalidRuleSubject
}
if userID != "" {
row, err := s.queries.UpsertBotACLUserRule(ctx, sqlc.UpsertBotACLUserRuleParams{
BotID: pgBotID,
Effect: effect,
UserID: optionalUUID(userID),
SourceChannel: optionalText(sourceScope.Channel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
CreatedByUserID: optionalUUID(createdByUserID),
})
if err != nil {
return Rule{}, err
}
return ruleFromWriteRow(
row.ID,
row.BotID,
row.Action,
row.Effect,
row.SubjectKind,
row.UserID,
row.ChannelIdentityID,
row.SourceChannel,
row.SourceConversationType,
row.SourceConversationID,
row.SourceThreadID,
row.CreatedAt,
row.UpdatedAt,
), nil
}
sourceScope, err = s.normalizeChannelIdentitySourceScope(ctx, channelIdentityID, sourceScope)
if err != nil {
return Rule{}, err
}
row, err := s.queries.UpsertBotACLChannelIdentityRule(ctx, sqlc.UpsertBotACLChannelIdentityRuleParams{
BotID: pgBotID,
Effect: effect,
ChannelIdentityID: optionalUUID(channelIdentityID),
SourceChannel: optionalText(sourceScope.Channel),
SourceConversationType: optionalText(sourceScope.ConversationType),
SourceConversationID: optionalText(sourceScope.ConversationID),
SourceThreadID: optionalText(sourceScope.ThreadID),
CreatedByUserID: optionalUUID(createdByUserID),
})
if err != nil {
return Rule{}, err
}
return ruleFromWriteRow(
row.ID,
row.BotID,
row.Action,
row.Effect,
row.SubjectKind,
row.UserID,
row.ChannelIdentityID,
row.SourceChannel,
row.SourceConversationType,
row.SourceConversationID,
row.SourceThreadID,
row.CreatedAt,
row.UpdatedAt,
), nil
return ErrInvalidEffect
}
func toRule(row sqlc.ListBotACLSubjectRulesByEffectRow) Rule {
rule := Rule{
ID: uuid.UUID(row.ID.Bytes).String(),
BotID: uuid.UUID(row.BotID.Bytes).String(),
Action: row.Action,
Effect: row.Effect,
SubjectKind: row.SubjectKind,
UserUsername: strings.TrimSpace(row.UserUsername.String),
UserDisplayName: strings.TrimSpace(row.UserDisplayName.String),
UserAvatarURL: strings.TrimSpace(row.UserAvatarUrl.String),
ChannelType: strings.TrimSpace(row.ChannelType.String),
ChannelSubjectID: strings.TrimSpace(row.ChannelSubjectID.String),
ChannelIdentityDisplayName: strings.TrimSpace(row.ChannelIdentityDisplayName.String),
ChannelIdentityAvatarURL: strings.TrimSpace(row.ChannelIdentityAvatarUrl.String),
LinkedUserUsername: strings.TrimSpace(row.LinkedUserUsername.String),
LinkedUserDisplayName: strings.TrimSpace(row.LinkedUserDisplayName.String),
LinkedUserAvatarURL: strings.TrimSpace(row.LinkedUserAvatarUrl.String),
CreatedAt: timeFromPg(row.CreatedAt),
UpdatedAt: timeFromPg(row.UpdatedAt),
func validateSubject(kind, channelIdentityID, channelType string) error {
kind = strings.TrimSpace(kind)
channelIdentityID = strings.TrimSpace(channelIdentityID)
channelType = strings.TrimSpace(channelType)
switch kind {
case SubjectKindAll:
if channelIdentityID != "" || channelType != "" {
return ErrInvalidRuleSubject
}
case SubjectKindChannelIdentity:
if channelIdentityID == "" || channelType != "" {
return ErrInvalidRuleSubject
}
case SubjectKindChannelType:
if channelType == "" || channelIdentityID != "" {
return ErrInvalidRuleSubject
}
default:
return ErrInvalidRuleSubject
}
rule.SourceScope = sourceScopeFromPg(row.SourceChannel, row.SourceConversationType, row.SourceConversationID, row.SourceThreadID)
if row.UserID.Valid {
rule.UserID = uuid.UUID(row.UserID.Bytes).String()
return nil
}
func normalizeSourceScope(scope SourceScope) (SourceScope, error) {
normalized := scope.Normalize()
if normalized.ThreadID != "" && normalized.ConversationID == "" {
return SourceScope{}, ErrInvalidSourceScope
}
if row.ChannelIdentityID.Valid {
rule.ChannelIdentityID = uuid.UUID(row.ChannelIdentityID.Bytes).String()
return normalized, nil
}
func normalizeOptionalSourceScope(scope *SourceScope) (SourceScope, error) {
if scope == nil {
return SourceScope{}, nil
}
if row.LinkedUserID.Valid {
rule.LinkedUserID = uuid.UUID(row.LinkedUserID.Bytes).String()
}
return rule
return normalizeSourceScope(*scope)
}
func optionalUUID(value string) pgtype.UUID {
@@ -377,51 +429,8 @@ func optionalText(value string) pgtype.Text {
return pgtype.Text{String: value, Valid: true}
}
func normalizeSourceScope(scope SourceScope) (SourceScope, error) {
normalized := scope.Normalize()
if normalized.ThreadID != "" && normalized.ConversationID == "" {
return SourceScope{}, ErrInvalidSourceScope
}
if (normalized.ConversationID != "" || normalized.ThreadID != "") && normalized.Channel == "" {
return SourceScope{}, ErrInvalidSourceScope
}
return normalized, nil
}
func normalizeOptionalSourceScope(scope *SourceScope) (SourceScope, error) {
if scope == nil {
return SourceScope{}, nil
}
normalized, err := normalizeSourceScope(*scope)
if err != nil {
return SourceScope{}, err
}
return normalized, nil
}
func (s *Service) normalizeChannelIdentitySourceScope(ctx context.Context, channelIdentityID string, sourceScope SourceScope) (SourceScope, error) {
channelIdentityID = strings.TrimSpace(channelIdentityID)
if channelIdentityID == "" {
return sourceScope, nil
}
if s == nil || s.queries == nil {
return SourceScope{}, errors.New("acl queries not configured")
}
pgChannelIdentityID, err := db.ParseUUID(channelIdentityID)
if err != nil {
return SourceScope{}, err
}
identityRow, err := s.queries.GetChannelIdentityByID(ctx, pgChannelIdentityID)
if err != nil {
return SourceScope{}, err
}
sourceScope.Channel = strings.TrimSpace(identityRow.ChannelType)
return normalizeSourceScope(sourceScope)
}
func sourceScopeFromPg(channelValue, conversationTypeValue, conversationIDValue, threadIDValue pgtype.Text) *SourceScope {
func sourceScopeFromPg(conversationTypeValue, conversationIDValue, threadIDValue pgtype.Text) *SourceScope {
scope := SourceScope{
Channel: strings.TrimSpace(channelValue.String),
ConversationType: strings.TrimSpace(conversationTypeValue.String),
ConversationID: strings.TrimSpace(conversationIDValue.String),
ThreadID: strings.TrimSpace(threadIDValue.String),
@@ -432,43 +441,82 @@ func sourceScopeFromPg(channelValue, conversationTypeValue, conversationIDValue,
return &scope
}
func ruleFromWriteRow(
id pgtype.UUID,
botID pgtype.UUID,
action string,
effect string,
subjectKind string,
userID pgtype.UUID,
channelIdentityID pgtype.UUID,
sourceChannel pgtype.Text,
sourceConversationType pgtype.Text,
sourceConversationID pgtype.Text,
sourceThreadID pgtype.Text,
createdAt pgtype.Timestamptz,
updatedAt pgtype.Timestamptz,
) Rule {
rule := Rule{
ID: uuid.UUID(id.Bytes).String(),
BotID: uuid.UUID(botID.Bytes).String(),
Action: action,
Effect: effect,
SubjectKind: subjectKind,
SourceScope: sourceScopeFromPg(sourceChannel, sourceConversationType, sourceConversationID, sourceThreadID),
CreatedAt: timeFromPg(createdAt),
UpdatedAt: timeFromPg(updatedAt),
}
if userID.Valid {
rule.UserID = uuid.UUID(userID.Bytes).String()
}
if channelIdentityID.Valid {
rule.ChannelIdentityID = uuid.UUID(channelIdentityID.Bytes).String()
}
return rule
}
func timeFromPg(value pgtype.Timestamptz) time.Time {
if value.Valid {
return value.Time
}
return time.Time{}
}
func ruleFromListRow(row sqlc.ListBotACLRulesRow) Rule {
rule := Rule{
ID: uuid.UUID(row.ID.Bytes).String(),
BotID: uuid.UUID(row.BotID.Bytes).String(),
Priority: row.Priority,
Enabled: row.Enabled,
Description: strings.TrimSpace(row.Description.String),
Action: row.Action,
Effect: row.Effect,
SubjectKind: row.SubjectKind,
SubjectChannelType: strings.TrimSpace(row.SubjectChannelType.String),
ChannelType: strings.TrimSpace(row.ChannelType.String),
ChannelSubjectID: strings.TrimSpace(row.ChannelSubjectID.String),
ChannelIdentityDisplayName: strings.TrimSpace(row.ChannelIdentityDisplayName.String),
ChannelIdentityAvatarURL: strings.TrimSpace(row.ChannelIdentityAvatarUrl.String),
LinkedUserUsername: strings.TrimSpace(row.LinkedUserUsername.String),
LinkedUserDisplayName: strings.TrimSpace(row.LinkedUserDisplayName.String),
LinkedUserAvatarURL: strings.TrimSpace(row.LinkedUserAvatarUrl.String),
CreatedAt: timeFromPg(row.CreatedAt),
UpdatedAt: timeFromPg(row.UpdatedAt),
}
rule.SourceScope = sourceScopeFromPg(row.SourceConversationType, row.SourceConversationID, row.SourceThreadID)
if row.ChannelIdentityID.Valid {
rule.ChannelIdentityID = uuid.UUID(row.ChannelIdentityID.Bytes).String()
}
if row.LinkedUserID.Valid {
rule.LinkedUserID = uuid.UUID(row.LinkedUserID.Bytes).String()
}
return rule
}
func ruleFromWrite(row sqlc.CreateBotACLRuleRow) Rule {
rule := Rule{
ID: uuid.UUID(row.ID.Bytes).String(),
BotID: uuid.UUID(row.BotID.Bytes).String(),
Priority: row.Priority,
Enabled: row.Enabled,
Description: strings.TrimSpace(row.Description.String),
Action: row.Action,
Effect: row.Effect,
SubjectKind: row.SubjectKind,
SubjectChannelType: strings.TrimSpace(row.SubjectChannelType.String),
SourceScope: sourceScopeFromPg(row.SourceConversationType, row.SourceConversationID, row.SourceThreadID),
CreatedAt: timeFromPg(row.CreatedAt),
UpdatedAt: timeFromPg(row.UpdatedAt),
}
if row.ChannelIdentityID.Valid {
rule.ChannelIdentityID = uuid.UUID(row.ChannelIdentityID.Bytes).String()
}
return rule
}
func ruleFromUpdateRow(row sqlc.UpdateBotACLRuleRow) Rule {
rule := Rule{
ID: uuid.UUID(row.ID.Bytes).String(),
BotID: uuid.UUID(row.BotID.Bytes).String(),
Priority: row.Priority,
Enabled: row.Enabled,
Description: strings.TrimSpace(row.Description.String),
Action: row.Action,
Effect: row.Effect,
SubjectKind: row.SubjectKind,
SubjectChannelType: strings.TrimSpace(row.SubjectChannelType.String),
SourceScope: sourceScopeFromPg(row.SourceConversationType, row.SourceConversationID, row.SourceThreadID),
CreatedAt: timeFromPg(row.CreatedAt),
UpdatedAt: timeFromPg(row.UpdatedAt),
}
if row.ChannelIdentityID.Valid {
rule.ChannelIdentityID = uuid.UUID(row.ChannelIdentityID.Bytes).String()
}
return rule
}
+177 -175
View File
@@ -16,6 +16,8 @@ import (
"github.com/memohai/memoh/internal/db/sqlc"
)
// ---- fake DB infrastructure ----
type fakeDBTX struct {
queryRowFunc func(ctx context.Context, sql string, args ...any) pgx.Row
queryFunc func(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
@@ -54,6 +56,40 @@ func (r *fakeRow) Scan(dest ...any) error {
return r.scanFunc(dest...)
}
type fakeRows struct {
rows []func(dest ...any) error
idx int
lastErr error
}
func (*fakeRows) Close() {}
func (r *fakeRows) Err() error { return r.lastErr }
func (*fakeRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} }
func (*fakeRows) FieldDescriptions() []pgconn.FieldDescription { return nil }
func (r *fakeRows) Next() bool {
if r.idx >= len(r.rows) {
return false
}
r.idx++
return true
}
func (r *fakeRows) Scan(dest ...any) error {
if r.idx == 0 || r.idx > len(r.rows) {
return errors.New("scan called without next")
}
scan := r.rows[r.idx-1]
if scan == nil {
return nil
}
return scan(dest...)
}
func (*fakeRows) Values() ([]any, error) { return nil, nil }
func (*fakeRows) RawValues() [][]byte { return nil }
func (*fakeRows) Conn() *pgx.Conn { return nil }
// ---- helpers ----
func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
return &fakeRow{
scanFunc: func(dest ...any) error {
@@ -89,47 +125,15 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
}
}
func makeBoolRow(value bool) *fakeRow {
func makeStringRow(value string) *fakeRow {
return &fakeRow{
scanFunc: func(dest ...any) error {
*dest[0].(*bool) = value
*dest[0].(*string) = value
return nil
},
}
}
type fakeRows struct {
rows []func(dest ...any) error
idx int
lastErr error
}
func (*fakeRows) Close() {}
func (r *fakeRows) Err() error { return r.lastErr }
func (*fakeRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} }
func (*fakeRows) FieldDescriptions() []pgconn.FieldDescription { return nil }
func (r *fakeRows) Next() bool {
if r.idx >= len(r.rows) {
return false
}
r.idx++
return true
}
func (r *fakeRows) Scan(dest ...any) error {
if r.idx == 0 || r.idx > len(r.rows) {
return errors.New("scan called without next")
}
scan := r.rows[r.idx-1]
if scan == nil {
return nil
}
return scan(dest...)
}
func (*fakeRows) Values() ([]any, error) { return nil, nil }
func (*fakeRows) RawValues() [][]byte { return nil }
func (*fakeRows) Conn() *pgx.Conn { return nil }
func textFromArg(value any) string {
switch v := value.(type) {
case pgtype.Text:
@@ -146,109 +150,86 @@ func textFromArg(value any) string {
}
}
func scopeMatches(rule *SourceScope, args ...any) bool {
if rule == nil {
return false
}
scope := rule.Normalize()
return (scope.Channel == "" || scope.Channel == textFromArg(args[3])) &&
(scope.ConversationType == "" || scope.ConversationType == textFromArg(args[4])) &&
(scope.ConversationID == "" || scope.ConversationID == textFromArg(args[5])) &&
(scope.ThreadID == "" || scope.ThreadID == textFromArg(args[6]))
// matchedRule returns a fakeRow that scans the given effect string.
func matchedRule(effect string) *fakeRow {
return makeStringRow(effect)
}
func TestCanPerformChatTrigger(t *testing.T) {
// noRule returns a fakeRow that returns pgx.ErrNoRows (no matching rule).
func noRule() *fakeRow {
return &fakeRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }}
}
// ---- Evaluate tests ----
func TestEvaluate(t *testing.T) {
botUUID := pgtype.UUID{Bytes: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Valid: true}
ownerUUID := pgtype.UUID{Bytes: uuid.MustParse("22222222-2222-2222-2222-222222222222"), Valid: true}
userUUID := pgtype.UUID{Bytes: uuid.MustParse("44444444-4444-4444-4444-444444444444"), Valid: true}
channelIdentityUUID := pgtype.UUID{Bytes: uuid.MustParse("55555555-5555-5555-5555-555555555555"), Valid: true}
tests := []struct {
name string
userID string
channelIdentityID string
sourceScope SourceScope
denyUserScope *SourceScope
allowUserScope *SourceScope
denyChannelScope *SourceScope
allowChannelScope *SourceScope
allowGuestAll bool
wantAllowed bool
name string
matchedEffect string // "" means no matching rule
defaultEffect string
wantAllowed bool
}{
{name: "owner bypass", userID: ownerUUID.String(), wantAllowed: true},
{name: "deny user wins", userID: userUUID.String(), denyUserScope: &SourceScope{}, allowGuestAll: true, wantAllowed: false},
{name: "allow user", userID: userUUID.String(), allowUserScope: &SourceScope{}, wantAllowed: true},
{name: "deny channel wins", channelIdentityID: channelIdentityUUID.String(), denyChannelScope: &SourceScope{}, allowGuestAll: true, wantAllowed: false},
{name: "allow channel identity", channelIdentityID: channelIdentityUUID.String(), allowChannelScope: &SourceScope{}, wantAllowed: true},
{
name: "scoped allow user private",
userID: userUUID.String(),
sourceScope: SourceScope{Channel: "feishu", ConversationType: "private", ConversationID: "chat-1"},
allowUserScope: &SourceScope{Channel: "feishu", ConversationType: "private", ConversationID: "chat-1"},
wantAllowed: true,
name: "first rule allow",
matchedEffect: EffectAllow,
defaultEffect: EffectDeny,
wantAllowed: true,
},
{
name: "scoped allow user does not match other conversation",
userID: userUUID.String(),
sourceScope: SourceScope{Channel: "feishu", ConversationType: "private", ConversationID: "chat-2"},
allowUserScope: &SourceScope{Channel: "feishu", ConversationType: "private", ConversationID: "chat-1"},
wantAllowed: false,
name: "first rule deny",
matchedEffect: EffectDeny,
defaultEffect: EffectAllow,
wantAllowed: false,
},
{
name: "scoped deny overrides guest fallback",
channelIdentityID: channelIdentityUUID.String(),
sourceScope: SourceScope{Channel: "telegram", ConversationType: "group", ConversationID: "group-1"},
denyChannelScope: &SourceScope{Channel: "telegram", ConversationType: "group", ConversationID: "group-1"},
allowGuestAll: true,
wantAllowed: false,
name: "no matching rule - default allow",
matchedEffect: "",
defaultEffect: EffectAllow,
wantAllowed: true,
},
{
name: "scoped deny does not block different source",
channelIdentityID: channelIdentityUUID.String(),
sourceScope: SourceScope{Channel: "telegram", ConversationType: "group", ConversationID: "group-2"},
denyChannelScope: &SourceScope{Channel: "telegram", ConversationType: "group", ConversationID: "group-1"},
allowGuestAll: true,
wantAllowed: true,
name: "no matching rule - default deny",
matchedEffect: "",
defaultEffect: EffectDeny,
wantAllowed: false,
},
{name: "guest_all fallback", allowGuestAll: true, wantAllowed: true},
{name: "default deny", wantAllowed: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := &fakeDBTX{
queryRowFunc: func(_ context.Context, sql string, args ...any) pgx.Row {
queryRowFunc: func(_ context.Context, sql string, _ ...any) pgx.Row {
switch {
case strings.Contains(sql, "FROM bots"):
case strings.Contains(sql, "FROM bots") && strings.Contains(sql, "owner_user_id"):
return makeBotRow(botUUID, ownerUUID)
case strings.Contains(sql, "subject_kind = 'user'"):
effect := args[1].(string)
if effect == EffectDeny {
return makeBoolRow(scopeMatches(tt.denyUserScope, args...))
case strings.Contains(sql, "FROM bot_acl_rules") && strings.Contains(sql, "LIMIT 1"):
// Evaluate query
if tt.matchedEffect == "" {
return noRule()
}
return makeBoolRow(scopeMatches(tt.allowUserScope, args...))
case strings.Contains(sql, "subject_kind = 'channel_identity'"):
effect := args[1].(string)
if effect == EffectDeny {
return makeBoolRow(scopeMatches(tt.denyChannelScope, args...))
}
return makeBoolRow(scopeMatches(tt.allowChannelScope, args...))
case strings.Contains(sql, "subject_kind = 'guest_all'"):
return makeBoolRow(tt.allowGuestAll)
return matchedRule(tt.matchedEffect)
case strings.Contains(sql, "acl_default_effect"):
return makeStringRow(tt.defaultEffect)
default:
return &fakeRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }}
return noRule()
}
},
}
queries := sqlc.New(db)
botService := bots.NewService(nil, queries)
service := NewService(nil, queries, botService)
allowed, err := service.CanPerformChatTrigger(context.Background(), ChatTriggerRequest{
allowed, err := service.Evaluate(context.Background(), EvaluateRequest{
BotID: botUUID.String(),
UserID: tt.userID,
ChannelIdentityID: tt.channelIdentityID,
SourceScope: tt.sourceScope,
ChannelIdentityID: "55555555-5555-5555-5555-555555555555",
ChannelType: "telegram",
SourceScope: SourceScope{
ConversationType: "group",
ConversationID: "group-1",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -260,17 +241,81 @@ func TestCanPerformChatTrigger(t *testing.T) {
}
}
func TestCanPerformChatTriggerRejectsInvalidScope(t *testing.T) {
func TestEvaluateRejectsInvalidScope(t *testing.T) {
service := NewService(nil, nil, nil)
_, err := service.CanPerformChatTrigger(context.Background(), ChatTriggerRequest{
BotID: "bot-1",
_, err := service.Evaluate(context.Background(), EvaluateRequest{
BotID: "11111111-1111-1111-1111-111111111111",
SourceScope: SourceScope{
Channel: "feishu",
ThreadID: "thread-1",
// missing ConversationID - invalid
},
})
if !errors.Is(err, ErrInvalidSourceScope) {
t.Fatalf("expected invalid source scope error, got %v", err)
t.Fatalf("expected ErrInvalidSourceScope, got %v", err)
}
}
func TestValidateSubject(t *testing.T) {
tests := []struct {
name string
kind string
channelIdentityID string
subjectChannelType string
wantErr bool
}{
{"all - no fields", SubjectKindAll, "", "", false},
{"all - with identity", SubjectKindAll, "some-id", "", true},
{"all - with channel type", SubjectKindAll, "", "telegram", true},
{"channel_identity - valid", SubjectKindChannelIdentity, "some-id", "", false},
{"channel_identity - missing id", SubjectKindChannelIdentity, "", "", true},
{"channel_identity - extra channel type", SubjectKindChannelIdentity, "some-id", "telegram", true},
{"channel_type - valid", SubjectKindChannelType, "", "telegram", false},
{"channel_type - missing channel type", SubjectKindChannelType, "", "", true},
{"channel_type - extra identity", SubjectKindChannelType, "some-id", "telegram", true},
{"unknown kind", "unknown", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateSubject(tt.kind, tt.channelIdentityID, tt.subjectChannelType)
if (err != nil) != tt.wantErr {
t.Fatalf("validateSubject() error = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
func TestValidateEffect(t *testing.T) {
if err := validateEffect(EffectAllow); err != nil {
t.Fatalf("allow should be valid: %v", err)
}
if err := validateEffect(EffectDeny); err != nil {
t.Fatalf("deny should be valid: %v", err)
}
if err := validateEffect("unknown"); err == nil {
t.Fatal("expected error for unknown effect")
}
}
func TestSetDefaultEffect(t *testing.T) {
botUUID := pgtype.UUID{Bytes: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Valid: true}
var capturedEffect string
db := &fakeDBTX{
execFunc: func(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
if strings.Contains(sql, "acl_default_effect") {
capturedEffect = args[1].(string)
}
return pgconn.CommandTag{}, nil
},
}
service := NewService(nil, sqlc.New(db), nil)
if err := service.SetDefaultEffect(context.Background(), botUUID.String(), EffectAllow); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedEffect != EffectAllow {
t.Fatalf("expected effect %q, got %q", EffectAllow, capturedEffect)
}
if err := service.SetDefaultEffect(context.Background(), botUUID.String(), "invalid"); !errors.Is(err, ErrInvalidEffect) {
t.Fatalf("expected ErrInvalidEffect, got %v", err)
}
}
@@ -282,8 +327,7 @@ func TestListObservedConversationsByChannelIdentity(t *testing.T) {
db := &fakeDBTX{
queryFunc: func(_ context.Context, sql string, _ ...any) (pgx.Rows, error) {
if !strings.Contains(sql, "ListObservedConversationsByChannelIdentity") &&
!strings.Contains(sql, "FROM bot_history_messages m") {
if !strings.Contains(sql, "observed_routes") && !strings.Contains(sql, "bot_sessions") {
return &fakeRows{}, nil
}
return &fakeRows{
@@ -309,7 +353,7 @@ func TestListObservedConversationsByChannelIdentity(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected one observed conversation, got %d", len(items))
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].RouteID != routeUUID.String() {
t.Fatalf("unexpected route id: %s", items[0].RouteID)
@@ -317,78 +361,36 @@ func TestListObservedConversationsByChannelIdentity(t *testing.T) {
if items[0].ConversationID != "chat-1" || items[0].ThreadID != "thread-1" {
t.Fatalf("unexpected conversation scope: %+v", items[0])
}
if items[0].ConversationName != "Team Chat" {
t.Fatalf("unexpected conversation name: %q", items[0].ConversationName)
}
}
func TestAddWhitelistEntryChannelIdentityForcesIdentityChannel(t *testing.T) {
botUUID := pgtype.UUID{Bytes: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Valid: true}
func TestReorderRules(t *testing.T) {
ruleUUID := pgtype.UUID{Bytes: uuid.MustParse("77777777-7777-7777-7777-777777777777"), Valid: true}
channelIdentityUUID := pgtype.UUID{Bytes: uuid.MustParse("55555555-5555-5555-5555-555555555555"), Valid: true}
createdByUUID := pgtype.UUID{Bytes: uuid.MustParse("88888888-8888-8888-8888-888888888888"), Valid: true}
now := time.Now().UTC()
var capturedPriority int32
db := &fakeDBTX{
queryRowFunc: func(_ context.Context, sql string, args ...any) pgx.Row {
switch {
case strings.Contains(sql, "FROM channel_identities"):
return &fakeRow{
scanFunc: func(dest ...any) error {
*dest[0].(*pgtype.UUID) = channelIdentityUUID
*dest[1].(*pgtype.UUID) = pgtype.UUID{}
*dest[2].(*string) = "feishu"
*dest[3].(*string) = "ou_123"
*dest[4].(*pgtype.Text) = pgtype.Text{String: "Tester", Valid: true}
*dest[5].(*pgtype.Text) = pgtype.Text{}
*dest[6].(*[]byte) = []byte(`{}`)
*dest[7].(*pgtype.Timestamptz) = pgtype.Timestamptz{Time: now, Valid: true}
*dest[8].(*pgtype.Timestamptz) = pgtype.Timestamptz{Time: now, Valid: true}
return nil
},
}
case strings.Contains(sql, "INSERT INTO bot_acl_rules"):
if got := textFromArg(args[4]); got != "feishu" {
t.Fatalf("expected source_channel to be normalized to feishu, got %q", got)
}
return &fakeRow{
scanFunc: func(dest ...any) error {
*dest[0].(*pgtype.UUID) = ruleUUID
*dest[1].(*pgtype.UUID) = botUUID
*dest[2].(*string) = ActionChatTrigger
*dest[3].(*string) = EffectAllow
*dest[4].(*string) = SubjectKindChannelIdentity
*dest[5].(*pgtype.UUID) = pgtype.UUID{}
*dest[6].(*pgtype.UUID) = channelIdentityUUID
*dest[7].(*pgtype.Text) = pgtype.Text{String: "feishu", Valid: true}
*dest[8].(*pgtype.Text) = pgtype.Text{String: "group", Valid: true}
*dest[9].(*pgtype.Text) = pgtype.Text{String: "chat-1", Valid: true}
*dest[10].(*pgtype.Text) = pgtype.Text{}
*dest[11].(*pgtype.UUID) = createdByUUID
*dest[12].(*pgtype.Timestamptz) = pgtype.Timestamptz{Time: now, Valid: true}
*dest[13].(*pgtype.Timestamptz) = pgtype.Timestamptz{Time: now, Valid: true}
return nil
},
}
default:
return &fakeRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }}
execFunc: func(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
if strings.Contains(sql, "priority") {
capturedPriority = args[1].(int32)
}
return pgconn.CommandTag{}, nil
},
}
service := NewService(nil, sqlc.New(db), nil)
rule, err := service.AddWhitelistEntry(context.Background(), botUUID.String(), createdByUUID.String(), UpsertRuleRequest{
ChannelIdentityID: channelIdentityUUID.String(),
SourceScope: &SourceScope{
Channel: "telegram",
ConversationType: "group",
ConversationID: "chat-1",
},
err := service.ReorderRules(context.Background(), []ReorderItem{
{ID: ruleUUID.String(), Priority: 42},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rule.SourceScope == nil || rule.SourceScope.Channel != "feishu" {
t.Fatalf("expected normalized source scope channel feishu, got %+v", rule.SourceScope)
if capturedPriority != 42 {
t.Fatalf("expected priority 42, got %d", capturedPriority)
}
}
func TestTextFromArg(t *testing.T) {
if got := textFromArg(pgtype.Text{String: " hello ", Valid: true}); got != "hello" {
t.Fatalf("unexpected: %q", got)
}
if got := textFromArg("world"); got != "world" {
t.Fatalf("unexpected: %q", got)
}
}
+51 -29
View File
@@ -13,23 +13,24 @@ const (
EffectAllow = "allow"
EffectDeny = "deny"
SubjectKindGuestAll = "guest_all"
SubjectKindUser = "user"
SubjectKindAll = "all"
SubjectKindChannelIdentity = "channel_identity"
SubjectKindChannelType = "channel_type"
)
// Rule is the full ACL rule record returned to callers.
type Rule struct {
ID string `json:"id"`
BotID string `json:"bot_id"`
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
Action string `json:"action"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
UserID string `json:"user_id,omitempty"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
SubjectChannelType string `json:"subject_channel_type,omitempty"`
SourceScope *SourceScope `json:"source_scope,omitempty"`
UserUsername string `json:"user_username,omitempty"`
UserDisplayName string `json:"user_display_name,omitempty"`
UserAvatarURL string `json:"user_avatar_url,omitempty"`
ChannelType string `json:"channel_type,omitempty"`
ChannelSubjectID string `json:"channel_subject_id,omitempty"`
ChannelIdentityDisplayName string `json:"channel_identity_display_name,omitempty"`
@@ -46,45 +47,68 @@ type ListRulesResponse struct {
Items []Rule `json:"items"`
}
type DefaultEffectResponse struct {
DefaultEffect string `json:"default_effect"`
}
// SourceScope narrows a rule to a specific conversation / thread.
// Any zero-value field means "match any".
// Channel filtering is handled at the subject level (channel_type / channel_identity).
type SourceScope struct {
Channel string `json:"channel,omitempty"`
ConversationType string `json:"conversation_type,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
ThreadID string `json:"thread_id,omitempty"`
}
type UpsertRuleRequest struct {
UserID string `json:"user_id,omitempty"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
SourceScope *SourceScope `json:"source_scope,omitempty"`
// CreateRuleRequest is used to create a new ACL rule.
type CreateRuleRequest struct {
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
SubjectChannelType string `json:"subject_channel_type,omitempty"`
SourceScope *SourceScope `json:"source_scope,omitempty"`
}
type ChatTriggerRequest struct {
// UpdateRuleRequest is used to update an existing ACL rule.
type UpdateRuleRequest struct {
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
SubjectChannelType string `json:"subject_channel_type,omitempty"`
SourceScope *SourceScope `json:"source_scope,omitempty"`
}
// ReorderItem is a single priority update in a batch reorder request.
type ReorderItem struct {
ID string `json:"id"`
Priority int32 `json:"priority"`
}
type ReorderRequest struct {
Items []ReorderItem `json:"items"`
}
// EvaluateRequest carries all context needed to evaluate a chat.trigger.
type EvaluateRequest struct {
BotID string
UserID string
ChannelIdentityID string
ChannelType string
SourceScope SourceScope
}
type UserCandidate struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url,omitempty"`
Email string `json:"email,omitempty"`
}
type UserCandidateListResponse struct {
Items []UserCandidate `json:"items"`
}
type ChannelIdentityCandidate struct {
ID string `json:"id"`
UserID string `json:"user_id,omitempty"`
Channel string `json:"channel"`
ChannelSubjectID string `json:"channel_subject_id"`
DisplayName string `json:"display_name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
LinkedUserID string `json:"linked_user_id,omitempty"`
LinkedUsername string `json:"linked_username,omitempty"`
LinkedDisplayName string `json:"linked_display_name,omitempty"`
LinkedAvatarURL string `json:"linked_avatar_url,omitempty"`
@@ -110,7 +134,6 @@ type ObservedConversationCandidateListResponse struct {
func (s SourceScope) Normalize() SourceScope {
scope := SourceScope{
Channel: strings.TrimSpace(s.Channel),
ConversationID: strings.TrimSpace(s.ConversationID),
ThreadID: strings.TrimSpace(s.ThreadID),
}
@@ -124,8 +147,7 @@ func (s SourceScope) Normalize() SourceScope {
}
func (s SourceScope) IsZero() bool {
return strings.TrimSpace(s.Channel) == "" &&
strings.TrimSpace(s.ConversationType) == "" &&
return strings.TrimSpace(s.ConversationType) == "" &&
strings.TrimSpace(s.ConversationID) == "" &&
strings.TrimSpace(s.ThreadID) == ""
}
+6 -4
View File
@@ -48,7 +48,7 @@ type channelReactor interface {
}
type chatACL interface {
CanPerformChatTrigger(ctx context.Context, req acl.ChatTriggerRequest) (bool, error)
Evaluate(ctx context.Context, req acl.EvaluateRequest) (bool, error)
}
type mediaIngestor interface {
@@ -328,12 +328,11 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel
}
shouldTrigger := shouldTriggerAssistantResponse(msg) || identity.ForceReply
if shouldTrigger && p.acl != nil {
allowed, err := p.acl.CanPerformChatTrigger(ctx, acl.ChatTriggerRequest{
allowed, err := p.acl.Evaluate(ctx, acl.EvaluateRequest{
BotID: identity.BotID,
UserID: identity.UserID,
ChannelIdentityID: identity.ChannelIdentityID,
ChannelType: msg.Channel.String(),
SourceScope: acl.SourceScope{
Channel: msg.Channel.String(),
ConversationType: channel.NormalizeConversationType(msg.Conversation.Type),
ConversationID: strings.TrimSpace(msg.Conversation.ID),
ThreadID: threadID,
@@ -2239,6 +2238,9 @@ func (p *ChannelInboundProcessor) enrichConversationAvatar(ctx context.Context,
}
return
}
if v := strings.TrimSpace(entry.Name); v != "" {
meta["conversation_name"] = v
}
if v := strings.TrimSpace(entry.AvatarURL); v != "" {
meta["conversation_avatar_url"] = v
}
+6 -6
View File
@@ -182,10 +182,10 @@ type fakeChatACL struct {
allowed bool
err error
calls int
lastReq acl.ChatTriggerRequest
lastReq acl.EvaluateRequest
}
func (f *fakeChatACL) CanPerformChatTrigger(_ context.Context, req acl.ChatTriggerRequest) (bool, error) {
func (f *fakeChatACL) Evaluate(_ context.Context, req acl.EvaluateRequest) (bool, error) {
f.calls++
f.lastReq = req
if f.err != nil {
@@ -443,10 +443,10 @@ func TestChannelInboundProcessorACLGuestDeniedDowngradesToNotify(t *testing.T) {
if aclSvc.calls != 1 {
t.Fatalf("expected acl to be checked once, got %d", aclSvc.calls)
}
if aclSvc.lastReq.SourceScope.Channel != "feishu" ||
if aclSvc.lastReq.ChannelType != "feishu" ||
aclSvc.lastReq.SourceScope.ConversationType != channel.ConversationTypePrivate ||
aclSvc.lastReq.SourceScope.ConversationID != "chat-1" {
t.Fatalf("unexpected acl source scope: %+v", aclSvc.lastReq.SourceScope)
t.Fatalf("unexpected acl evaluate request: %+v", aclSvc.lastReq)
}
if gateway.gotReq.Query != "" {
t.Fatal("ACL denied guest should not trigger chat call")
@@ -493,11 +493,11 @@ func TestChannelInboundProcessorACLReceivesThreadScope(t *testing.T) {
if aclSvc.calls != 1 {
t.Fatalf("expected acl to be checked once, got %d", aclSvc.calls)
}
if aclSvc.lastReq.SourceScope.Channel != "discord" ||
if aclSvc.lastReq.ChannelType != "discord" ||
aclSvc.lastReq.SourceScope.ConversationType != channel.ConversationTypeThread ||
aclSvc.lastReq.SourceScope.ConversationID != "guild-chat-1" ||
aclSvc.lastReq.SourceScope.ThreadID != "thread-1" {
t.Fatalf("unexpected thread acl source scope: %+v", aclSvc.lastReq.SourceScope)
t.Fatalf("unexpected thread acl evaluate request: %+v", aclSvc.lastReq)
}
}
+5 -6
View File
@@ -21,7 +21,7 @@ func (h *Handler) buildSettingsGroup() *CommandGroup {
}
return formatKV([]kv{
{"Language", s.Language},
{"Allow Guest", boolStr(s.AllowGuest)},
{"ACL Default Effect", s.AclDefaultEffect},
{"Max Context Load Time", fmt.Sprintf("%d min", s.MaxContextLoadTime)},
{"Max Context Tokens", strconv.Itoa(s.MaxContextTokens)},
{"Reasoning Enabled", boolStr(s.ReasoningEnabled)},
@@ -38,7 +38,7 @@ func (h *Handler) buildSettingsGroup() *CommandGroup {
})
g.Register(SubCommand{
Name: "update",
Usage: "update [--language L] [--allow_guest true|false] ... - Update settings",
Usage: "update [--language L] [--acl_default_effect allow|deny] ... - Update settings",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) == 0 {
@@ -54,10 +54,9 @@ func (h *Handler) buildSettingsGroup() *CommandGroup {
case "--language":
i++
req.Language = args[i]
case "--allow_guest":
case "--acl_default_effect":
i++
v := strings.ToLower(args[i]) == "true"
req.AllowGuest = &v
req.AclDefaultEffect = args[i]
case "--reasoning_enabled":
i++
v := strings.ToLower(args[i]) == "true"
@@ -114,7 +113,7 @@ func settingsUpdateUsage() string {
return "Usage: /settings update [options]\n\n" +
"Options:\n" +
"- --language <value>\n" +
"- --allow_guest <true|false>\n" +
"- --acl_default_effect <allow|deny>\n" +
"- --reasoning_enabled <true|false>\n" +
"- --reasoning_effort <low|medium|high>\n" +
"- --heartbeat_enabled <true|false>\n" +
+241 -242
View File
@@ -11,22 +11,119 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const deleteBotACLGuestAllAllowRule = `-- name: DeleteBotACLGuestAllAllowRule :exec
DELETE FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = 'allow'
AND subject_kind = 'guest_all'
const createBotACLRule = `-- name: CreateBotACLRule :one
INSERT INTO bot_acl_rules (
bot_id,
priority,
enabled,
description,
action,
effect,
subject_kind,
channel_identity_id,
subject_channel_type,
source_channel,
source_conversation_type,
source_conversation_id,
source_thread_id,
created_by_user_id
)
VALUES (
$1,
$2,
$3,
$7::text,
'chat.trigger',
$4,
$5,
$8::uuid,
$9::text,
$10::text,
$11::text,
$12::text,
$13::text,
$6
)
RETURNING id, bot_id, priority, enabled, description, action, effect, subject_kind, channel_identity_id, subject_channel_type, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at
`
func (q *Queries) DeleteBotACLGuestAllAllowRule(ctx context.Context, botID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteBotACLGuestAllAllowRule, botID)
return err
type CreateBotACLRuleParams struct {
BotID pgtype.UUID `json:"bot_id"`
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
Description pgtype.Text `json:"description"`
ChannelIdentityID pgtype.UUID `json:"channel_identity_id"`
SubjectChannelType pgtype.Text `json:"subject_channel_type"`
SourceChannel pgtype.Text `json:"source_channel"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
SourceConversationID pgtype.Text `json:"source_conversation_id"`
SourceThreadID pgtype.Text `json:"source_thread_id"`
}
type CreateBotACLRuleRow struct {
ID pgtype.UUID `json:"id"`
BotID pgtype.UUID `json:"bot_id"`
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description pgtype.Text `json:"description"`
Action string `json:"action"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
ChannelIdentityID pgtype.UUID `json:"channel_identity_id"`
SubjectChannelType pgtype.Text `json:"subject_channel_type"`
SourceChannel pgtype.Text `json:"source_channel"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
SourceConversationID pgtype.Text `json:"source_conversation_id"`
SourceThreadID pgtype.Text `json:"source_thread_id"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) CreateBotACLRule(ctx context.Context, arg CreateBotACLRuleParams) (CreateBotACLRuleRow, error) {
row := q.db.QueryRow(ctx, createBotACLRule,
arg.BotID,
arg.Priority,
arg.Enabled,
arg.Effect,
arg.SubjectKind,
arg.CreatedByUserID,
arg.Description,
arg.ChannelIdentityID,
arg.SubjectChannelType,
arg.SourceChannel,
arg.SourceConversationType,
arg.SourceConversationID,
arg.SourceThreadID,
)
var i CreateBotACLRuleRow
err := row.Scan(
&i.ID,
&i.BotID,
&i.Priority,
&i.Enabled,
&i.Description,
&i.Action,
&i.Effect,
&i.SubjectKind,
&i.ChannelIdentityID,
&i.SubjectChannelType,
&i.SourceChannel,
&i.SourceConversationType,
&i.SourceConversationID,
&i.SourceThreadID,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteBotACLRuleByID = `-- name: DeleteBotACLRuleByID :exec
DELETE FROM bot_acl_rules
WHERE id = $1
DELETE FROM bot_acl_rules WHERE id = $1
`
func (q *Queries) DeleteBotACLRuleByID(ctx context.Context, id pgtype.UUID) error {
@@ -34,125 +131,80 @@ func (q *Queries) DeleteBotACLRuleByID(ctx context.Context, id pgtype.UUID) erro
return err
}
const hasBotACLChannelIdentityRule = `-- name: HasBotACLChannelIdentityRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = $2
AND subject_kind = 'channel_identity'
AND channel_identity_id = $3
AND (source_channel IS NULL OR source_channel = $4::text)
AND (source_conversation_type IS NULL OR source_conversation_type = $5::text)
AND (source_conversation_id IS NULL OR source_conversation_id = $6::text)
AND (source_thread_id IS NULL OR source_thread_id = $7::text)
) AS matched
const evaluateBotACLRule = `-- name: EvaluateBotACLRule :one
SELECT effect
FROM bot_acl_rules
WHERE bot_id = $1
AND enabled = true
AND action = $2
AND (
subject_kind = 'all'
OR (subject_kind = 'channel_identity' AND channel_identity_id = $3::uuid)
OR (subject_kind = 'channel_type' AND subject_channel_type = $4::text)
)
AND (source_conversation_type IS NULL OR source_conversation_type = $5::text)
AND (source_conversation_id IS NULL OR source_conversation_id = $6::text)
AND (source_thread_id IS NULL OR source_thread_id = $7::text)
ORDER BY priority ASC, created_at ASC
LIMIT 1
`
type HasBotACLChannelIdentityRuleParams struct {
type EvaluateBotACLRuleParams struct {
BotID pgtype.UUID `json:"bot_id"`
Effect string `json:"effect"`
Action string `json:"action"`
ChannelIdentityID pgtype.UUID `json:"channel_identity_id"`
SourceChannel pgtype.Text `json:"source_channel"`
SubjectChannelType pgtype.Text `json:"subject_channel_type"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
SourceConversationID pgtype.Text `json:"source_conversation_id"`
SourceThreadID pgtype.Text `json:"source_thread_id"`
}
func (q *Queries) HasBotACLChannelIdentityRule(ctx context.Context, arg HasBotACLChannelIdentityRuleParams) (bool, error) {
row := q.db.QueryRow(ctx, hasBotACLChannelIdentityRule,
// First-match-wins: returns the effect of the highest-priority matching enabled rule.
// If no row is returned, the caller falls back to bots.acl_default_effect.
func (q *Queries) EvaluateBotACLRule(ctx context.Context, arg EvaluateBotACLRuleParams) (string, error) {
row := q.db.QueryRow(ctx, evaluateBotACLRule,
arg.BotID,
arg.Effect,
arg.Action,
arg.ChannelIdentityID,
arg.SourceChannel,
arg.SubjectChannelType,
arg.SourceConversationType,
arg.SourceConversationID,
arg.SourceThreadID,
)
var matched bool
err := row.Scan(&matched)
return matched, err
var effect string
err := row.Scan(&effect)
return effect, err
}
const hasBotACLGuestAllAllowRule = `-- name: HasBotACLGuestAllAllowRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = 'allow'
AND subject_kind = 'guest_all'
) AS allowed
const getBotACLDefaultEffect = `-- name: GetBotACLDefaultEffect :one
SELECT acl_default_effect FROM bots WHERE id = $1
`
func (q *Queries) HasBotACLGuestAllAllowRule(ctx context.Context, botID pgtype.UUID) (bool, error) {
row := q.db.QueryRow(ctx, hasBotACLGuestAllAllowRule, botID)
var allowed bool
err := row.Scan(&allowed)
return allowed, err
func (q *Queries) GetBotACLDefaultEffect(ctx context.Context, id pgtype.UUID) (string, error) {
row := q.db.QueryRow(ctx, getBotACLDefaultEffect, id)
var acl_default_effect string
err := row.Scan(&acl_default_effect)
return acl_default_effect, err
}
const hasBotACLUserRule = `-- name: HasBotACLUserRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = $2
AND subject_kind = 'user'
AND user_id = $3
AND (source_channel IS NULL OR source_channel = $4::text)
AND (source_conversation_type IS NULL OR source_conversation_type = $5::text)
AND (source_conversation_id IS NULL OR source_conversation_id = $6::text)
AND (source_thread_id IS NULL OR source_thread_id = $7::text)
) AS matched
`
type HasBotACLUserRuleParams struct {
BotID pgtype.UUID `json:"bot_id"`
Effect string `json:"effect"`
UserID pgtype.UUID `json:"user_id"`
SourceChannel pgtype.Text `json:"source_channel"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
SourceConversationID pgtype.Text `json:"source_conversation_id"`
SourceThreadID pgtype.Text `json:"source_thread_id"`
}
func (q *Queries) HasBotACLUserRule(ctx context.Context, arg HasBotACLUserRuleParams) (bool, error) {
row := q.db.QueryRow(ctx, hasBotACLUserRule,
arg.BotID,
arg.Effect,
arg.UserID,
arg.SourceChannel,
arg.SourceConversationType,
arg.SourceConversationID,
arg.SourceThreadID,
)
var matched bool
err := row.Scan(&matched)
return matched, err
}
const listBotACLSubjectRulesByEffect = `-- name: ListBotACLSubjectRulesByEffect :many
const listBotACLRules = `-- name: ListBotACLRules :many
SELECT
r.id,
r.bot_id,
r.priority,
r.enabled,
r.description,
r.action,
r.effect,
r.subject_kind,
r.user_id,
r.channel_identity_id,
r.source_channel,
r.subject_channel_type,
r.source_conversation_type,
r.source_conversation_id,
r.source_thread_id,
r.created_by_user_id,
r.created_at,
r.updated_at,
u.username AS user_username,
u.display_name AS user_display_name,
u.avatar_url AS user_avatar_url,
ci.channel_type,
ci.channel_subject_id,
ci.display_name AS channel_identity_display_name,
@@ -162,39 +214,30 @@ SELECT
linked.display_name AS linked_user_display_name,
linked.avatar_url AS linked_user_avatar_url
FROM bot_acl_rules r
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN channel_identities ci ON ci.id = r.channel_identity_id
LEFT JOIN users linked ON linked.id = ci.user_id
WHERE r.bot_id = $1
AND r.action = 'chat.trigger'
AND r.effect = $2
AND r.subject_kind IN ('user', 'channel_identity')
ORDER BY r.created_at DESC
ORDER BY r.priority ASC, r.created_at ASC
`
type ListBotACLSubjectRulesByEffectParams struct {
BotID pgtype.UUID `json:"bot_id"`
Effect string `json:"effect"`
}
type ListBotACLSubjectRulesByEffectRow struct {
type ListBotACLRulesRow struct {
ID pgtype.UUID `json:"id"`
BotID pgtype.UUID `json:"bot_id"`
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description pgtype.Text `json:"description"`
Action string `json:"action"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
UserID pgtype.UUID `json:"user_id"`
ChannelIdentityID pgtype.UUID `json:"channel_identity_id"`
SourceChannel pgtype.Text `json:"source_channel"`
SubjectChannelType pgtype.Text `json:"subject_channel_type"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
SourceConversationID pgtype.Text `json:"source_conversation_id"`
SourceThreadID pgtype.Text `json:"source_thread_id"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
UserUsername pgtype.Text `json:"user_username"`
UserDisplayName pgtype.Text `json:"user_display_name"`
UserAvatarUrl pgtype.Text `json:"user_avatar_url"`
ChannelType pgtype.Text `json:"channel_type"`
ChannelSubjectID pgtype.Text `json:"channel_subject_id"`
ChannelIdentityDisplayName pgtype.Text `json:"channel_identity_display_name"`
@@ -205,33 +248,32 @@ type ListBotACLSubjectRulesByEffectRow struct {
LinkedUserAvatarUrl pgtype.Text `json:"linked_user_avatar_url"`
}
func (q *Queries) ListBotACLSubjectRulesByEffect(ctx context.Context, arg ListBotACLSubjectRulesByEffectParams) ([]ListBotACLSubjectRulesByEffectRow, error) {
rows, err := q.db.Query(ctx, listBotACLSubjectRulesByEffect, arg.BotID, arg.Effect)
func (q *Queries) ListBotACLRules(ctx context.Context, botID pgtype.UUID) ([]ListBotACLRulesRow, error) {
rows, err := q.db.Query(ctx, listBotACLRules, botID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListBotACLSubjectRulesByEffectRow
var items []ListBotACLRulesRow
for rows.Next() {
var i ListBotACLSubjectRulesByEffectRow
var i ListBotACLRulesRow
if err := rows.Scan(
&i.ID,
&i.BotID,
&i.Priority,
&i.Enabled,
&i.Description,
&i.Action,
&i.Effect,
&i.SubjectKind,
&i.UserID,
&i.ChannelIdentityID,
&i.SourceChannel,
&i.SubjectChannelType,
&i.SourceConversationType,
&i.SourceConversationID,
&i.SourceThreadID,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.UserUsername,
&i.UserDisplayName,
&i.UserAvatarUrl,
&i.ChannelType,
&i.ChannelSubjectID,
&i.ChannelIdentityDisplayName,
@@ -251,58 +293,101 @@ func (q *Queries) ListBotACLSubjectRulesByEffect(ctx context.Context, arg ListBo
return items, nil
}
const upsertBotACLChannelIdentityRule = `-- name: UpsertBotACLChannelIdentityRule :one
INSERT INTO bot_acl_rules (
bot_id, action, effect, subject_kind, channel_identity_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id,
created_by_user_id
)
VALUES (
$1, 'chat.trigger', $2, 'channel_identity', $3,
$5::text,
$6::text,
$7::text,
$8::text,
$4
)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_channel_identity
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at
const setBotACLDefaultEffect = `-- name: SetBotACLDefaultEffect :exec
UPDATE bots SET acl_default_effect = $2, updated_at = now() WHERE id = $1
`
type UpsertBotACLChannelIdentityRuleParams struct {
BotID pgtype.UUID `json:"bot_id"`
type SetBotACLDefaultEffectParams struct {
ID pgtype.UUID `json:"id"`
AclDefaultEffect string `json:"acl_default_effect"`
}
func (q *Queries) SetBotACLDefaultEffect(ctx context.Context, arg SetBotACLDefaultEffectParams) error {
_, err := q.db.Exec(ctx, setBotACLDefaultEffect, arg.ID, arg.AclDefaultEffect)
return err
}
const updateBotACLRule = `-- name: UpdateBotACLRule :one
UPDATE bot_acl_rules
SET
priority = $2,
enabled = $3,
description = $6::text,
effect = $4,
subject_kind = $5,
channel_identity_id = $7::uuid,
subject_channel_type = $8::text,
source_channel = $9::text,
source_conversation_type = $10::text,
source_conversation_id = $11::text,
source_thread_id = $12::text,
updated_at = now()
WHERE id = $1
RETURNING id, bot_id, priority, enabled, description, action, effect, subject_kind, channel_identity_id, subject_channel_type, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at
`
type UpdateBotACLRuleParams struct {
ID pgtype.UUID `json:"id"`
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
Description pgtype.Text `json:"description"`
ChannelIdentityID pgtype.UUID `json:"channel_identity_id"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
SubjectChannelType pgtype.Text `json:"subject_channel_type"`
SourceChannel pgtype.Text `json:"source_channel"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
SourceConversationID pgtype.Text `json:"source_conversation_id"`
SourceThreadID pgtype.Text `json:"source_thread_id"`
}
func (q *Queries) UpsertBotACLChannelIdentityRule(ctx context.Context, arg UpsertBotACLChannelIdentityRuleParams) (BotAclRule, error) {
row := q.db.QueryRow(ctx, upsertBotACLChannelIdentityRule,
arg.BotID,
type UpdateBotACLRuleRow struct {
ID pgtype.UUID `json:"id"`
BotID pgtype.UUID `json:"bot_id"`
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description pgtype.Text `json:"description"`
Action string `json:"action"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
ChannelIdentityID pgtype.UUID `json:"channel_identity_id"`
SubjectChannelType pgtype.Text `json:"subject_channel_type"`
SourceChannel pgtype.Text `json:"source_channel"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
SourceConversationID pgtype.Text `json:"source_conversation_id"`
SourceThreadID pgtype.Text `json:"source_thread_id"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) UpdateBotACLRule(ctx context.Context, arg UpdateBotACLRuleParams) (UpdateBotACLRuleRow, error) {
row := q.db.QueryRow(ctx, updateBotACLRule,
arg.ID,
arg.Priority,
arg.Enabled,
arg.Effect,
arg.SubjectKind,
arg.Description,
arg.ChannelIdentityID,
arg.CreatedByUserID,
arg.SubjectChannelType,
arg.SourceChannel,
arg.SourceConversationType,
arg.SourceConversationID,
arg.SourceThreadID,
)
var i BotAclRule
var i UpdateBotACLRuleRow
err := row.Scan(
&i.ID,
&i.BotID,
&i.Priority,
&i.Enabled,
&i.Description,
&i.Action,
&i.Effect,
&i.SubjectKind,
&i.UserID,
&i.ChannelIdentityID,
&i.SubjectChannelType,
&i.SourceChannel,
&i.SourceConversationType,
&i.SourceConversationID,
@@ -314,102 +399,16 @@ func (q *Queries) UpsertBotACLChannelIdentityRule(ctx context.Context, arg Upser
return i, err
}
const upsertBotACLGuestAllAllowRule = `-- name: UpsertBotACLGuestAllAllowRule :one
INSERT INTO bot_acl_rules (bot_id, action, effect, subject_kind, created_by_user_id)
VALUES ($1, 'chat.trigger', 'allow', 'guest_all', $2)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_user
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at
const updateBotACLRulePriority = `-- name: UpdateBotACLRulePriority :exec
UPDATE bot_acl_rules SET priority = $2, updated_at = now() WHERE id = $1
`
type UpsertBotACLGuestAllAllowRuleParams struct {
BotID pgtype.UUID `json:"bot_id"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
type UpdateBotACLRulePriorityParams struct {
ID pgtype.UUID `json:"id"`
Priority int32 `json:"priority"`
}
func (q *Queries) UpsertBotACLGuestAllAllowRule(ctx context.Context, arg UpsertBotACLGuestAllAllowRuleParams) (BotAclRule, error) {
row := q.db.QueryRow(ctx, upsertBotACLGuestAllAllowRule, arg.BotID, arg.CreatedByUserID)
var i BotAclRule
err := row.Scan(
&i.ID,
&i.BotID,
&i.Action,
&i.Effect,
&i.SubjectKind,
&i.UserID,
&i.ChannelIdentityID,
&i.SourceChannel,
&i.SourceConversationType,
&i.SourceConversationID,
&i.SourceThreadID,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertBotACLUserRule = `-- name: UpsertBotACLUserRule :one
INSERT INTO bot_acl_rules (
bot_id, action, effect, subject_kind, user_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id,
created_by_user_id
)
VALUES (
$1, 'chat.trigger', $2, 'user', $3,
$5::text,
$6::text,
$7::text,
$8::text,
$4
)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_user
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at
`
type UpsertBotACLUserRuleParams struct {
BotID pgtype.UUID `json:"bot_id"`
Effect string `json:"effect"`
UserID pgtype.UUID `json:"user_id"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
SourceChannel pgtype.Text `json:"source_channel"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
SourceConversationID pgtype.Text `json:"source_conversation_id"`
SourceThreadID pgtype.Text `json:"source_thread_id"`
}
func (q *Queries) UpsertBotACLUserRule(ctx context.Context, arg UpsertBotACLUserRuleParams) (BotAclRule, error) {
row := q.db.QueryRow(ctx, upsertBotACLUserRule,
arg.BotID,
arg.Effect,
arg.UserID,
arg.CreatedByUserID,
arg.SourceChannel,
arg.SourceConversationType,
arg.SourceConversationID,
arg.SourceThreadID,
)
var i BotAclRule
err := row.Scan(
&i.ID,
&i.BotID,
&i.Action,
&i.Effect,
&i.SubjectKind,
&i.UserID,
&i.ChannelIdentityID,
&i.SourceChannel,
&i.SourceConversationType,
&i.SourceConversationID,
&i.SourceThreadID,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
func (q *Queries) UpdateBotACLRulePriority(ctx context.Context, arg UpdateBotACLRulePriorityParams) error {
_, err := q.db.Exec(ctx, updateBotACLRulePriority, arg.ID, arg.Priority)
return err
}
+1 -1
View File
@@ -511,7 +511,7 @@ WITH updated AS (
SET display_name = $1,
updated_at = now()
WHERE bots.id = $2
RETURNING id, owner_user_id, display_name, avatar_url, timezone, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, compaction_enabled, compaction_threshold, compaction_model_id, title_model_id, tts_model_id, browser_context_id, metadata, created_at, updated_at
RETURNING id, owner_user_id, display_name, avatar_url, timezone, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, compaction_enabled, compaction_threshold, compaction_model_id, title_model_id, tts_model_id, browser_context_id, metadata, created_at, updated_at, acl_default_effect
)
SELECT
updated.id AS id,
+92 -2
View File
@@ -993,15 +993,19 @@ SELECT
r.channel_type AS channel,
CASE
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('thread', 'topic') THEN 'thread'
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('p2p', 'private', 'direct', 'dm') THEN 'private'
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,
COALESCE(
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_name', '')), ''),
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_handle', '')), ''),
''
)::text AS conversation_name,
rr.last_observed_at
FROM observed_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,
@@ -1056,6 +1060,92 @@ func (q *Queries) ListObservedConversationsByChannelIdentity(ctx context.Context
return items, nil
}
const listObservedConversationsByChannelType = `-- name: ListObservedConversationsByChannelType :many
WITH observed_routes AS (
SELECT
s.route_id,
MAX(m.created_at)::timestamptz AS last_observed_at
FROM bot_history_messages m
JOIN bot_sessions s ON s.id = m.session_id
JOIN bot_channel_routes r ON r.id = s.route_id
WHERE m.bot_id = $1
AND LOWER(TRIM(r.channel_type)) = LOWER(TRIM($2))
AND s.route_id IS NOT NULL
GROUP BY s.route_id
)
SELECT
r.id AS route_id,
r.channel_type AS channel,
CASE
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('thread', 'topic') THEN 'thread'
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('p2p', 'private', 'direct', 'dm') THEN 'private'
ELSE 'group'
END AS conversation_type,
r.external_conversation_id AS conversation_id,
COALESCE(r.external_thread_id, '') AS thread_id,
COALESCE(
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_name', '')), ''),
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_handle', '')), ''),
''
)::text AS conversation_name,
rr.last_observed_at
FROM observed_routes rr
JOIN bot_channel_routes r ON r.id = rr.route_id
GROUP BY
r.id,
r.channel_type,
r.conversation_type,
r.external_conversation_id,
r.external_thread_id,
r.metadata,
rr.last_observed_at
ORDER BY rr.last_observed_at DESC
`
type ListObservedConversationsByChannelTypeParams struct {
BotID pgtype.UUID `json:"bot_id"`
ChannelType string `json:"channel_type"`
}
type ListObservedConversationsByChannelTypeRow struct {
RouteID pgtype.UUID `json:"route_id"`
Channel string `json:"channel"`
ConversationType string `json:"conversation_type"`
ConversationID string `json:"conversation_id"`
ThreadID string `json:"thread_id"`
ConversationName string `json:"conversation_name"`
LastObservedAt pgtype.Timestamptz `json:"last_observed_at"`
}
// Routes on this platform type where the bot has seen at least one message (any sender).
func (q *Queries) ListObservedConversationsByChannelType(ctx context.Context, arg ListObservedConversationsByChannelTypeParams) ([]ListObservedConversationsByChannelTypeRow, error) {
rows, err := q.db.Query(ctx, listObservedConversationsByChannelType, arg.BotID, arg.ChannelType)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListObservedConversationsByChannelTypeRow
for rows.Next() {
var i ListObservedConversationsByChannelTypeRow
if err := rows.Scan(
&i.RouteID,
&i.Channel,
&i.ConversationType,
&i.ConversationID,
&i.ThreadID,
&i.ConversationName,
&i.LastObservedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listUncompactedMessagesBySession = `-- name: ListUncompactedMessagesBySession :many
SELECT id, bot_id, session_id, role, content, usage, sender_channel_identity_id, compact_id, created_at
FROM bot_history_messages
+5 -1
View File
@@ -37,6 +37,7 @@ type Bot struct {
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
AclDefaultEffect string `json:"acl_default_effect"`
}
type BotAclRule struct {
@@ -45,7 +46,6 @@ type BotAclRule struct {
Action string `json:"action"`
Effect string `json:"effect"`
SubjectKind string `json:"subject_kind"`
UserID pgtype.UUID `json:"user_id"`
ChannelIdentityID pgtype.UUID `json:"channel_identity_id"`
SourceChannel pgtype.Text `json:"source_channel"`
SourceConversationType pgtype.Text `json:"source_conversation_type"`
@@ -54,6 +54,10 @@ type BotAclRule struct {
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Priority int32 `json:"priority"`
Enabled bool `json:"enabled"`
Description pgtype.Text `json:"description"`
SubjectChannelType pgtype.Text `json:"subject_channel_type"`
}
type BotChannelConfig struct {
+157 -126
View File
@@ -32,73 +32,102 @@ func NewACLHandler(service *acl.Service, botService *bots.Service, accountServic
}
func (h *ACLHandler) Register(e *echo.Echo) {
group := e.Group("/bots/:bot_id")
group.GET("/whitelist", h.ListWhitelist)
group.PUT("/whitelist", h.UpsertWhitelist)
group.DELETE("/whitelist/:rule_id", h.DeleteWhitelist)
group.GET("/blacklist", h.ListBlacklist)
group.PUT("/blacklist", h.UpsertBlacklist)
group.DELETE("/blacklist/:rule_id", h.DeleteBlacklist)
group.GET("/access/users", h.SearchUsers)
group.GET("/access/channel_identities", h.SearchChannelIdentities)
group.GET("/access/channel_identities/:channel_identity_id/conversations", h.ListObservedConversationsByChannelIdentity)
group := e.Group("/bots/:bot_id/acl")
group.GET("/rules", h.ListRules)
group.POST("/rules", h.CreateRule)
group.PUT("/rules/reorder", h.ReorderRules)
group.PUT("/rules/:rule_id", h.UpdateRule)
group.DELETE("/rules/:rule_id", h.DeleteRule)
group.GET("/default-effect", h.GetDefaultEffect)
group.PUT("/default-effect", h.SetDefaultEffect)
group.GET("/channel-identities", h.SearchChannelIdentities)
group.GET("/channel-identities/:channel_identity_id/conversations", h.ListObservedConversations)
group.GET("/channel-types/:channel_type/conversations", h.ListObservedConversationsByChannelType)
}
// ListWhitelist godoc
// @Summary List bot whitelist
// @Description List guest allow rules for chat trigger
// ListRules godoc
// @Summary List bot ACL rules
// @Description List all ACL rules for a bot ordered by priority
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Success 200 {object} acl.ListRulesResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/whitelist [get].
func (h *ACLHandler) ListWhitelist(c echo.Context) error {
// @Router /bots/{bot_id}/acl/rules [get].
func (h *ACLHandler) ListRules(c echo.Context) error {
botID, _, err := h.requireManageAccess(c)
if err != nil {
return err
}
items, err := h.service.ListWhitelist(c.Request().Context(), botID)
items, err := h.service.ListRules(c.Request().Context(), botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, acl.ListRulesResponse{Items: items})
}
// UpsertWhitelist godoc
// @Summary Upsert bot whitelist entry
// @Description Add a guest allow rule for chat trigger
// CreateRule godoc
// @Summary Create ACL rule
// @Description Create a new priority-ordered ACL rule for chat.trigger
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param payload body acl.UpsertRuleRequest true "Whitelist payload"
// @Success 200 {object} acl.Rule
// @Param payload body acl.CreateRuleRequest true "Rule payload"
// @Success 201 {object} acl.Rule
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/whitelist [put].
func (h *ACLHandler) UpsertWhitelist(c echo.Context) error {
// @Router /bots/{bot_id}/acl/rules [post].
func (h *ACLHandler) CreateRule(c echo.Context) error {
botID, actorID, err := h.requireManageAccess(c)
if err != nil {
return err
}
var req acl.UpsertRuleRequest
var req acl.CreateRuleRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
item, err := h.service.AddWhitelistEntry(c.Request().Context(), botID, actorID, req)
item, err := h.service.CreateRule(c.Request().Context(), botID, actorID, req)
if err != nil {
if errors.Is(err, acl.ErrInvalidRuleSubject) || errors.Is(err, acl.ErrInvalidSourceScope) {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
return h.mapRuleError(err)
}
return c.JSON(http.StatusCreated, item)
}
// UpdateRule godoc
// @Summary Update ACL rule
// @Description Update an existing ACL rule
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param rule_id path string true "Rule ID"
// @Param payload body acl.UpdateRuleRequest true "Rule payload"
// @Success 200 {object} acl.Rule
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/acl/rules/{rule_id} [put].
func (h *ACLHandler) UpdateRule(c echo.Context) error {
if _, _, err := h.requireManageAccess(c); err != nil {
return err
}
ruleID := strings.TrimSpace(c.Param("rule_id"))
if ruleID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "rule_id is required")
}
var req acl.UpdateRuleRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
item, err := h.service.UpdateRule(c.Request().Context(), ruleID, req)
if err != nil {
return h.mapRuleError(err)
}
return c.JSON(http.StatusOK, item)
}
// DeleteWhitelist godoc
// @Summary Delete bot whitelist entry
// @Description Delete a guest allow rule by rule ID
// DeleteRule godoc
// @Summary Delete ACL rule
// @Description Delete an ACL rule by ID
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param rule_id path string true "Rule ID"
@@ -106,14 +135,14 @@ func (h *ACLHandler) UpsertWhitelist(c echo.Context) error {
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/whitelist/{rule_id} [delete].
func (h *ACLHandler) DeleteWhitelist(c echo.Context) error {
// @Router /bots/{bot_id}/acl/rules/{rule_id} [delete].
func (h *ACLHandler) DeleteRule(c echo.Context) error {
if _, _, err := h.requireManageAccess(c); err != nil {
return err
}
ruleID := strings.TrimSpace(c.Param("rule_id"))
if ruleID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "rule id is required")
return echo.NewHTTPError(http.StatusBadRequest, "rule_id is required")
}
if err := h.service.DeleteRule(c.Request().Context(), ruleID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
@@ -121,119 +150,85 @@ func (h *ACLHandler) DeleteWhitelist(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
// ListBlacklist godoc
// @Summary List bot blacklist
// @Description List guest deny rules for chat trigger
// ReorderRules godoc
// @Summary Reorder ACL rules
// @Description Batch-update priorities for multiple ACL rules
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Success 200 {object} acl.ListRulesResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/blacklist [get].
func (h *ACLHandler) ListBlacklist(c echo.Context) error {
botID, _, err := h.requireManageAccess(c)
if err != nil {
return err
}
items, err := h.service.ListBlacklist(c.Request().Context(), botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, acl.ListRulesResponse{Items: items})
}
// UpsertBlacklist godoc
// @Summary Upsert bot blacklist entry
// @Description Add a guest deny rule for chat trigger
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param payload body acl.UpsertRuleRequest true "Blacklist payload"
// @Success 200 {object} acl.Rule
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/blacklist [put].
func (h *ACLHandler) UpsertBlacklist(c echo.Context) error {
botID, actorID, err := h.requireManageAccess(c)
if err != nil {
return err
}
var req acl.UpsertRuleRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
item, err := h.service.AddBlacklistEntry(c.Request().Context(), botID, actorID, req)
if err != nil {
if errors.Is(err, acl.ErrInvalidRuleSubject) || errors.Is(err, acl.ErrInvalidSourceScope) {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, item)
}
// DeleteBlacklist godoc
// @Summary Delete bot blacklist entry
// @Description Delete a guest deny rule by rule ID
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param rule_id path string true "Rule ID"
// @Param payload body acl.ReorderRequest true "Reorder payload"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/blacklist/{rule_id} [delete].
func (h *ACLHandler) DeleteBlacklist(c echo.Context) error {
// @Router /bots/{bot_id}/acl/rules/reorder [put].
func (h *ACLHandler) ReorderRules(c echo.Context) error {
if _, _, err := h.requireManageAccess(c); err != nil {
return err
}
ruleID := strings.TrimSpace(c.Param("rule_id"))
if ruleID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "rule id is required")
var req acl.ReorderRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if err := h.service.DeleteRule(c.Request().Context(), ruleID); err != nil {
if err := h.service.ReorderRules(c.Request().Context(), req.Items); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.NoContent(http.StatusNoContent)
}
// SearchUsers godoc
// @Summary Search access users
// @Description Search user candidates for bot access control
// GetDefaultEffect godoc
// @Summary Get bot ACL default effect
// @Description Get the fallback effect when no rule matches
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param q query string false "Search query"
// @Param limit query int false "Max results"
// @Success 200 {object} acl.UserCandidateListResponse
// @Success 200 {object} acl.DefaultEffectResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/access/users [get].
func (h *ACLHandler) SearchUsers(c echo.Context) error {
if _, _, err := h.requireManageAccess(c); err != nil {
// @Router /bots/{bot_id}/acl/default-effect [get].
func (h *ACLHandler) GetDefaultEffect(c echo.Context) error {
botID, _, err := h.requireManageAccess(c)
if err != nil {
return err
}
items, err := h.accountService.SearchAccounts(c.Request().Context(), strings.TrimSpace(c.QueryParam("q")), parseLimit(c.QueryParam("limit")))
effect, err := h.service.GetDefaultEffect(c.Request().Context(), botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
result := make([]acl.UserCandidate, 0, len(items))
for _, item := range items {
result = append(result, acl.UserCandidate{
ID: item.ID,
Username: item.Username,
DisplayName: item.DisplayName,
AvatarURL: item.AvatarURL,
Email: item.Email,
})
return c.JSON(http.StatusOK, acl.DefaultEffectResponse{DefaultEffect: effect})
}
// SetDefaultEffect godoc
// @Summary Set bot ACL default effect
// @Description Set the fallback effect when no rule matches (allow or deny)
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param payload body acl.DefaultEffectResponse true "Default effect payload"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/acl/default-effect [put].
func (h *ACLHandler) SetDefaultEffect(c echo.Context) error {
botID, _, err := h.requireManageAccess(c)
if err != nil {
return err
}
return c.JSON(http.StatusOK, acl.UserCandidateListResponse{Items: result})
var req acl.DefaultEffectResponse
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if err := h.service.SetDefaultEffect(c.Request().Context(), botID, req.DefaultEffect); err != nil {
if errors.Is(err, acl.ErrInvalidEffect) {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.NoContent(http.StatusNoContent)
}
// SearchChannelIdentities godoc
// @Summary Search access channel identities
// @Description Search locally observed channel identity candidates for bot access control
// @Summary Search ACL channel identity candidates
// @Description Search locally observed channel identities for building ACL rules
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param q query string false "Search query"
@@ -242,7 +237,7 @@ func (h *ACLHandler) SearchUsers(c echo.Context) error {
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/access/channel_identities [get].
// @Router /bots/{bot_id}/acl/channel-identities [get].
func (h *ACLHandler) SearchChannelIdentities(c echo.Context) error {
if _, _, err := h.requireManageAccess(c); err != nil {
return err
@@ -255,11 +250,11 @@ func (h *ACLHandler) SearchChannelIdentities(c echo.Context) error {
for _, item := range items {
result = append(result, acl.ChannelIdentityCandidate{
ID: item.ID,
UserID: item.UserID,
Channel: item.Channel,
ChannelSubjectID: item.ChannelSubjectID,
DisplayName: item.DisplayName,
AvatarURL: item.AvatarURL,
LinkedUserID: item.UserID,
LinkedUsername: item.LinkedUsername,
LinkedDisplayName: item.LinkedDisplayName,
LinkedAvatarURL: item.LinkedAvatarURL,
@@ -268,9 +263,9 @@ func (h *ACLHandler) SearchChannelIdentities(c echo.Context) error {
return c.JSON(http.StatusOK, acl.ChannelIdentityCandidateListResponse{Items: result})
}
// ListObservedConversationsByChannelIdentity godoc
// ListObservedConversations godoc
// @Summary List observed conversations for a channel identity
// @Description List previously observed conversation candidates for a channel identity under a bot
// @Description List previously observed conversation candidates for a channel identity, for scoped rule building
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param channel_identity_id path string true "Channel Identity ID"
@@ -278,8 +273,8 @@ func (h *ACLHandler) SearchChannelIdentities(c echo.Context) error {
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/access/channel_identities/{channel_identity_id}/conversations [get].
func (h *ACLHandler) ListObservedConversationsByChannelIdentity(c echo.Context) error {
// @Router /bots/{bot_id}/acl/channel-identities/{channel_identity_id}/conversations [get].
func (h *ACLHandler) ListObservedConversations(c echo.Context) error {
botID, _, err := h.requireManageAccess(c)
if err != nil {
return err
@@ -295,6 +290,33 @@ func (h *ACLHandler) ListObservedConversationsByChannelIdentity(c echo.Context)
return c.JSON(http.StatusOK, acl.ObservedConversationCandidateListResponse{Items: items})
}
// ListObservedConversationsByChannelType godoc
// @Summary List observed conversations for a platform type
// @Description List previously observed group/thread conversation candidates for a channel type under this bot
// @Tags bots
// @Param bot_id path string true "Bot ID"
// @Param channel_type path string true "Channel type (e.g. telegram, discord)"
// @Success 200 {object} acl.ObservedConversationCandidateListResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/acl/channel-types/{channel_type}/conversations [get].
func (h *ACLHandler) ListObservedConversationsByChannelType(c echo.Context) error {
botID, _, err := h.requireManageAccess(c)
if err != nil {
return err
}
channelType := strings.TrimSpace(c.Param("channel_type"))
if channelType == "" {
return echo.NewHTTPError(http.StatusBadRequest, "channel_type is required")
}
items, err := h.service.ListObservedConversationsByChannelType(c.Request().Context(), botID, channelType)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, acl.ObservedConversationCandidateListResponse{Items: items})
}
func (h *ACLHandler) requireManageAccess(c echo.Context) (string, string, error) {
actorID, err := RequireChannelIdentityID(c)
if err != nil {
@@ -302,7 +324,7 @@ func (h *ACLHandler) requireManageAccess(c echo.Context) (string, string, error)
}
botID := strings.TrimSpace(c.Param("bot_id"))
if botID == "" {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
return "", "", echo.NewHTTPError(http.StatusBadRequest, "bot_id is required")
}
if _, err := AuthorizeBotAccess(c.Request().Context(), h.botService, h.accountService, actorID, botID); err != nil {
return "", "", err
@@ -310,6 +332,15 @@ func (h *ACLHandler) requireManageAccess(c echo.Context) (string, string, error)
return botID, actorID, nil
}
func (*ACLHandler) mapRuleError(err error) error {
if errors.Is(err, acl.ErrInvalidRuleSubject) ||
errors.Is(err, acl.ErrInvalidSourceScope) ||
errors.Is(err, acl.ErrInvalidEffect) {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
func parseLimit(raw string) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value <= 0 {
+24 -17
View File
@@ -46,11 +46,11 @@ func (s *Service) GetBot(ctx context.Context, botID string) (Settings, error) {
return Settings{}, err
}
settings := normalizeBotSettingsReadRow(row)
allowGuest, err := s.allowGuestEnabled(ctx, botID)
aclDefaultEffect, err := s.getDefaultEffect(ctx, botID)
if err != nil {
return Settings{}, err
}
settings.AllowGuest = allowGuest
settings.AclDefaultEffect = aclDefaultEffect
return settings, nil
}
@@ -66,11 +66,11 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
if err != nil {
return Settings{}, err
}
allowGuest, err := s.allowGuestEnabled(ctx, botID)
aclDefaultEffect, err := s.getDefaultEffect(ctx, botID)
if err != nil {
return Settings{}, err
}
current := normalizeBotSetting(botRow.MaxContextLoadTime, botRow.MaxContextTokens, botRow.Language, allowGuest, botRow.ReasoningEnabled, botRow.ReasoningEffort, botRow.HeartbeatEnabled, botRow.HeartbeatInterval, botRow.CompactionEnabled, botRow.CompactionThreshold)
current := normalizeBotSetting(botRow.MaxContextLoadTime, botRow.MaxContextTokens, botRow.Language, aclDefaultEffect, botRow.ReasoningEnabled, botRow.ReasoningEffort, botRow.HeartbeatEnabled, botRow.HeartbeatInterval, botRow.CompactionEnabled, botRow.CompactionThreshold)
if req.MaxContextLoadTime != nil && *req.MaxContextLoadTime > 0 {
current.MaxContextLoadTime = *req.MaxContextLoadTime
}
@@ -80,8 +80,8 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
if strings.TrimSpace(req.Language) != "" {
current.Language = strings.TrimSpace(req.Language)
}
if req.AllowGuest != nil {
current.AllowGuest = *req.AllowGuest
if effect := strings.TrimSpace(req.AclDefaultEffect); effect != "" {
current.AclDefaultEffect = effect
}
if req.ReasoningEnabled != nil {
current.ReasoningEnabled = *req.ReasoningEnabled
@@ -202,11 +202,12 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
if botRow.OwnerUserID.Valid {
createdByUserID = uuid.UUID(botRow.OwnerUserID.Bytes).String()
}
if err := s.setAllowGuest(ctx, botID, createdByUserID, current.AllowGuest); err != nil {
_ = createdByUserID
if err := s.setDefaultEffect(ctx, botID, current.AclDefaultEffect); err != nil {
return Settings{}, err
}
settings := normalizeBotSettingsWriteRow(updated)
settings.AllowGuest = current.AllowGuest
settings.AclDefaultEffect = current.AclDefaultEffect
return settings, nil
}
@@ -221,15 +222,15 @@ func (s *Service) Delete(ctx context.Context, botID string) error {
if err := s.queries.DeleteSettingsByBotID(ctx, pgID); err != nil {
return err
}
return s.setAllowGuest(ctx, botID, "", false)
return nil
}
func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, language string, allowGuest bool, reasoningEnabled bool, reasoningEffort string, heartbeatEnabled bool, heartbeatInterval int32, compactionEnabled bool, compactionThreshold int32) Settings {
func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, language string, aclDefaultEffect string, reasoningEnabled bool, reasoningEffort string, heartbeatEnabled bool, heartbeatInterval int32, compactionEnabled bool, compactionThreshold int32) Settings {
settings := Settings{
MaxContextLoadTime: int(maxContextLoadTime),
MaxContextTokens: int(maxContextTokens),
Language: strings.TrimSpace(language),
AllowGuest: allowGuest,
AclDefaultEffect: strings.TrimSpace(aclDefaultEffect),
ReasoningEnabled: reasoningEnabled,
ReasoningEffort: strings.TrimSpace(reasoningEffort),
HeartbeatEnabled: heartbeatEnabled,
@@ -246,6 +247,9 @@ func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, langu
if settings.Language == "" {
settings.Language = DefaultLanguage
}
if settings.AclDefaultEffect == "" {
settings.AclDefaultEffect = "deny"
}
if !isValidReasoningEffort(settings.ReasoningEffort) {
settings.ReasoningEffort = DefaultReasoningEffort
}
@@ -330,7 +334,7 @@ func normalizeBotSettingsFields(
ttsModelID pgtype.UUID,
browserContextID pgtype.UUID,
) Settings {
settings := normalizeBotSetting(maxContextLoadTime, maxContextTokens, language, false, reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold)
settings := normalizeBotSetting(maxContextLoadTime, maxContextTokens, language, "", reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold)
if chatModelID.Valid {
settings.ChatModelID = uuid.UUID(chatModelID.Bytes).String()
}
@@ -358,18 +362,21 @@ func normalizeBotSettingsFields(
return settings
}
func (s *Service) allowGuestEnabled(ctx context.Context, botID string) (bool, error) {
func (s *Service) getDefaultEffect(ctx context.Context, botID string) (string, error) {
if s.acl == nil {
return false, nil
return "deny", nil
}
return s.acl.AllowGuestEnabled(ctx, botID)
return s.acl.GetDefaultEffect(ctx, botID)
}
func (s *Service) setAllowGuest(ctx context.Context, botID, createdByUserID string, enabled bool) error {
func (s *Service) setDefaultEffect(ctx context.Context, botID, effect string) error {
if s.acl == nil {
return nil
}
return s.acl.SetAllowGuest(ctx, botID, createdByUserID, enabled)
if effect == "" {
return nil
}
return s.acl.SetDefaultEffect(ctx, botID, effect)
}
func (s *Service) resolveModelUUID(ctx context.Context, modelID string) (pgtype.UUID, error) {
+2 -2
View File
@@ -16,7 +16,7 @@ type Settings struct {
MaxContextLoadTime int `json:"max_context_load_time"`
MaxContextTokens int `json:"max_context_tokens"`
Language string `json:"language"`
AllowGuest bool `json:"allow_guest"`
AclDefaultEffect string `json:"acl_default_effect"`
ReasoningEnabled bool `json:"reasoning_enabled"`
ReasoningEffort string `json:"reasoning_effort"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
@@ -37,7 +37,7 @@ type UpsertRequest struct {
MaxContextLoadTime *int `json:"max_context_load_time,omitempty"`
MaxContextTokens *int `json:"max_context_tokens,omitempty"`
Language string `json:"language,omitempty"`
AllowGuest *bool `json:"allow_guest,omitempty"`
AclDefaultEffect string `json:"acl_default_effect,omitempty"`
ReasoningEnabled *bool `json:"reasoning_enabled,omitempty"`
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+387 -189
View File
@@ -62,14 +62,29 @@ export type AclChannelIdentityCandidate = {
id?: string;
linked_avatar_url?: string;
linked_display_name?: string;
linked_user_id?: string;
linked_username?: string;
user_id?: string;
};
export type AclChannelIdentityCandidateListResponse = {
items?: Array<AclChannelIdentityCandidate>;
};
export type AclCreateRuleRequest = {
channel_identity_id?: string;
description?: string;
effect?: string;
enabled?: boolean;
priority?: number;
source_scope?: AclSourceScope;
subject_channel_type?: string;
subject_kind?: string;
};
export type AclDefaultEffectResponse = {
default_effect?: string;
};
export type AclListRulesResponse = {
items?: Array<AclRule>;
};
@@ -88,6 +103,15 @@ export type AclObservedConversationCandidateListResponse = {
items?: Array<AclObservedConversationCandidate>;
};
export type AclReorderItem = {
id?: string;
priority?: number;
};
export type AclReorderRequest = {
items?: Array<AclReorderItem>;
};
export type AclRule = {
action?: string;
bot_id?: string;
@@ -97,44 +121,36 @@ export type AclRule = {
channel_subject_id?: string;
channel_type?: string;
created_at?: string;
description?: string;
effect?: string;
enabled?: boolean;
id?: string;
linked_user_avatar_url?: string;
linked_user_display_name?: string;
linked_user_id?: string;
linked_user_username?: string;
priority?: number;
source_scope?: AclSourceScope;
subject_channel_type?: string;
subject_kind?: string;
updated_at?: string;
user_avatar_url?: string;
user_display_name?: string;
user_id?: string;
user_username?: string;
};
export type AclSourceScope = {
channel?: string;
conversation_id?: string;
conversation_type?: string;
thread_id?: string;
};
export type AclUpsertRuleRequest = {
export type AclUpdateRuleRequest = {
channel_identity_id?: string;
description?: string;
effect?: string;
enabled?: boolean;
priority?: number;
source_scope?: AclSourceScope;
user_id?: string;
};
export type AclUserCandidate = {
avatar_url?: string;
display_name?: string;
email?: string;
id?: string;
username?: string;
};
export type AclUserCandidateListResponse = {
items?: Array<AclUserCandidate>;
subject_channel_type?: string;
subject_kind?: string;
};
export type AdaptersCdfPoint = {
@@ -1207,6 +1223,7 @@ export type ModelsModelConfig = {
compatibilities?: Array<string>;
context_window?: number;
dimensions?: number;
reasoning_efforts?: Array<string>;
};
export type ModelsModelType = 'chat' | 'embedding';
@@ -1264,6 +1281,14 @@ export type ProvidersImportModelsResponse = {
skipped?: number;
};
export type ProvidersOAuthStatus = {
callback_url?: string;
configured?: boolean;
expired?: boolean;
expires_at?: string;
has_token?: boolean;
};
export type ProvidersTestResponse = {
latency_ms?: number;
message?: string;
@@ -1410,7 +1435,7 @@ export type SessionSession = {
};
export type SettingsSettings = {
allow_guest?: boolean;
acl_default_effect?: string;
browser_context_id?: string;
chat_model_id?: string;
compaction_enabled?: boolean;
@@ -1431,7 +1456,7 @@ export type SettingsSettings = {
};
export type SettingsUpsertRequest = {
allow_guest?: boolean;
acl_default_effect?: string;
browser_context_id?: string;
chat_model_id?: string;
compaction_enabled?: boolean;
@@ -1678,7 +1703,7 @@ export type PostBotsResponses = {
export type PostBotsResponse = PostBotsResponses[keyof PostBotsResponses];
export type GetBotsByBotIdAccessChannelIdentitiesData = {
export type GetBotsByBotIdAclChannelIdentitiesData = {
body?: never;
path: {
/**
@@ -1696,10 +1721,10 @@ export type GetBotsByBotIdAccessChannelIdentitiesData = {
*/
limit?: number;
};
url: '/bots/{bot_id}/access/channel_identities';
url: '/bots/{bot_id}/acl/channel-identities';
};
export type GetBotsByBotIdAccessChannelIdentitiesErrors = {
export type GetBotsByBotIdAclChannelIdentitiesErrors = {
/**
* Bad Request
*/
@@ -1714,18 +1739,18 @@ export type GetBotsByBotIdAccessChannelIdentitiesErrors = {
500: HandlersErrorResponse;
};
export type GetBotsByBotIdAccessChannelIdentitiesError = GetBotsByBotIdAccessChannelIdentitiesErrors[keyof GetBotsByBotIdAccessChannelIdentitiesErrors];
export type GetBotsByBotIdAclChannelIdentitiesError = GetBotsByBotIdAclChannelIdentitiesErrors[keyof GetBotsByBotIdAclChannelIdentitiesErrors];
export type GetBotsByBotIdAccessChannelIdentitiesResponses = {
export type GetBotsByBotIdAclChannelIdentitiesResponses = {
/**
* OK
*/
200: AclChannelIdentityCandidateListResponse;
};
export type GetBotsByBotIdAccessChannelIdentitiesResponse = GetBotsByBotIdAccessChannelIdentitiesResponses[keyof GetBotsByBotIdAccessChannelIdentitiesResponses];
export type GetBotsByBotIdAclChannelIdentitiesResponse = GetBotsByBotIdAclChannelIdentitiesResponses[keyof GetBotsByBotIdAclChannelIdentitiesResponses];
export type GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsData = {
export type GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsData = {
body?: never;
path: {
/**
@@ -1738,10 +1763,10 @@ export type GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversation
channel_identity_id: string;
};
query?: never;
url: '/bots/{bot_id}/access/channel_identities/{channel_identity_id}/conversations';
url: '/bots/{bot_id}/acl/channel-identities/{channel_identity_id}/conversations';
};
export type GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsErrors = {
export type GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsErrors = {
/**
* Bad Request
*/
@@ -1756,39 +1781,34 @@ export type GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversation
500: HandlersErrorResponse;
};
export type GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsError = GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsErrors[keyof GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsErrors];
export type GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsError = GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsErrors[keyof GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsErrors];
export type GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsResponses = {
export type GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponses = {
/**
* OK
*/
200: AclObservedConversationCandidateListResponse;
};
export type GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsResponse = GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsResponses[keyof GetBotsByBotIdAccessChannelIdentitiesByChannelIdentityIdConversationsResponses];
export type GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponse = GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponses[keyof GetBotsByBotIdAclChannelIdentitiesByChannelIdentityIdConversationsResponses];
export type GetBotsByBotIdAccessUsersData = {
export type GetBotsByBotIdAclChannelTypesByChannelTypeConversationsData = {
body?: never;
path: {
/**
* Bot ID
*/
bot_id: string;
};
query?: {
/**
* Search query
* Channel type (e.g. telegram, discord)
*/
q?: string;
/**
* Max results
*/
limit?: number;
channel_type: string;
};
url: '/bots/{bot_id}/access/users';
query?: never;
url: '/bots/{bot_id}/acl/channel-types/{channel_type}/conversations';
};
export type GetBotsByBotIdAccessUsersErrors = {
export type GetBotsByBotIdAclChannelTypesByChannelTypeConversationsErrors = {
/**
* Bad Request
*/
@@ -1803,18 +1823,18 @@ export type GetBotsByBotIdAccessUsersErrors = {
500: HandlersErrorResponse;
};
export type GetBotsByBotIdAccessUsersError = GetBotsByBotIdAccessUsersErrors[keyof GetBotsByBotIdAccessUsersErrors];
export type GetBotsByBotIdAclChannelTypesByChannelTypeConversationsError = GetBotsByBotIdAclChannelTypesByChannelTypeConversationsErrors[keyof GetBotsByBotIdAclChannelTypesByChannelTypeConversationsErrors];
export type GetBotsByBotIdAccessUsersResponses = {
export type GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponses = {
/**
* OK
*/
200: AclUserCandidateListResponse;
200: AclObservedConversationCandidateListResponse;
};
export type GetBotsByBotIdAccessUsersResponse = GetBotsByBotIdAccessUsersResponses[keyof GetBotsByBotIdAccessUsersResponses];
export type GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponse = GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponses[keyof GetBotsByBotIdAclChannelTypesByChannelTypeConversationsResponses];
export type GetBotsByBotIdBlacklistData = {
export type GetBotsByBotIdAclDefaultEffectData = {
body?: never;
path: {
/**
@@ -1823,10 +1843,10 @@ export type GetBotsByBotIdBlacklistData = {
bot_id: string;
};
query?: never;
url: '/bots/{bot_id}/blacklist';
url: '/bots/{bot_id}/acl/default-effect';
};
export type GetBotsByBotIdBlacklistErrors = {
export type GetBotsByBotIdAclDefaultEffectErrors = {
/**
* Bad Request
*/
@@ -1841,22 +1861,99 @@ export type GetBotsByBotIdBlacklistErrors = {
500: HandlersErrorResponse;
};
export type GetBotsByBotIdBlacklistError = GetBotsByBotIdBlacklistErrors[keyof GetBotsByBotIdBlacklistErrors];
export type GetBotsByBotIdAclDefaultEffectError = GetBotsByBotIdAclDefaultEffectErrors[keyof GetBotsByBotIdAclDefaultEffectErrors];
export type GetBotsByBotIdBlacklistResponses = {
export type GetBotsByBotIdAclDefaultEffectResponses = {
/**
* OK
*/
200: AclDefaultEffectResponse;
};
export type GetBotsByBotIdAclDefaultEffectResponse = GetBotsByBotIdAclDefaultEffectResponses[keyof GetBotsByBotIdAclDefaultEffectResponses];
export type PutBotsByBotIdAclDefaultEffectData = {
/**
* Default effect payload
*/
body: AclDefaultEffectResponse;
path: {
/**
* Bot ID
*/
bot_id: string;
};
query?: never;
url: '/bots/{bot_id}/acl/default-effect';
};
export type PutBotsByBotIdAclDefaultEffectErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type PutBotsByBotIdAclDefaultEffectError = PutBotsByBotIdAclDefaultEffectErrors[keyof PutBotsByBotIdAclDefaultEffectErrors];
export type PutBotsByBotIdAclDefaultEffectResponses = {
/**
* No Content
*/
204: unknown;
};
export type GetBotsByBotIdAclRulesData = {
body?: never;
path: {
/**
* Bot ID
*/
bot_id: string;
};
query?: never;
url: '/bots/{bot_id}/acl/rules';
};
export type GetBotsByBotIdAclRulesErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type GetBotsByBotIdAclRulesError = GetBotsByBotIdAclRulesErrors[keyof GetBotsByBotIdAclRulesErrors];
export type GetBotsByBotIdAclRulesResponses = {
/**
* OK
*/
200: AclListRulesResponse;
};
export type GetBotsByBotIdBlacklistResponse = GetBotsByBotIdBlacklistResponses[keyof GetBotsByBotIdBlacklistResponses];
export type GetBotsByBotIdAclRulesResponse = GetBotsByBotIdAclRulesResponses[keyof GetBotsByBotIdAclRulesResponses];
export type PutBotsByBotIdBlacklistData = {
export type PostBotsByBotIdAclRulesData = {
/**
* Blacklist payload
* Rule payload
*/
body: AclUpsertRuleRequest;
body: AclCreateRuleRequest;
path: {
/**
* Bot ID
@@ -1864,10 +1961,10 @@ export type PutBotsByBotIdBlacklistData = {
bot_id: string;
};
query?: never;
url: '/bots/{bot_id}/blacklist';
url: '/bots/{bot_id}/acl/rules';
};
export type PutBotsByBotIdBlacklistErrors = {
export type PostBotsByBotIdAclRulesErrors = {
/**
* Bad Request
*/
@@ -1882,18 +1979,57 @@ export type PutBotsByBotIdBlacklistErrors = {
500: HandlersErrorResponse;
};
export type PutBotsByBotIdBlacklistError = PutBotsByBotIdBlacklistErrors[keyof PutBotsByBotIdBlacklistErrors];
export type PostBotsByBotIdAclRulesError = PostBotsByBotIdAclRulesErrors[keyof PostBotsByBotIdAclRulesErrors];
export type PutBotsByBotIdBlacklistResponses = {
export type PostBotsByBotIdAclRulesResponses = {
/**
* OK
* Created
*/
200: AclRule;
201: AclRule;
};
export type PutBotsByBotIdBlacklistResponse = PutBotsByBotIdBlacklistResponses[keyof PutBotsByBotIdBlacklistResponses];
export type PostBotsByBotIdAclRulesResponse = PostBotsByBotIdAclRulesResponses[keyof PostBotsByBotIdAclRulesResponses];
export type DeleteBotsByBotIdBlacklistByRuleIdData = {
export type PutBotsByBotIdAclRulesReorderData = {
/**
* Reorder payload
*/
body: AclReorderRequest;
path: {
/**
* Bot ID
*/
bot_id: string;
};
query?: never;
url: '/bots/{bot_id}/acl/rules/reorder';
};
export type PutBotsByBotIdAclRulesReorderErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type PutBotsByBotIdAclRulesReorderError = PutBotsByBotIdAclRulesReorderErrors[keyof PutBotsByBotIdAclRulesReorderErrors];
export type PutBotsByBotIdAclRulesReorderResponses = {
/**
* No Content
*/
204: unknown;
};
export type DeleteBotsByBotIdAclRulesByRuleIdData = {
body?: never;
path: {
/**
@@ -1906,10 +2042,10 @@ export type DeleteBotsByBotIdBlacklistByRuleIdData = {
rule_id: string;
};
query?: never;
url: '/bots/{bot_id}/blacklist/{rule_id}';
url: '/bots/{bot_id}/acl/rules/{rule_id}';
};
export type DeleteBotsByBotIdBlacklistByRuleIdErrors = {
export type DeleteBotsByBotIdAclRulesByRuleIdErrors = {
/**
* Bad Request
*/
@@ -1924,15 +2060,60 @@ export type DeleteBotsByBotIdBlacklistByRuleIdErrors = {
500: HandlersErrorResponse;
};
export type DeleteBotsByBotIdBlacklistByRuleIdError = DeleteBotsByBotIdBlacklistByRuleIdErrors[keyof DeleteBotsByBotIdBlacklistByRuleIdErrors];
export type DeleteBotsByBotIdAclRulesByRuleIdError = DeleteBotsByBotIdAclRulesByRuleIdErrors[keyof DeleteBotsByBotIdAclRulesByRuleIdErrors];
export type DeleteBotsByBotIdBlacklistByRuleIdResponses = {
export type DeleteBotsByBotIdAclRulesByRuleIdResponses = {
/**
* No Content
*/
204: unknown;
};
export type PutBotsByBotIdAclRulesByRuleIdData = {
/**
* Rule payload
*/
body: AclUpdateRuleRequest;
path: {
/**
* Bot ID
*/
bot_id: string;
/**
* Rule ID
*/
rule_id: string;
};
query?: never;
url: '/bots/{bot_id}/acl/rules/{rule_id}';
};
export type PutBotsByBotIdAclRulesByRuleIdErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type PutBotsByBotIdAclRulesByRuleIdError = PutBotsByBotIdAclRulesByRuleIdErrors[keyof PutBotsByBotIdAclRulesByRuleIdErrors];
export type PutBotsByBotIdAclRulesByRuleIdResponses = {
/**
* OK
*/
200: AclRule;
};
export type PutBotsByBotIdAclRulesByRuleIdResponse = PutBotsByBotIdAclRulesByRuleIdResponses[keyof PutBotsByBotIdAclRulesByRuleIdResponses];
export type PostBotsByBotIdCliMessagesData = {
/**
* Message payload
@@ -5339,125 +5520,6 @@ export type GetBotsByBotIdWebWsErrors = {
export type GetBotsByBotIdWebWsError = GetBotsByBotIdWebWsErrors[keyof GetBotsByBotIdWebWsErrors];
export type GetBotsByBotIdWhitelistData = {
body?: never;
path: {
/**
* Bot ID
*/
bot_id: string;
};
query?: never;
url: '/bots/{bot_id}/whitelist';
};
export type GetBotsByBotIdWhitelistErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type GetBotsByBotIdWhitelistError = GetBotsByBotIdWhitelistErrors[keyof GetBotsByBotIdWhitelistErrors];
export type GetBotsByBotIdWhitelistResponses = {
/**
* OK
*/
200: AclListRulesResponse;
};
export type GetBotsByBotIdWhitelistResponse = GetBotsByBotIdWhitelistResponses[keyof GetBotsByBotIdWhitelistResponses];
export type PutBotsByBotIdWhitelistData = {
/**
* Whitelist payload
*/
body: AclUpsertRuleRequest;
path: {
/**
* Bot ID
*/
bot_id: string;
};
query?: never;
url: '/bots/{bot_id}/whitelist';
};
export type PutBotsByBotIdWhitelistErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type PutBotsByBotIdWhitelistError = PutBotsByBotIdWhitelistErrors[keyof PutBotsByBotIdWhitelistErrors];
export type PutBotsByBotIdWhitelistResponses = {
/**
* OK
*/
200: AclRule;
};
export type PutBotsByBotIdWhitelistResponse = PutBotsByBotIdWhitelistResponses[keyof PutBotsByBotIdWhitelistResponses];
export type DeleteBotsByBotIdWhitelistByRuleIdData = {
body?: never;
path: {
/**
* Bot ID
*/
bot_id: string;
/**
* Rule ID
*/
rule_id: string;
};
query?: never;
url: '/bots/{bot_id}/whitelist/{rule_id}';
};
export type DeleteBotsByBotIdWhitelistByRuleIdErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type DeleteBotsByBotIdWhitelistByRuleIdError = DeleteBotsByBotIdWhitelistByRuleIdErrors[keyof DeleteBotsByBotIdWhitelistByRuleIdErrors];
export type DeleteBotsByBotIdWhitelistByRuleIdResponses = {
/**
* No Content
*/
204: unknown;
};
export type DeleteBotsByIdData = {
body?: never;
path: {
@@ -7283,6 +7345,40 @@ export type GetProvidersNameByNameResponses = {
export type GetProvidersNameByNameResponse = GetProvidersNameByNameResponses[keyof GetProvidersNameByNameResponses];
export type GetProvidersOauthCallbackData = {
body?: never;
path?: never;
query: {
/**
* Authorization code
*/
code: string;
/**
* State parameter
*/
state: string;
};
url: '/providers/oauth/callback';
};
export type GetProvidersOauthCallbackErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
};
export type GetProvidersOauthCallbackError = GetProvidersOauthCallbackErrors[keyof GetProvidersOauthCallbackErrors];
export type GetProvidersOauthCallbackResponses = {
/**
* HTML success page
*/
200: string;
};
export type GetProvidersOauthCallbackResponse = GetProvidersOauthCallbackResponses[keyof GetProvidersOauthCallbackResponses];
export type DeleteProvidersByIdData = {
body?: never;
path: {
@@ -7479,6 +7575,108 @@ export type GetProvidersByIdModelsResponses = {
export type GetProvidersByIdModelsResponse = GetProvidersByIdModelsResponses[keyof GetProvidersByIdModelsResponses];
export type GetProvidersByIdOauthAuthorizeData = {
body?: never;
path: {
/**
* Provider ID (UUID)
*/
id: string;
};
query?: never;
url: '/providers/{id}/oauth/authorize';
};
export type GetProvidersByIdOauthAuthorizeErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Not Found
*/
404: HandlersErrorResponse;
};
export type GetProvidersByIdOauthAuthorizeError = GetProvidersByIdOauthAuthorizeErrors[keyof GetProvidersByIdOauthAuthorizeErrors];
export type GetProvidersByIdOauthAuthorizeResponses = {
/**
* OK
*/
200: {
[key: string]: string;
};
};
export type GetProvidersByIdOauthAuthorizeResponse = GetProvidersByIdOauthAuthorizeResponses[keyof GetProvidersByIdOauthAuthorizeResponses];
export type GetProvidersByIdOauthStatusData = {
body?: never;
path: {
/**
* Provider ID (UUID)
*/
id: string;
};
query?: never;
url: '/providers/{id}/oauth/status';
};
export type GetProvidersByIdOauthStatusErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Not Found
*/
404: HandlersErrorResponse;
};
export type GetProvidersByIdOauthStatusError = GetProvidersByIdOauthStatusErrors[keyof GetProvidersByIdOauthStatusErrors];
export type GetProvidersByIdOauthStatusResponses = {
/**
* OK
*/
200: ProvidersOAuthStatus;
};
export type GetProvidersByIdOauthStatusResponse = GetProvidersByIdOauthStatusResponses[keyof GetProvidersByIdOauthStatusResponses];
export type DeleteProvidersByIdOauthTokenData = {
body?: never;
path: {
/**
* Provider ID (UUID)
*/
id: string;
};
query?: never;
url: '/providers/{id}/oauth/token';
};
export type DeleteProvidersByIdOauthTokenErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Not Found
*/
404: HandlersErrorResponse;
};
export type DeleteProvidersByIdOauthTokenError = DeleteProvidersByIdOauthTokenErrors[keyof DeleteProvidersByIdOauthTokenErrors];
export type DeleteProvidersByIdOauthTokenResponses = {
/**
* No Content
*/
204: unknown;
};
export type PostProvidersByIdTestData = {
body?: never;
path: {
+95 -4
View File
@@ -134,6 +134,9 @@ importers:
'@vueuse/core':
specifier: ^14.1.0
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
'@vueuse/integrations':
specifier: ^14.2.1
version: 14.2.1(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(sortablejs@1.15.7)(vue@3.5.26(typescript@5.9.3))
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
@@ -179,6 +182,9 @@ importers:
shiki:
specifier: ^3.21.0
version: 3.23.0
sortablejs:
specifier: ^1.15.7
version: 1.15.7
stream-markdown:
specifier: ^0.0.13
version: 0.0.13(shiki@3.23.0)
@@ -246,7 +252,7 @@ importers:
devDependencies:
vitepress:
specifier: ^1.6.0
version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@5.9.3)
version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(sortablejs@1.15.7)(typescript@5.9.3)
vue:
specifier: ^3.5.0
version: 3.5.26(typescript@5.9.3)
@@ -2391,6 +2397,11 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/core@14.2.1':
resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
peerDependencies:
vue: ^3.5.0
'@vueuse/integrations@12.8.2':
resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==}
peerDependencies:
@@ -2432,12 +2443,57 @@ packages:
universal-cookie:
optional: true
'@vueuse/integrations@14.2.1':
resolution: {integrity: sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==}
peerDependencies:
async-validator: ^4
axios: ^1
change-case: ^5
drauu: ^0.4
focus-trap: ^7 || ^8
fuse.js: ^7
idb-keyval: ^6
jwt-decode: ^4
nprogress: ^0.2
qrcode: ^1.5
sortablejs: ^1
universal-cookie: ^7 || ^8
vue: ^3.5.0
peerDependenciesMeta:
async-validator:
optional: true
axios:
optional: true
change-case:
optional: true
drauu:
optional: true
focus-trap:
optional: true
fuse.js:
optional: true
idb-keyval:
optional: true
jwt-decode:
optional: true
nprogress:
optional: true
qrcode:
optional: true
sortablejs:
optional: true
universal-cookie:
optional: true
'@vueuse/metadata@12.8.2':
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
'@vueuse/metadata@14.1.0':
resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==}
'@vueuse/metadata@14.2.1':
resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
@@ -2446,6 +2502,11 @@ packages:
peerDependencies:
vue: ^3.5.0
'@vueuse/shared@14.2.1':
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
peerDependencies:
vue: ^3.5.0
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
@@ -4176,6 +4237,9 @@ packages:
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
engines: {node: '>=20'}
sortablejs@1.15.7:
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -6790,7 +6854,14 @@ snapshots:
'@vueuse/shared': 14.1.0(vue@3.5.26(typescript@5.9.3))
vue: 3.5.26(typescript@5.9.3)
'@vueuse/integrations@12.8.2(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(typescript@5.9.3)':
'@vueuse/core@14.2.1(vue@3.5.26(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.2.1
'@vueuse/shared': 14.2.1(vue@3.5.26(typescript@5.9.3))
vue: 3.5.26(typescript@5.9.3)
'@vueuse/integrations@12.8.2(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(sortablejs@1.15.7)(typescript@5.9.3)':
dependencies:
'@vueuse/core': 12.8.2(typescript@5.9.3)
'@vueuse/shared': 12.8.2(typescript@5.9.3)
@@ -6799,13 +6870,27 @@ snapshots:
axios: 1.13.4
focus-trap: 7.8.0
qrcode: 1.5.4
sortablejs: 1.15.7
transitivePeerDependencies:
- typescript
'@vueuse/integrations@14.2.1(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(sortablejs@1.15.7)(vue@3.5.26(typescript@5.9.3))':
dependencies:
'@vueuse/core': 14.2.1(vue@3.5.26(typescript@5.9.3))
'@vueuse/shared': 14.2.1(vue@3.5.26(typescript@5.9.3))
vue: 3.5.26(typescript@5.9.3)
optionalDependencies:
axios: 1.13.4
focus-trap: 7.8.0
qrcode: 1.5.4
sortablejs: 1.15.7
'@vueuse/metadata@12.8.2': {}
'@vueuse/metadata@14.1.0': {}
'@vueuse/metadata@14.2.1': {}
'@vueuse/shared@12.8.2(typescript@5.9.3)':
dependencies:
vue: 3.5.26(typescript@5.9.3)
@@ -6816,6 +6901,10 @@ snapshots:
dependencies:
vue: 3.5.26(typescript@5.9.3)
'@vueuse/shared@14.2.1(vue@3.5.26(typescript@5.9.3))':
dependencies:
vue: 3.5.26(typescript@5.9.3)
'@xterm/addon-fit@0.11.0': {}
'@xterm/addon-serialize@0.14.0': {}
@@ -8687,6 +8776,8 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
sortablejs@1.15.7: {}
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -9066,7 +9157,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@5.9.3):
vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(sortablejs@1.15.7)(typescript@5.9.3):
dependencies:
'@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.46.2)(search-insights@2.17.3)
@@ -9079,7 +9170,7 @@ snapshots:
'@vue/devtools-api': 7.7.9
'@vue/shared': 3.5.26
'@vueuse/core': 12.8.2(typescript@5.9.3)
'@vueuse/integrations': 12.8.2(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(typescript@5.9.3)
'@vueuse/integrations': 12.8.2(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(sortablejs@1.15.7)(typescript@5.9.3)
focus-trap: 7.8.0
mark.js: 8.11.1
minisearch: 7.2.0
+511 -382
View File
File diff suppressed because it is too large Load Diff
+511 -382
View File
File diff suppressed because it is too large Load Diff
+349 -264
View File
@@ -17,6 +17,8 @@ definitions:
type: string
role:
type: string
timezone:
type: string
updated_at:
type: string
username:
@@ -75,6 +77,8 @@ definitions:
type: string
display_name:
type: string
timezone:
type: string
type: object
acl.ChannelIdentityCandidate:
properties:
@@ -92,9 +96,9 @@ definitions:
type: string
linked_display_name:
type: string
linked_username:
linked_user_id:
type: string
user_id:
linked_username:
type: string
type: object
acl.ChannelIdentityCandidateListResponse:
@@ -104,6 +108,30 @@ definitions:
$ref: '#/definitions/acl.ChannelIdentityCandidate'
type: array
type: object
acl.CreateRuleRequest:
properties:
channel_identity_id:
type: string
description:
type: string
effect:
type: string
enabled:
type: boolean
priority:
type: integer
source_scope:
$ref: '#/definitions/acl.SourceScope'
subject_channel_type:
type: string
subject_kind:
type: string
type: object
acl.DefaultEffectResponse:
properties:
default_effect:
type: string
type: object
acl.ListRulesResponse:
properties:
items:
@@ -135,6 +163,20 @@ definitions:
$ref: '#/definitions/acl.ObservedConversationCandidate'
type: array
type: object
acl.ReorderItem:
properties:
id:
type: string
priority:
type: integer
type: object
acl.ReorderRequest:
properties:
items:
items:
$ref: '#/definitions/acl.ReorderItem'
type: array
type: object
acl.Rule:
properties:
action:
@@ -153,8 +195,12 @@ definitions:
type: string
created_at:
type: string
description:
type: string
effect:
type: string
enabled:
type: boolean
id:
type: string
linked_user_avatar_url:
@@ -165,25 +211,19 @@ definitions:
type: string
linked_user_username:
type: string
priority:
type: integer
source_scope:
$ref: '#/definitions/acl.SourceScope'
subject_channel_type:
type: string
subject_kind:
type: string
updated_at:
type: string
user_avatar_url:
type: string
user_display_name:
type: string
user_id:
type: string
user_username:
type: string
type: object
acl.SourceScope:
properties:
channel:
type: string
conversation_id:
type: string
conversation_type:
@@ -191,34 +231,24 @@ definitions:
thread_id:
type: string
type: object
acl.UpsertRuleRequest:
acl.UpdateRuleRequest:
properties:
channel_identity_id:
type: string
description:
type: string
effect:
type: string
enabled:
type: boolean
priority:
type: integer
source_scope:
$ref: '#/definitions/acl.SourceScope'
user_id:
subject_channel_type:
type: string
type: object
acl.UserCandidate:
properties:
avatar_url:
subject_kind:
type: string
display_name:
type: string
email:
type: string
id:
type: string
username:
type: string
type: object
acl.UserCandidateListResponse:
properties:
items:
items:
$ref: '#/definitions/acl.UserCandidate'
type: array
type: object
adapters.CDFPoint:
properties:
@@ -482,6 +512,8 @@ definitions:
type: string
status:
type: string
timezone:
type: string
updated_at:
type: string
type: object
@@ -516,6 +548,8 @@ definitions:
metadata:
additionalProperties: {}
type: object
timezone:
type: string
type: object
bots.ListBotsResponse:
properties:
@@ -547,6 +581,8 @@ definitions:
metadata:
additionalProperties: {}
type: object
timezone:
type: string
type: object
browsercontexts.BrowserContext:
properties:
@@ -1355,6 +1391,8 @@ definitions:
type: string
role:
type: string
timezone:
type: string
token_type:
type: string
user_id:
@@ -1962,6 +2000,10 @@ definitions:
type: integer
dimensions:
type: integer
reasoning_efforts:
items:
type: string
type: array
type: object
models.ModelType:
enum:
@@ -2067,6 +2109,19 @@ definitions:
skipped:
type: integer
type: object
providers.OAuthStatus:
properties:
callback_url:
type: string
configured:
type: boolean
expired:
type: boolean
expires_at:
type: string
has_token:
type: boolean
type: object
providers.TestResponse:
properties:
latency_ms:
@@ -2319,8 +2374,8 @@ definitions:
type: object
settings.Settings:
properties:
allow_guest:
type: boolean
acl_default_effect:
type: string
browser_context_id:
type: string
chat_model_id:
@@ -2358,8 +2413,8 @@ definitions:
type: object
settings.UpsertRequest:
properties:
allow_guest:
type: boolean
acl_default_effect:
type: string
browser_context_id:
type: string
chat_model_id:
@@ -2531,39 +2586,6 @@ definitions:
name:
type: string
type: object
weixin.QRPollRequest:
properties:
baseUrl:
type: string
qr_code:
type: string
routeTag:
type: string
type: object
weixin.QRPollResponse:
properties:
message:
type: string
status:
description: wait, scaned, confirmed, expired
type: string
type: object
weixin.QRStartRequest:
properties:
baseUrl:
type: string
routeTag:
type: string
type: object
weixin.QRStartResponse:
properties:
message:
type: string
qr_code:
type: string
qr_code_url:
type: string
type: object
info:
contact: {}
title: Memoh API
@@ -2677,10 +2699,9 @@ paths:
summary: Create bot user
tags:
- bots
/bots/{bot_id}/access/channel_identities:
/bots/{bot_id}/acl/channel-identities:
get:
description: Search locally observed channel identity candidates for bot access
control
description: Search locally observed channel identities for building ACL rules
parameters:
- description: Bot ID
in: path
@@ -2712,13 +2733,13 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Search access channel identities
summary: Search ACL channel identity candidates
tags:
- bots
/bots/{bot_id}/access/channel_identities/{channel_identity_id}/conversations:
/bots/{bot_id}/acl/channel-identities/{channel_identity_id}/conversations:
get:
description: List previously observed conversation candidates for a channel
identity under a bot
identity, for scoped rule building
parameters:
- description: Bot ID
in: path
@@ -2750,28 +2771,26 @@ paths:
summary: List observed conversations for a channel identity
tags:
- bots
/bots/{bot_id}/access/users:
/bots/{bot_id}/acl/channel-types/{channel_type}/conversations:
get:
description: Search user candidates for bot access control
description: List previously observed group/thread conversation candidates for
a channel type under this bot
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Search query
in: query
name: q
- description: Channel type (e.g. telegram, discord)
in: path
name: channel_type
required: true
type: string
- description: Max results
in: query
name: limit
type: integer
responses:
"200":
description: OK
schema:
$ref: '#/definitions/acl.UserCandidateListResponse'
$ref: '#/definitions/acl.ObservedConversationCandidateListResponse'
"400":
description: Bad Request
schema:
@@ -2784,12 +2803,73 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Search access users
summary: List observed conversations for a platform type
tags:
- bots
/bots/{bot_id}/blacklist:
/bots/{bot_id}/acl/default-effect:
get:
description: List guest deny rules for chat trigger
description: Get the fallback effect when no rule matches
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/acl.DefaultEffectResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Get bot ACL default effect
tags:
- bots
put:
description: Set the fallback effect when no rule matches (allow or deny)
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Default effect payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/acl.DefaultEffectResponse'
responses:
"204":
description: No Content
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Set bot ACL default effect
tags:
- bots
/bots/{bot_id}/acl/rules:
get:
description: List all ACL rules for a bot ordered by priority
parameters:
- description: Bot ID
in: path
@@ -2813,26 +2893,26 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: List bot blacklist
summary: List bot ACL rules
tags:
- bots
put:
description: Add a guest deny rule for chat trigger
post:
description: Create a new priority-ordered ACL rule for chat.trigger
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Blacklist payload
- description: Rule payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/acl.UpsertRuleRequest'
$ref: '#/definitions/acl.CreateRuleRequest'
responses:
"200":
description: OK
"201":
description: Created
schema:
$ref: '#/definitions/acl.Rule'
"400":
@@ -2847,12 +2927,12 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Upsert bot blacklist entry
summary: Create ACL rule
tags:
- bots
/bots/{bot_id}/blacklist/{rule_id}:
/bots/{bot_id}/acl/rules/{rule_id}:
delete:
description: Delete a guest deny rule by rule ID
description: Delete an ACL rule by ID
parameters:
- description: Bot ID
in: path
@@ -2879,7 +2959,79 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Delete bot blacklist entry
summary: Delete ACL rule
tags:
- bots
put:
description: Update an existing ACL rule
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Rule ID
in: path
name: rule_id
required: true
type: string
- description: Rule payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/acl.UpdateRuleRequest'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/acl.Rule'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Update ACL rule
tags:
- bots
/bots/{bot_id}/acl/rules/reorder:
put:
description: Batch-update priorities for multiple ACL rules
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Reorder payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/acl.ReorderRequest'
responses:
"204":
description: No Content
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Reorder ACL rules
tags:
- bots
/bots/{bot_id}/cli/messages:
@@ -5657,101 +5809,6 @@ paths:
summary: WebSocket chat endpoint
tags:
- local-channel
/bots/{bot_id}/whitelist:
get:
description: List guest allow rules for chat trigger
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/acl.ListRulesResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: List bot whitelist
tags:
- bots
put:
description: Add a guest allow rule for chat trigger
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Whitelist payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/acl.UpsertRuleRequest'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/acl.Rule'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Upsert bot whitelist entry
tags:
- bots
/bots/{bot_id}/whitelist/{rule_id}:
delete:
description: Delete a guest allow rule by rule ID
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Rule ID
in: path
name: rule_id
required: true
type: string
responses:
"204":
description: No Content
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Delete bot whitelist entry
tags:
- bots
/bots/{id}:
delete:
description: Delete a bot user (owner/admin only)
@@ -6105,75 +6162,6 @@ paths:
summary: Update bot channel status
tags:
- bots
/bots/{id}/channel/weixin/qr/poll:
post:
description: Long-poll the QR code scan status. On confirmed, auto-saves credentials.
parameters:
- description: Bot ID
in: path
name: id
required: true
type: string
- description: QR code to poll
in: body
name: payload
required: true
schema:
$ref: '#/definitions/weixin.QRPollRequest'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/weixin.QRPollResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Poll WeChat QR login status
tags:
- bots
/bots/{id}/channel/weixin/qr/start:
post:
description: Fetch a QR code from WeChat for scanning
parameters:
- description: Bot ID
in: path
name: id
required: true
type: string
- description: Optional base URL override
in: body
name: payload
schema:
$ref: '#/definitions/weixin.QRStartRequest'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/weixin.QRStartResponse'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Start WeChat QR login
tags:
- bots
/bots/{id}/checks:
get:
description: Evaluate bot attached resource checks in runtime
@@ -7406,6 +7394,78 @@ paths:
summary: List provider models
tags:
- providers
/providers/{id}/oauth/authorize:
get:
parameters:
- description: Provider ID (UUID)
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Start OAuth2 authorization for an LLM provider
tags:
- providers-oauth
/providers/{id}/oauth/status:
get:
parameters:
- description: Provider ID (UUID)
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/providers.OAuthStatus'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Get OAuth2 status for an LLM provider
tags:
- providers-oauth
/providers/{id}/oauth/token:
delete:
parameters:
- description: Provider ID (UUID)
in: path
name: id
required: true
type: string
responses:
"204":
description: No Content
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Revoke stored OAuth2 tokens for an LLM provider
tags:
- providers-oauth
/providers/{id}/test:
post:
consumes:
@@ -7492,6 +7552,31 @@ paths:
summary: Get provider by name
tags:
- providers
/providers/oauth/callback:
get:
parameters:
- description: Authorization code
in: query
name: code
required: true
type: string
- description: State parameter
in: query
name: state
required: true
type: string
responses:
"200":
description: HTML success page
schema:
type: string
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: OAuth2 callback for LLM providers
tags:
- providers-oauth
/search-providers:
get:
consumes: