mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) == ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
+88
-47
File diff suppressed because one or more lines are too long
+387
-189
@@ -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: {
|
||||
|
||||
Generated
+95
-4
@@ -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
File diff suppressed because it is too large
Load Diff
+511
-382
File diff suppressed because it is too large
Load Diff
+349
-264
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user