Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/apps/developers/content/docs/en/settings/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Connect to a self-hosted OneBot v11 backend (go-cqhttp, NapCat, Lagrange, etc.).

- **WebSocket URL** — reverse or forward WebSocket endpoint of the OneBot service.
- **HTTP API URL** + **Token** — used for outbound API calls and authentication.
- **Bot name** — robot name used as a group-chat trigger keyword.
- **QR Login** — scan to log in the underlying QQ account.
- **Auto Re-login** — automatically reconnect when the session drops.
- **Allowed QQ Users** — QQ numbers allowed in private chats.
Expand Down
1 change: 1 addition & 0 deletions src/apps/developers/content/docs/zh/settings/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Channel 设置用于配置 Arkloop 与各个外部消息平台的连接。每个

- **WebSocket URL** —— OneBot 服务的正向或反向 WebSocket 端点。
- **HTTP API URL** + **Token** —— 对外 API 调用地址及鉴权 token。
- **Bot 名称** —— 群聊中可作为触发关键词使用的机器人名称。
- **QR Login** —— 扫码登录底层 QQ 账号。
- **Auto Re-login** —— 会话掉线时自动重连。
- **Allowed QQ Users** —— 允许私聊的 QQ 号。
Expand Down
51 changes: 12 additions & 39 deletions src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('ChatInput persona selector', () => {
}
})

