[codex] add QQBot and DingTalk channel settings (#787)

* add qqbot and dingtalk channel settings

* remove history session context menu
This commit is contained in:
ekko
2026-05-16 13:54:38 +08:00
committed by GitHub
parent 67723d9315
commit db0c23bf5e
9 changed files with 107 additions and 176 deletions
+1
View File
@@ -59,6 +59,7 @@ export interface AppConfig {
wecom?: Record<string, any>
feishu?: Record<string, any>
dingtalk?: Record<string, any>
qqbot?: Record<string, any>
platforms?: Record<string, any>
[key: string]: any
}
@@ -52,6 +52,10 @@ function getCreds(key: string) {
return (settingsStore.platforms[key] || {}) as Record<string, any>
}
function boolValue(value: unknown) {
return value === true || value === 'true'
}
// Weixin QR code login state
const wxQrUrl = ref('')
const wxQrId = ref('')
@@ -152,6 +156,18 @@ const platforms = [
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.59 3.41a2.25 2.25 0 0 1 3.182 0L13.5 7.14l-3.182 3.182L6.59 7.59a2.25 2.25 0 0 1 0-3.182zm5.303 5.303L15.075 5.53a2.25 2.25 0 0 1 3.182 3.182L15.075 11.894 11.893 8.713zM3.41 6.59a2.25 2.25 0 0 1 3.182 0l3.182 3.182-3.182 3.182a2.25 2.25 0 0 1-3.182-3.182L3.41 6.59zm5.303 5.303L11.894 15.075a2.25 2.25 0 0 1-3.182 3.182L5.53 15.075 8.713 11.893zm5.303-5.303L17.478 9.778a2.25 2.25 0 0 1-3.182 3.182L10.53 10.075l3.182-3.182 0 .023z"/></svg>',
},
{
key: 'dingtalk',
name: 'DingTalk',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.76 7.05c-.23-.52-.7-.9-1.26-1.02L5.35 3.2c-.77-.16-1.51.38-1.58 1.16-.22 2.55.17 5.4 1.13 7.66.97 2.29 2.52 4.11 4.45 4.82l-1.28 3.03c-.17.4.24.79.63.59l9.47-4.83c.34-.17.55-.52.55-.9v-3.12c.73-.4 1.22-1.17 1.22-2.06 0-.87-.08-1.73-.18-2.5zm-3.66 5.95-5.19 2.65.76-1.8c.12-.29-.03-.62-.33-.72-2.1-.73-3.56-3.54-3.95-6.73l9.27 2c.04.38.07.76.07 1.15 0 .45-.36.81-.81.81h-2.79c-.35 0-.63.28-.63.63s.28.63.63.63h2.97V13z"/></svg>',
},
{
key: 'qqbot',
name: 'QQBot',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C7.58 2 4 5.27 4 9.31c0 2.3 1.15 4.34 2.95 5.68-.13.58-.48 1.62-1.26 2.53-.24.28-.05.72.32.73 1.72.05 3.02-.68 3.69-1.15.72.16 1.49.25 2.3.25 4.42 0 8-3.27 8-7.31S16.42 2 12 2zm-3.2 7.63c-.63 0-1.14-.55-1.14-1.23s.51-1.23 1.14-1.23 1.14.55 1.14 1.23-.51 1.23-1.14 1.23zm6.4 0c-.63 0-1.14-.55-1.14-1.23s.51-1.23 1.14-1.23 1.14.55 1.14 1.23-.51 1.23-1.14 1.23zM5.5 20.5a.5.5 0 0 1 .5-.5h12a.5.5 0 0 1 0 1H6a.5.5 0 0 1-.5-.5z"/></svg>',
},
{
key: 'weixin',
name: 'Weixin',
@@ -302,6 +318,12 @@ const platforms = [
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
<NInput :default-value="getCreds('dingtalk').extra?.client_secret || ''" :loading="isSaving('dingtalk', 'client_secret')" clearable size="small" class="input-lg" placeholder="Client Secret" @change="v => saveCredentials('dingtalk', 'client_secret', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
<NSwitch :value="boolValue(getCreds('dingtalk').allow_all_users)" :loading="isSaving('dingtalk', 'allow_all_users')" @update:value="v => saveCredentials('dingtalk', 'allow_all_users', { allow_all_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
<NInput :default-value="getCreds('dingtalk').allowed_users || ''" :loading="isSaving('dingtalk', 'allowed_users')" clearable size="small" class="input-lg" placeholder="user_id1,user_id2" @change="v => saveCredentials('dingtalk', 'allowed_users', { allowed_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.dingtalk.require_mention" :loading="isSaving('dingtalk', 'require_mention')" @update:value="v => saveChannel('dingtalk', 'require_mention', { require_mention: v })" />
</SettingRow>
@@ -310,6 +332,25 @@ const platforms = [
</SettingRow>
</template>
<!-- QQBot -->
<template v-if="p.key === 'qqbot'">
<SettingRow :label="t('platform.qqAppId')" :hint="t('platform.qqAppIdHint')">
<NInput :default-value="getCreds('qqbot').extra?.app_id || ''" :loading="isSaving('qqbot', 'app_id')" clearable size="small" class="input-lg" placeholder="App ID" @change="v => saveCredentials('qqbot', 'app_id', { extra: { ...getCreds('qqbot').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.qqAppSecret')" :hint="t('platform.qqAppSecretHint')">
<NInput :default-value="getCreds('qqbot').extra?.client_secret || ''" :loading="isSaving('qqbot', 'client_secret')" clearable size="small" class="input-lg" placeholder="App Secret" @change="v => saveCredentials('qqbot', 'client_secret', { extra: { ...getCreds('qqbot').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
<NInput :default-value="getCreds('qqbot').allowed_users || ''" :loading="isSaving('qqbot', 'allowed_users')" clearable size="small" class="input-lg" placeholder="openid1,openid2" @change="v => saveCredentials('qqbot', 'allowed_users', { allowed_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
<NSwitch :value="boolValue(getCreds('qqbot').allow_all_users)" :loading="isSaving('qqbot', 'allow_all_users')" @update:value="v => saveCredentials('qqbot', 'allow_all_users', { allow_all_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.qqMarkdown')" :hint="t('platform.qqMarkdownHint')">
<NSwitch :value="settingsStore.qqbot.extra?.markdown_support ?? true" :loading="isSaving('qqbot', 'markdown_support')" @update:value="v => saveChannel('qqbot', 'markdown_support', { extra: { ...settingsStore.qqbot.extra, markdown_support: v } })" />
</SettingRow>
</template>
<!-- Weixin -->
<template v-if="p.key === 'weixin'">
<div class="weixin-qr-section">
+4
View File
@@ -920,6 +920,10 @@ export default {
clientIdHint: 'DingTalk Client ID',
clientSecret: 'Client Secret',
clientSecretHint: 'DingTalk Client Secret',
allowedUsers: 'Allowed Users',
allowedUsersHint: 'Whitelist user IDs or OpenIDs (comma-separated)',
allowAllUsers: 'Allow All Users',
allowAllUsersHint: 'Allow messages from any user; keep off to use the allowlist',
botId: 'Bot ID',
botIdHint: 'WeCom Bot ID',
wecomSecretHint: 'WeCom Bot Secret',
@@ -909,6 +909,10 @@ export default {
clientIdHint: '釘釘 Client ID',
clientSecret: 'Client Secret',
clientSecretHint: '釘釘 Client Secret',
allowedUsers: '允許使用者',
allowedUsersHint: '使用者 ID 或 OpenID 白名單,多個請用英文逗號分隔',
allowAllUsers: '允許所有使用者',
allowAllUsersHint: '允許任意使用者發起訊息;關閉後使用白名單',
botId: 'Bot ID',
botIdHint: '企業微信 Bot ID',
wecomSecretHint: '企業微信 Bot Secret',
+4
View File
@@ -912,6 +912,10 @@ export default {
clientIdHint: '钉钉 Client ID',
clientSecret: 'Client Secret',
clientSecretHint: '钉钉 Client Secret',
allowedUsers: '允许用户',
allowedUsersHint: '用户 ID 或 OpenID 白名单,多个用英文逗号分隔',
allowAllUsers: '允许所有用户',
allowAllUsersHint: '允许任意用户发起消息;关闭后使用白名单',
botId: 'Bot ID',
botIdHint: '企业微信 Bot ID',
wecomSecretHint: '企业微信 Bot Secret',
@@ -21,6 +21,7 @@ export const useSettingsStore = defineStore('settings', () => {
const wecom = ref<Record<string, any>>({})
const feishu = ref<Record<string, any>>({})
const dingtalk = ref<Record<string, any>>({})
const qqbot = ref<Record<string, any>>({})
const weixin = ref<Record<string, any>>({})
const platforms = ref<Record<string, any>>({})
@@ -42,6 +43,7 @@ export const useSettingsStore = defineStore('settings', () => {
wecom.value = data.wecom || {}
feishu.value = data.feishu || {}
dingtalk.value = data.dingtalk || {}
qqbot.value = data.qqbot || {}
weixin.value = data.weixin || {}
platforms.value = data.platforms || {}
} catch (err) {
@@ -67,6 +69,7 @@ export const useSettingsStore = defineStore('settings', () => {
case 'wecom': wecom.value = { ...wecom.value, ...values }; break
case 'feishu': feishu.value = { ...feishu.value, ...values }; break
case 'dingtalk': dingtalk.value = { ...dingtalk.value, ...values }; break
case 'qqbot': qqbot.value = { ...qqbot.value, ...values }; break
case 'weixin': weixin.value = { ...weixin.value, ...values }; break
case 'platforms': {
for (const [key, val] of Object.entries(values)) {
@@ -99,6 +102,7 @@ export const useSettingsStore = defineStore('settings', () => {
case 'wechat': case 'wecom': wecom.value = { ...wecom.value, ...values }; break
case 'feishu': feishu.value = { ...feishu.value, ...values }; break
case 'dingtalk': dingtalk.value = { ...dingtalk.value, ...values }; break
case 'qqbot': qqbot.value = { ...qqbot.value, ...values }; break
case 'weixin': weixin.value = { ...weixin.value, ...values }; break
case 'platforms': {
// Deep-merge each platform's credentials
@@ -119,7 +123,7 @@ export const useSettingsStore = defineStore('settings', () => {
return {
loading, saving,
display, agent, memory, sessionReset, privacy, approvals,
telegram, discord, slack, whatsapp, matrix, wecom, feishu, dingtalk, weixin, platforms,
telegram, discord, slack, whatsapp, matrix, wecom, feishu, dingtalk, qqbot, weixin, platforms,
fetchSettings, saveSection, updateLocal,
}
})
@@ -1,17 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useChatStore, type Session } from '@/stores/hermes/chat'
import { useAppStore } from '@/stores/hermes/app'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive-ui'
import { NButton, NTooltip, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { getSourceLabel } from '@/shared/session-display'
import { copyToClipboard } from '@/utils/clipboard'
import FolderPicker from '@/components/hermes/chat/FolderPicker.vue'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
import { renameSession, setSessionWorkspace, fetchHermesSessions, fetchHermesSession, exportSession, type SessionSummary } from '@/api/hermes/sessions'
import { fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
const chatStore = useChatStore()
const appStore = useAppStore()
@@ -125,10 +124,6 @@ onUnmounted(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
})
const showRenameModal = ref(false)
const renameValue = ref('')
const renameSessionId = ref<string | null>(null)
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
// Convert SessionSummary to Session format
@@ -272,129 +267,6 @@ async function copySessionId(id?: string) {
}
}
const contextSessionId = ref<string | null>(null)
const contextSessionPinned = computed(() =>
contextSessionId.value ? sessionBrowserPrefsStore.isPinned(contextSessionId.value) : false,
)
const contextMenuOptions = computed(() => [
{ label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' },
{ label: t('chat.rename'), key: 'rename' },
{ label: t('chat.setWorkspace'), key: 'workspace' },
{
label: t('chat.export'),
key: 'export',
children: [
{
label: t('chat.exportFull'),
key: 'export-full',
children: [
{ label: 'JSON', key: 'export-full-json' },
{ label: 'TXT', key: 'export-full-txt' },
],
},
{
label: t('chat.exportCompressed'),
key: 'export-compressed',
children: [
{ label: 'JSON', key: 'export-compressed-json' },
{ label: 'TXT', key: 'export-compressed-txt' },
],
},
],
},
{ label: t('chat.copySessionId'), key: 'copy-id' },
])
function handleContextMenu(e: MouseEvent, sessionId: string) {
e.preventDefault()
contextSessionId.value = sessionId
showContextMenu.value = true
contextMenuX.value = e.clientX
contextMenuY.value = e.clientY
}
const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
function parseExportKey(key: string): { mode: 'full' | 'compressed'; ext: 'json' | 'txt' } | null {
if (key === 'export-full-json') return { mode: 'full', ext: 'json' }
if (key === 'export-full-txt') return { mode: 'full', ext: 'txt' }
if (key === 'export-compressed-json') return { mode: 'compressed', ext: 'json' }
if (key === 'export-compressed-txt') return { mode: 'compressed', ext: 'txt' }
return null
}
async function handleContextMenuSelect(key: string) {
showContextMenu.value = false
if (!contextSessionId.value) return
if (key === 'pin') {
sessionBrowserPrefsStore.togglePinned(contextSessionId.value)
return
}
if (key === 'copy-id') {
copySessionId(contextSessionId.value)
} else if (parseExportKey(key)) {
const { mode, ext } = parseExportKey(key)!
const loadingMsg = mode === 'compressed' ? message.loading(t('chat.exportCompressing'), { duration: 0 }) : null
try {
await exportSession(contextSessionId.value, mode, ext)
loadingMsg?.destroy()
message.success(t('chat.exportSuccess'))
} catch {
loadingMsg?.destroy()
message.error(t('chat.exportFailed'))
}
} else if (key === 'workspace') {
const session = historySessions.value.find(s => s.id === contextSessionId.value)
workspaceSessionId.value = contextSessionId.value
workspaceValue.value = session?.workspace || ''
showWorkspaceModal.value = true
} else if (key === 'rename') {
const session = historySessions.value.find(s => s.id === contextSessionId.value)
renameSessionId.value = contextSessionId.value
renameValue.value = session?.title || ''
showRenameModal.value = true
nextTick(() => {
renameInputRef.value?.focus()
})
}
}
function handleClickOutside() {
showContextMenu.value = false
}
async function handleRenameConfirm() {
if (!renameSessionId.value || !renameValue.value.trim()) return
const ok = await renameSession(renameSessionId.value, renameValue.value.trim())
if (ok) {
// Reload Hermes sessions to get updated title
await loadHermesSessions()
message.success(t('chat.renamed'))
} else {
message.error(t('chat.renameFailed'))
}
showRenameModal.value = false
}
const showWorkspaceModal = ref(false)
const workspaceValue = ref('')
const workspaceSessionId = ref<string | null>(null)
async function handleWorkspaceConfirm() {
if (!workspaceSessionId.value) return
const ok = await setSessionWorkspace(workspaceSessionId.value, workspaceValue.value || null)
if (ok) {
// Reload Hermes sessions to get updated workspace
await loadHermesSessions()
message.success(t('chat.workspaceSet'))
} else {
message.error(t('chat.workspaceSetFailed'))
}
showWorkspaceModal.value = false
}
</script>
<template>
@@ -430,7 +302,6 @@ async function handleWorkspaceConfirm() {
:can-delete="false"
:streaming="false"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
/>
</template>
@@ -450,52 +321,12 @@ async function handleWorkspaceConfirm() {
:can-delete="false"
:streaming="false"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
/>
</template>
</template>
</div>
</aside>
<NDropdown
placement="bottom-start"
trigger="manual"
:x="contextMenuX"
:y="contextMenuY"
:options="contextMenuOptions"
:show="showContextMenu"
@select="handleContextMenuSelect"
@clickoutside="handleClickOutside"
/>
<NModal
v-model:show="showRenameModal"
preset="dialog"
:title="t('chat.renameSession')"
:positive-text="t('common.ok')"
:negative-text="t('common.cancel')"
@positive-click="handleRenameConfirm"
>
<NInput
ref="renameInputRef"
v-model:value="renameValue"
:placeholder="t('chat.enterNewTitle')"
@keydown.enter="handleRenameConfirm"
/>
</NModal>
<NModal
v-model:show="showWorkspaceModal"
preset="dialog"
:title="t('chat.setWorkspaceTitle')"
:positive-text="t('common.ok')"
:negative-text="t('common.cancel')"
style="width: 520px"
@positive-click="handleWorkspaceConfirm"
>
<FolderPicker v-model="workspaceValue" />
</NModal>
<div class="chat-main">
<header class="chat-header">
<div class="header-left">
@@ -7,7 +7,7 @@ import { safeFileStore } from '../../services/safe-file-store'
const PLATFORM_SECTIONS = new Set([
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
'weixin', 'wecom', 'feishu', 'dingtalk',
'weixin', 'wecom', 'feishu', 'dingtalk', 'qqbot',
'approvals',
])
@@ -25,6 +25,12 @@ const envPlatformMap: Record<string, [string, string]> = {
DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'],
DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'],
DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'],
DINGTALK_ALLOWED_USERS: ['dingtalk', 'allowed_users'],
DINGTALK_ALLOW_ALL_USERS: ['dingtalk', 'allow_all_users'],
QQ_APP_ID: ['qqbot', 'extra.app_id'],
QQ_CLIENT_SECRET: ['qqbot', 'extra.client_secret'],
QQ_ALLOWED_USERS: ['qqbot', 'allowed_users'],
QQ_ALLOW_ALL_USERS: ['qqbot', 'allow_all_users'],
WECOM_BOT_ID: ['wecom', 'extra.bot_id'],
WECOM_SECRET: ['wecom', 'extra.secret'],
WEIXIN_TOKEN: ['weixin', 'token'],
@@ -82,7 +88,7 @@ async function readEnvPlatforms(): Promise<Record<string, any>> {
if (val === undefined || val === '') continue
if (!platforms[platform]) platforms[platform] = {}
let finalVal: any = val
if (cfgPath === 'enabled') finalVal = val === 'true'
if (cfgPath === 'enabled' || cfgPath === 'allow_all_users') finalVal = val === 'true'
setNested(platforms[platform], cfgPath, finalVal)
}
return platforms
@@ -100,7 +106,7 @@ export async function getConfig(ctx: any) {
if (Object.keys(envPlatforms).length > 0) {
const existing = config.platforms || {}
for (const [platform, vals] of Object.entries(envPlatforms)) {
existing[platform] = { ...(existing[platform] || {}), ...(vals as Record<string, any>) }
existing[platform] = deepMerge(existing[platform] || {}, vals as Record<string, any>)
}
config.platforms = existing
}
@@ -105,4 +105,40 @@ describe('config controller locked file updates', () => {
expect(config.platforms.weixin.extra.base_url).toBe('https://old.example')
expect(config.model.default).toBe('glm-5.1')
})
it('writes QQBot credentials to env and overlays them into platform config reads', async () => {
await writeFile(join(hermesHome, 'config.yaml'), [
'platforms:',
' qqbot:',
' extra:',
' markdown_support: true',
'',
].join('\n'), 'utf-8')
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
const { updateCredentials, getConfig } = await loadController()
await updateCredentials(makeCtx({
platform: 'qqbot',
values: {
extra: { app_id: 'qq-app', client_secret: 'qq-secret' },
allowed_users: 'user-1,user-2',
allow_all_users: false,
},
}))
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
expect(env).toContain('OPENROUTER_API_KEY=keep')
expect(env).toContain('QQ_APP_ID=qq-app')
expect(env).toContain('QQ_CLIENT_SECRET=qq-secret')
expect(env).toContain('QQ_ALLOWED_USERS=user-1,user-2')
expect(env).toContain('QQ_ALLOW_ALL_USERS=false')
const ctx = makeCtx({})
await getConfig(ctx)
expect(ctx.body.platforms.qqbot.extra.app_id).toBe('qq-app')
expect(ctx.body.platforms.qqbot.extra.client_secret).toBe('qq-secret')
expect(ctx.body.platforms.qqbot.extra.markdown_support).toBe(true)
expect(ctx.body.platforms.qqbot.allowed_users).toBe('user-1,user-2')
expect(ctx.body.platforms.qqbot.allow_all_users).toBe(false)
})
})