it('按动态列表循环切换并可从下拉选择人格', async () => {
it('不再从加号菜单加载动态人格,提交沿用已选人格', async () => {
const onSubmit = vi.fn<(event: FormEvent<HTMLFormElement>, personaKey: string) => void>((event) => event.preventDefault())
const container = document.createElement('div')
document.body.appendChild(container)
Expand All @@ -149,49 +149,22 @@ describe('ChatInput persona selector', () => {
await flushMicrotasks()
})

expect(mockedListSelectablePersonas).toHaveBeenCalledWith('token')

const selectorButton = findButtonByText(container, 'Normal')
expect(selectorButton).not.toBeNull()
if (!selectorButton) return

await act(async () => {
selectorButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})

const searchMenuButton = Array.from(container.querySelectorAll('button')).find(
(button) => button !== selectorButton && button.textContent?.trim() === 'Search',
) as HTMLButtonElement | null
expect(searchMenuButton).not.toBeNull()
if (!searchMenuButton) return

await act(async () => {
searchMenuButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})

expect(findButtonByText(container, 'Search')).not.toBeNull()

const searchSelectorButton = findButtonByText(container, 'Search')
expect(searchSelectorButton).not.toBeNull()
if (!searchSelectorButton) return

await act(async () => {
searchSelectorButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
const form = container.querySelector('form')
expect(form).not.toBeNull()
if (!form) return

const menuNormalButton = Array.from(container.querySelectorAll('button')).find(
(button) => button !== searchSelectorButton && button.textContent?.trim() === 'Normal',
) as HTMLButtonElement | null
expect(menuNormalButton).not.toBeNull()
if (!menuNormalButton) return
const menuButton = form.querySelector<HTMLButtonElement>('button[type="button"]')
expect(menuButton).not.toBeNull()
if (!menuButton) return

await act(async () => {
menuNormalButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
menuButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushMicrotasks()
})

const form = container.querySelector('form')
expect(form).not.toBeNull()
if (!form) return
expect(mockedListSelectablePersonas).not.toHaveBeenCalled()
expect(findButtonByText(container, 'Normal')).toBeFalsy()
expect(findButtonByText(container, 'Search')).toBeFalsy()

await act(async () => {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
Expand Down
169 changes: 169 additions & 0 deletions src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ async function loadChannelsSubject() {
updateChannel: vi.fn(),
verifyChannel: vi.fn(),
createChannelBindCode: vi.fn(),
listChannelBindings: vi.fn().mockResolvedValue([]),
updateChannelBinding: vi.fn(),
deleteChannelBinding: vi.fn(),
unbindChannelIdentity: vi.fn(),
isApiError: vi.fn(() => false),
}
Expand Down Expand Up @@ -510,6 +513,172 @@ describe('DesktopChannelsSettings', () => {
expect(document.body.textContent).toContain('app-123')
})

it('可以保存 QQ OneBot 的 Bot 名称配置', async () => {
const { api, DesktopChannelsSettings, LocaleProvider } = await loadChannelsSubject()
const qqChannel = {
id: 'qq-1',
account_id: 'acc-1',
channel_type: 'qq',
persona_id: 'persona-1',
webhook_url: null,
is_active: true,
config_json: {
onebot_ws_url: 'ws://127.0.0.1:6098',
onebot_http_url: 'http://127.0.0.1:3000',
onebot_token: 'secret',
bot_name: 'Old Bot',
allowed_user_ids: ['10001'],
allowed_group_ids: ['20001'],
},
has_credentials: true,
created_at: '2026-03-26T00:00:00Z',
updated_at: '2026-03-26T00:00:00Z',
}
vi.mocked(api.listChannels).mockResolvedValue([qqChannel])
vi.mocked(api.listMyChannelIdentities).mockResolvedValue([])
vi.mocked(api.listChannelPersonas).mockResolvedValue([
{
id: 'persona-1',
persona_key: 'normal',
version: '1',
display_name: 'Normal',
source: 'project',
} as never,
])
vi.mocked(api.listLlmProviders).mockResolvedValue([])
vi.mocked(api.listChannelBindings).mockResolvedValue([])
vi.mocked(api.updateChannel).mockResolvedValue({
...qqChannel,
config_json: {
...qqChannel.config_json,
bot_name: 'New Bot',
},
})

await act(async () => {
root!.render(
<LocaleProvider>
<DesktopChannelsSettings accessToken="token" />
</LocaleProvider>,
)
})
await flushEffects()

const qqOneBotTab = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('OneBot'))
expect(qqOneBotTab).toBeTruthy()

await act(async () => {
qqOneBotTab!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await flushEffects()

const botNameInput = Array.from(document.body.querySelectorAll('input')).find((input) => input.value === 'Old Bot') as HTMLInputElement
expect(botNameInput).toBeTruthy()

await act(async () => {
setInputValue(botNameInput, 'New Bot')
})
await flushEffects()

const saveButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.trim() === '保存')
await act(async () => {
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await flushEffects()

expect(api.updateChannel).toHaveBeenCalledWith('token', 'qq-1', {
persona_id: 'persona-1',
is_active: true,
config_json: {
onebot_ws_url: 'ws://127.0.0.1:6098',
onebot_http_url: 'http://127.0.0.1:3000',
onebot_token: 'secret',
bot_name: 'New Bot',
allowed_user_ids: ['10001'],
allowed_group_ids: ['20001'],
},
})
})

it('清空 QQ OneBot 的 Bot 名称时会移除配置字段', async () => {
const { api, DesktopChannelsSettings, LocaleProvider } = await loadChannelsSubject()
const qqChannel = {
id: 'qq-1',
account_id: 'acc-1',
channel_type: 'qq',
persona_id: 'persona-1',
webhook_url: null,
is_active: true,
config_json: {
onebot_ws_url: 'ws://127.0.0.1:6098',
bot_name: 'Old Bot',
},
has_credentials: true,
created_at: '2026-03-26T00:00:00Z',
updated_at: '2026-03-26T00:00:00Z',
}
vi.mocked(api.listChannels).mockResolvedValue([qqChannel])
vi.mocked(api.listMyChannelIdentities).mockResolvedValue([])
vi.mocked(api.listChannelPersonas).mockResolvedValue([
{
id: 'persona-1',
persona_key: 'normal',
version: '1',
display_name: 'Normal',
source: 'project',
} as never,
])
vi.mocked(api.listLlmProviders).mockResolvedValue([])
vi.mocked(api.listChannelBindings).mockResolvedValue([])
vi.mocked(api.updateChannel).mockResolvedValue({
...qqChannel,
config_json: {
onebot_ws_url: 'ws://127.0.0.1:6098',
},
})

await act(async () => {
root!.render(
<LocaleProvider>
<DesktopChannelsSettings accessToken="token" />
</LocaleProvider>,
)
})
await flushEffects()

const qqOneBotTab = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('OneBot'))
expect(qqOneBotTab).toBeTruthy()

await act(async () => {
qqOneBotTab!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await flushEffects()

const botNameInput = Array.from(document.body.querySelectorAll('input')).find((input) => input.value === 'Old Bot') as HTMLInputElement
expect(botNameInput).toBeTruthy()

await act(async () => {
setInputValue(botNameInput, ' ')
})
await flushEffects()

const saveButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.trim() === '保存')
await act(async () => {
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await flushEffects()

expect(api.updateChannel).toHaveBeenCalledWith('token', 'qq-1', {
persona_id: 'persona-1',
is_active: true,
config_json: {
onebot_ws_url: 'ws://127.0.0.1:6098',
allowed_user_ids: [],
allowed_group_ids: [],
},
})
})

it('可以创建飞书官方渠道并提交官方接入配置', async () => {
const { api, DesktopChannelsSettings, LocaleProvider } = await loadChannelsSubject()
vi.mocked(api.listChannels).mockResolvedValue([])
Expand Down
Loading
Loading