diff --git a/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx b/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx index b650dc7c0..ea1006388 100644 --- a/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx +++ b/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx @@ -117,6 +117,9 @@ async function loadChannelsSubject() { createChannel: vi.fn(), updateChannel: vi.fn(), verifyChannel: vi.fn(), + listChannelBindings: vi.fn().mockResolvedValue([]), + deleteChannelBinding: vi.fn(), + updateChannelBinding: vi.fn(), createChannelBindCode: vi.fn(), unbindChannelIdentity: vi.fn(), isApiError: vi.fn(() => false), @@ -665,4 +668,107 @@ describe('DesktopChannelsSettings', () => { expect(document.body.textContent).toContain('Arkloop Feishu') expect(document.body.textContent).toContain('ou_bot') }) + + it('persists QQ OneBot access control when adding a QQ user', 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', + 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', + } + const updatedQQChannel = { + ...qqChannel, + config_json: { + ...qqChannel.config_json, + allowed_user_ids: ['10001', '30003'], + }, + } + vi.mocked(api.listChannels) + .mockResolvedValueOnce([qqChannel]) + .mockResolvedValue([updatedQQChannel]) + 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(updatedQQChannel) + + await act(async () => { + root!.render( + + + , + ) + }) + 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 allowedUserInput = Array.from(document.body.querySelectorAll('input')).find((input) => input.getAttribute('placeholder')?.includes('QQ 号')) as HTMLInputElement + expect(allowedUserInput).toBeTruthy() + + await act(async () => { + setInputValue(allowedUserInput, '30003') + }) + await flushEffects() + + const addUserButton = allowedUserInput.nextElementSibling as HTMLButtonElement + await act(async () => { + addUserButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + 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', + allowed_user_ids: ['10001', '30003'], + allowed_group_ids: ['20001'], + }, + }) + + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + await flushEffects() + + const reopenedQQOneBotTab = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('OneBot')) + await act(async () => { + reopenedQQOneBotTab!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + expect(document.body.textContent).toContain('30003') + }) }) diff --git a/src/apps/web/src/components/settings/DesktopChannelSettingsShared.tsx b/src/apps/web/src/components/settings/DesktopChannelSettingsShared.tsx index 3ca51b75d..3a9c28c39 100644 --- a/src/apps/web/src/components/settings/DesktopChannelSettingsShared.tsx +++ b/src/apps/web/src/components/settings/DesktopChannelSettingsShared.tsx @@ -26,14 +26,18 @@ export const channelRowsCls = export function ChannelDetailRow({ label, children, + wide = false, }: { label: string children: ReactNode + wide?: boolean }) { return ( -
+
{label}
-
{children}
+
+ {children} +
) } diff --git a/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx b/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx index f4974508a..2691303c7 100644 --- a/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx +++ b/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx @@ -155,29 +155,10 @@ export function DesktopQQSettingsPanel({ ]) const canSave = dirty || channel === null - const handleAddAllowedUsers = () => { - const nextIDs = mergeListValues(allowedUserIDs, allowedUserInput) - if (nextIDs.length === allowedUserIDs.length) return - setAllowedUserIDs(nextIDs) - setAllowedUserInput('') - setSaved(false) - } - - const handleAddAllowedGroups = () => { - const nextIDs = mergeListValues(allowedGroupIDs, allowedGroupInput) - if (nextIDs.length === allowedGroupIDs.length) return - setAllowedGroupIDs(nextIDs) - setAllowedGroupInput('') - setSaved(false) - } - - const handleSave = async () => { - const nextAllowedUserIDs = mergeListValues(allowedUserIDs, allowedUserInput) - const nextAllowedGroupIDs = mergeListValues(allowedGroupIDs, allowedGroupInput) - + const persistQQSettings = async (nextAllowedUserIDs: string[], nextAllowedGroupIDs: string[]) => { if (enabled && !personaID) { setError(ct.personaRequired) - return + return false } setSaving(true) @@ -231,6 +212,7 @@ export function DesktopQQSettingsPanel({ setSaved(true) setTimeout(() => setSaved(false), 2500) await reload() + return true } catch (err) { if (err instanceof Error && err.name === 'AbortError') { setError(ds.connectorSaveTimeout) @@ -240,6 +222,50 @@ export function DesktopQQSettingsPanel({ } finally { setSaving(false) } + return false + } + + const handleSave = async () => { + await persistQQSettings( + mergeListValues(allowedUserIDs, allowedUserInput), + mergeListValues(allowedGroupIDs, allowedGroupInput), + ) + } + + const handleAddAllowedUsers = async () => { + const nextIDs = mergeListValues(allowedUserIDs, allowedUserInput) + if (nextIDs.length === allowedUserIDs.length) return + setAllowedUserIDs(nextIDs) + setAllowedUserInput('') + setSaved(false) + await persistQQSettings(nextIDs, mergeListValues(allowedGroupIDs, allowedGroupInput)) + } + + const handleAddAllowedGroups = async () => { + const nextIDs = mergeListValues(allowedGroupIDs, allowedGroupInput) + if (nextIDs.length === allowedGroupIDs.length) return + setAllowedGroupIDs(nextIDs) + setAllowedGroupInput('') + setSaved(false) + await persistQQSettings(mergeListValues(allowedUserIDs, allowedUserInput), nextIDs) + } + + const handleRemoveAllowedUser = async (value: string) => { + const nextIDs = allowedUserIDs.filter((item) => item !== value) + setAllowedUserIDs(nextIDs) + setSaved(false) + if (channel) { + await persistQQSettings(nextIDs, mergeListValues(allowedGroupIDs, allowedGroupInput)) + } + } + + const handleRemoveAllowedGroup = async (value: string) => { + const nextIDs = allowedGroupIDs.filter((item) => item !== value) + setAllowedGroupIDs(nextIDs) + setSaved(false) + if (channel) { + await persistQQSettings(mergeListValues(allowedUserIDs, allowedUserInput), nextIDs) + } } const handleGenerateBindCode = async () => { @@ -353,8 +379,10 @@ export function DesktopQQSettingsPanel({ { setEnabled(next); setSaved(false) }} />
- - + +
+ +
{isWindows && ( @@ -397,34 +425,30 @@ export function DesktopQQSettingsPanel({ onChange={(v) => { setOnebotToken(v); setSaved(false) }} /> - - { - setAllowedUserIDs((current) => current.filter((item) => item !== value)) - setSaved(false) - }} - /> - + +
+ - - { - setAllowedGroupIDs((current) => current.filter((item) => item !== value)) - setSaved(false) - }} - /> + +
diff --git a/src/services/api/internal/http/accountapi/channels_qq.go b/src/services/api/internal/http/accountapi/channels_qq.go index b53e0647c..24ea65f6c 100644 --- a/src/services/api/internal/http/accountapi/channels_qq.go +++ b/src/services/api/internal/http/accountapi/channels_qq.go @@ -41,18 +41,53 @@ type qqChannelConfig struct { AutoLoginUin string `json:"auto_login_uin,omitempty"` } +const qqAccessDeniedReplyText = "此用户不在白名单中。" + func resolveQQChannelConfig(raw json.RawMessage) (qqChannelConfig, error) { + _, cfg, err := normalizeQQChannelConfig(raw) + if err != nil { + return qqChannelConfig{}, fmt.Errorf("invalid qq channel config: %w", err) + } + return cfg, nil +} + +func normalizeQQChannelConfig(raw json.RawMessage) (json.RawMessage, qqChannelConfig, error) { if len(raw) == 0 { - return qqChannelConfig{AllowAllUsers: true}, nil + raw = json.RawMessage(`{}`) } var cfg qqChannelConfig if err := json.Unmarshal(raw, &cfg); err != nil { - return qqChannelConfig{}, fmt.Errorf("invalid qq channel config: %w", err) + return nil, qqChannelConfig{}, fmt.Errorf("config_json must be a valid JSON object") } - if len(cfg.AllowedUserIDs) == 0 && len(cfg.AllowedGroupIDs) == 0 { - cfg.AllowAllUsers = true + cfg.AllowedUserIDs = normalizeQQIDList(cfg.AllowedUserIDs) + cfg.AllowedGroupIDs = normalizeQQIDList(cfg.AllowedGroupIDs) + cfg.AllowAllUsers = len(cfg.AllowedUserIDs) == 0 && len(cfg.AllowedGroupIDs) == 0 + normalized, err := json.Marshal(cfg) + if err != nil { + return nil, qqChannelConfig{}, err } - return cfg, nil + return normalized, cfg, nil +} + +func normalizeQQIDList(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + for _, item := range strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' ' + }) { + cleaned := strings.TrimSpace(item) + if cleaned == "" { + continue + } + if _, ok := seen[cleaned]; ok { + continue + } + seen[cleaned] = struct{}{} + out = append(out, cleaned) + } + } + return out } func qqUserAllowed(cfg qqChannelConfig, userID, groupID string) bool { @@ -74,6 +109,38 @@ func qqUserAllowed(cfg qqChannelConfig, userID, groupID string) bool { return false } +func qqAccessDeniedReplyDestination(userID, groupID string) (string, string) { + if groupID != "" { + return "group", groupID + } + return "private", userID +} + +func qqShouldReplyAccessDenied(incoming qqIncomingMessage) bool { + if incoming.inboundMessage().ShouldCreateRun() { + return true + } + if incoming.ChatType != "group" { + return false + } + cmd, ok := slashCommandBase(stripLeadingMention(incoming.Text), "") + if !ok { + return false + } + return qqKnownChannelCommand(cmd) +} + +func qqKnownChannelCommand(cmd string) bool { + switch { + case channelCommandRequiresAdmin(cmd): + return true + case cmd == "/help", cmd == "/start", cmd == "/bind": + return true + default: + return false + } +} + // --- incoming message --- type qqIncomingMessage struct { @@ -180,10 +247,6 @@ func (c *qqConnector) HandleEvent(ctx context.Context, traceID string, ch data.C groupID = "" } - if !qqUserAllowed(cfg, userID, groupID) { - return nil - } - text := strings.TrimSpace(event.PlainText()) imageURLs := event.ImageURLs() if text == "" && len(imageURLs) == 0 { @@ -240,6 +303,14 @@ func (c *qqConnector) HandleEvent(ctx context.Context, traceID string, ch data.C } } + if !qqUserAllowed(cfg, userID, groupID) { + if qqShouldReplyAccessDenied(incoming) { + msgType, target := qqAccessDeniedReplyDestination(userID, groupID) + c.sendQQReply(ctx, cfg, msgType, target, qqAccessDeniedReplyText) + } + return nil + } + persona, personaRef, err := c.resolveQQPersona(ctx, ch) if err != nil { return err @@ -340,21 +411,6 @@ func (c *qqConnector) HandleEvent(ctx context.Context, traceID string, ch data.C // --- 私聊路径 --- if isPrivate { - // bind 访问控制(复用 Telegram 的 bootstrap 判断) - if c.channelIdentityLinksRepo != nil && !telegramLinkBootstrapAllowed(text) { - hasLink, err := c.channelIdentityLinksRepo.WithTx(tx).HasLink(ctx, ch.ID, identity.ID) - if err != nil { - return err - } - if !hasLink { - if err := commitTx(); err != nil { - return err - } - c.sendQQReply(ctx, cfg, "private", platformChatID, "当前账号未关联此接入。请使用 /bind 关联。") - return nil - } - } - handled, replyText, _, _, cancelRunID, err := DispatchChannelCommand( ctx, tx, ch, *persona, identity, text, true, platformChatID, @@ -418,8 +474,8 @@ func (c *qqConnector) HandleEvent(ctx context.Context, traceID string, ch data.C } return &gi, nil }, - IsGroupAdmin: func(ctx context.Context) bool { - return c.isQQGroupAdmin(ctx, cfg, platformChatID, identity.PlatformSubjectID) + IsBoundAdmin: func(ctx context.Context) bool { + return qqChannelIdentityIsOwner(ctx, tx, ch, identity, c.channelIdentityLinksRepo) }, BindCode: func() string { parts := strings.Fields(cmdText) @@ -607,22 +663,25 @@ func (c *qqConnector) checkReplyToBot(ctx context.Context, cfg qqChannelConfig, return false } -// --- commands --- - -// isQQGroupAdmin 通过 OneBot API 校验群管理员权限 -func (c *qqConnector) isQQGroupAdmin(ctx context.Context, cfg qqChannelConfig, groupID, userID string) bool { - client := c.buildOneBotClient(cfg) - if client == nil { - return true // 无法校验时放行 +func qqChannelIdentityIsOwner(ctx context.Context, tx pgx.Tx, ch data.Channel, identity data.ChannelIdentity, repo *data.ChannelIdentityLinksRepository) bool { + if repo == nil || ch.ID == uuid.Nil || identity.ID == uuid.Nil || ch.OwnerUserID == nil || identity.UserID == nil { + return false + } + if *ch.OwnerUserID == uuid.Nil || *identity.UserID != *ch.OwnerUserID { + return false + } + var linked bool + var err error + if tx != nil { + linked, err = repo.WithTx(tx).HasLink(ctx, ch.ID, identity.ID) + } else { + linked, err = repo.HasLink(ctx, ch.ID, identity.ID) } - reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - info, err := client.GetGroupMemberInfo(reqCtx, groupID, userID) if err != nil { - slog.Warn("qq_admin_check_failed", "group_id", groupID, "user_id", userID, "error", err) - return true // API 失败时放行 + slog.WarnContext(ctx, "qq_owner_check_failed", "error", err, "channel_id", ch.ID, "identity_id", identity.ID) + return false } - return info.Role == "owner" || info.Role == "admin" + return linked } // --- passive persist --- diff --git a/src/services/api/internal/http/accountapi/channels_qq_test.go b/src/services/api/internal/http/accountapi/channels_qq_test.go index 1d21826eb..199d033c5 100644 --- a/src/services/api/internal/http/accountapi/channels_qq_test.go +++ b/src/services/api/internal/http/accountapi/channels_qq_test.go @@ -1,8 +1,14 @@ package accountapi import ( + "context" + "encoding/json" "strings" "testing" + + "arkloop/services/api/internal/data" + + "github.com/google/uuid" ) func TestTelegramCommandBaseWorksForQQ(t *testing.T) { @@ -121,6 +127,115 @@ func TestChannelCommandHelpTextUsesGroupMentions(t *testing.T) { } } +func TestQQGroupCommandAuthorizationUsesArkloopOwner(t *testing.T) { + t.Run("qq group role is not a fallback", func(t *testing.T) { + handled, replyText, _, _, _, err := DispatchChannelCommand( + context.Background(), + nil, + data.Channel{}, + data.Persona{}, + data.ChannelIdentity{}, + "/new", + false, + "20002", + nil, + ChannelCommandResolver{ + IsBoundAdmin: func(context.Context) bool { return false }, + IsGroupAdmin: func(context.Context) bool { return true }, + }, + ChannelCommandDeps{}, + "QQ", + ) + if err != nil { + t.Fatal(err) + } + if !handled || replyText != "无权限。" { + t.Fatalf("expected Arkloop binding denial, handled=%v reply=%q", handled, replyText) + } + }) + + t.Run("owner identity can use group command", func(t *testing.T) { + handled, replyText, _, _, _, err := DispatchChannelCommand( + context.Background(), + nil, + data.Channel{}, + data.Persona{}, + data.ChannelIdentity{}, + "/new", + false, + "20002", + nil, + ChannelCommandResolver{ + IsBoundAdmin: func(context.Context) bool { return true }, + IsGroupAdmin: func(context.Context) bool { return false }, + }, + ChannelCommandDeps{}, + "QQ", + ) + if err != nil { + t.Fatal(err) + } + if !handled || replyText == "无权限。" { + t.Fatalf("expected owner identity to pass authorization, handled=%v reply=%q", handled, replyText) + } + }) +} + +func TestQQPrivateCommandDoesNotRequireArkloopOwner(t *testing.T) { + personaID := uuid.New() + handled, replyText, _, _, _, err := DispatchChannelCommand( + context.Background(), + nil, + data.Channel{PersonaID: &personaID}, + data.Persona{}, + data.ChannelIdentity{}, + "/new", + true, + "10001", + nil, + ChannelCommandResolver{ + IsBoundAdmin: func(context.Context) bool { return false }, + }, + ChannelCommandDeps{}, + "QQ", + ) + if err != nil { + t.Fatal(err) + } + if !handled || replyText == "无权限。" { + t.Fatalf("private QQ command should not require owner binding, handled=%v reply=%q", handled, replyText) + } +} + +func TestQQChannelIdentityIsOwnerRejectsNonOwner(t *testing.T) { + ownerUserID := uuid.New() + otherUserID := uuid.New() + identityID := uuid.New() + channelID := uuid.New() + + if qqChannelIdentityIsOwner(context.Background(), nil, data.Channel{ + ID: channelID, + OwnerUserID: &ownerUserID, + }, data.ChannelIdentity{ + ID: identityID, + UserID: &otherUserID, + }, nil) { + t.Fatal("non-owner identity must not be treated as QQ channel owner") + } +} + +func TestQQChannelIdentityIsOwnerRejectsUnownedChannel(t *testing.T) { + identityUserID := uuid.New() + if qqChannelIdentityIsOwner(context.Background(), nil, data.Channel{ + ID: uuid.New(), + }, data.ChannelIdentity{ + ID: uuid.New(), + UserID: &identityUserID, + }, nil) { + t.Fatal("unowned channel must not authorize QQ owner commands") + } +} + func TestQQUserAllowed(t *testing.T) { t.Run("allow all users when no allowlist", func(t *testing.T) { cfg := qqChannelConfig{AllowAllUsers: true} @@ -158,6 +273,72 @@ func TestQQUserAllowed(t *testing.T) { }) } +func TestQQAccessDeniedReply(t *testing.T) { + if qqAccessDeniedReplyText != "此用户不在白名单中。" { + t.Fatalf("unexpected reply text: %q", qqAccessDeniedReplyText) + } + + t.Run("private", func(t *testing.T) { + msgType, target := qqAccessDeniedReplyDestination("10001", "") + if msgType != "private" || target != "10001" { + t.Fatalf("unexpected destination: %s %s", msgType, target) + } + }) + + t.Run("group", func(t *testing.T) { + msgType, target := qqAccessDeniedReplyDestination("10001", "20002") + if msgType != "group" || target != "20002" { + t.Fatalf("unexpected destination: %s %s", msgType, target) + } + }) +} + +func TestQQAccessDeniedReplyTrigger(t *testing.T) { + tests := []struct { + name string + incoming qqIncomingMessage + want bool + }{ + { + name: "private replies", + incoming: qqIncomingMessage{ChatType: "private", Text: "hello"}, + want: true, + }, + { + name: "group ordinary message is silent", + incoming: qqIncomingMessage{ChatType: "group", Text: "hello"}, + want: false, + }, + { + name: "group mention replies", + incoming: qqIncomingMessage{ChatType: "group", Text: "hello", MentionsBot: true}, + want: true, + }, + { + name: "group keyword replies", + incoming: qqIncomingMessage{ChatType: "group", Text: "草洛", MatchesKeyword: true}, + want: true, + }, + { + name: "group command replies", + incoming: qqIncomingMessage{ChatType: "group", Text: "/new"}, + want: true, + }, + { + name: "unknown slash is silent", + incoming: qqIncomingMessage{ChatType: "group", Text: "/unknown"}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := qqShouldReplyAccessDenied(tt.incoming); got != tt.want { + t.Fatalf("qqShouldReplyAccessDenied() = %v, want %v", got, tt.want) + } + }) + } +} + func TestResolveQQChannelConfig(t *testing.T) { t.Run("nil config allows all", func(t *testing.T) { cfg, err := resolveQQChannelConfig(nil) @@ -192,6 +373,39 @@ func TestResolveQQChannelConfig(t *testing.T) { } }) + t.Run("allow all follows normalized allowlists", func(t *testing.T) { + cfg, err := resolveQQChannelConfig([]byte(`{"allow_all_users":true,"allowed_user_ids":[" 123 ","123","456\n789"],"allowed_group_ids":[" 20001 "]}`)) + if err != nil { + t.Fatal(err) + } + if cfg.AllowAllUsers { + t.Fatal("expected AllowAllUsers=false when allowlist present") + } + if got, want := strings.Join(cfg.AllowedUserIDs, ","), "123,456,789"; got != want { + t.Fatalf("AllowedUserIDs = %q, want %q", got, want) + } + if got, want := strings.Join(cfg.AllowedGroupIDs, ","), "20001"; got != want { + t.Fatalf("AllowedGroupIDs = %q, want %q", got, want) + } + }) + + t.Run("normalizes persisted config", func(t *testing.T) { + normalized, _, err := normalizeChannelConfigJSON("qq", []byte(`{"allow_all_users":true,"allowed_user_ids":["123"]}`)) + if err != nil { + t.Fatal(err) + } + var cfg qqChannelConfig + if err := json.Unmarshal(normalized, &cfg); err != nil { + t.Fatal(err) + } + if cfg.AllowAllUsers { + t.Fatal("expected stale allow_all_users to be cleared") + } + if len(cfg.AllowedUserIDs) != 1 || cfg.AllowedUserIDs[0] != "123" { + t.Fatalf("unexpected AllowedUserIDs: %v", cfg.AllowedUserIDs) + } + }) + t.Run("invalid json", func(t *testing.T) { _, err := resolveQQChannelConfig([]byte(`{invalid}`)) if err == nil { diff --git a/src/services/api/internal/http/accountapi/channels_qq_ws.go b/src/services/api/internal/http/accountapi/channels_qq_ws.go index 09333293e..71147d8a4 100644 --- a/src/services/api/internal/http/accountapi/channels_qq_ws.go +++ b/src/services/api/internal/http/accountapi/channels_qq_ws.go @@ -12,7 +12,6 @@ import ( "arkloop/services/api/internal/data" "arkloop/services/api/internal/observability" "arkloop/services/shared/eventbus" - "arkloop/services/shared/napcat" "arkloop/services/shared/onebotclient" "arkloop/services/shared/pgnotify" @@ -97,7 +96,11 @@ func qqWSListenerLoop(ctx context.Context, channelsRepo *data.ChannelsRepository // NapCat 管理的 channel:检测 QQ 掉线后拆除 listener 以触发重登 mgr := getNapCatManagerIfExists() - if mgr != nil && !mgr.IsLoggedIn() { + mgrLoggedIn := false + if mgr != nil { + mgrLoggedIn = mgr.IsLoggedIn() + } + if mgr != nil && !mgrLoggedIn { activeListeners.Range(func(key, value any) bool { id := key.(uuid.UUID) if l, ok := value.(*onebotclient.WSListener); ok { @@ -117,21 +120,22 @@ func qqWSListenerLoop(ctx context.Context, channelsRepo *data.ChannelsRepository if err != nil { continue } - wsURL := strings.TrimSpace(cfg.OneBotWSURL) - if wsURL == "" { + + if mgr != nil && !mgrLoggedIn { + qqAutoQuickLogin(mgr, cfg, &lastAutoLoginAttempt) continue } - if _, exists := activeListeners.Load(ch.ID); exists { + + wsURL, token := resolveQQWSListenerEndpoint(cfg, mgr) + if wsURL == "" { continue } - if mgr != nil && !mgr.IsLoggedIn() { - qqAutoQuickLogin(mgr, cfg, &lastAutoLoginAttempt) + if _, exists := activeListeners.Load(ch.ID); exists { continue } chCopy := ch - token := cfg.OneBotToken if token == "" { if mgr != nil { _, token = mgr.WSEndpoint() @@ -184,9 +188,35 @@ func qqWSListenerLoop(ctx context.Context, channelsRepo *data.ChannelsRepository } } +type qqOneBotEndpointProvider interface { + WSEndpoint() (addr string, token string) +} + +func resolveQQWSListenerEndpoint(cfg qqChannelConfig, mgr qqOneBotEndpointProvider) (string, string) { + wsURL := strings.TrimSpace(cfg.OneBotWSURL) + token := strings.TrimSpace(cfg.OneBotToken) + if wsURL != "" || mgr == nil { + return wsURL, token + } + endpoint, endpointToken := mgr.WSEndpoint() + wsURL = strings.TrimSpace(endpoint) + if token == "" { + token = strings.TrimSpace(endpointToken) + } + return wsURL, token +} + +type qqQuickLoginManager interface { + QuickLoginUins() []string + QuickLogin(uin string) error +} + // qqAutoQuickLogin 在 NapCat 运行但未登录时,自动使用 auto_login_uin 快速登录。 // 30 秒 cooldown 防止频繁重试。 -func qqAutoQuickLogin(mgr *napcat.Manager, cfg qqChannelConfig, lastAttempt *time.Time) { +func qqAutoQuickLogin(mgr qqQuickLoginManager, cfg qqChannelConfig, lastAttempt *time.Time) { + if mgr == nil { + return + } uin := strings.TrimSpace(cfg.AutoLoginUin) if uin == "" { return diff --git a/src/services/api/internal/http/accountapi/channels_qq_ws_test.go b/src/services/api/internal/http/accountapi/channels_qq_ws_test.go new file mode 100644 index 000000000..407f4b539 --- /dev/null +++ b/src/services/api/internal/http/accountapi/channels_qq_ws_test.go @@ -0,0 +1,88 @@ +//go:build desktop + +package accountapi + +import ( + "errors" + "testing" + "time" +) + +type fakeQQOneBotRuntime struct { + wsURL string + token string + uins []string + loginCalls []string + loginErr error +} + +func (f *fakeQQOneBotRuntime) WSEndpoint() (string, string) { + return f.wsURL, f.token +} + +func (f *fakeQQOneBotRuntime) QuickLoginUins() []string { + return append([]string(nil), f.uins...) +} + +func (f *fakeQQOneBotRuntime) QuickLogin(uin string) error { + f.loginCalls = append(f.loginCalls, uin) + return f.loginErr +} + +func TestResolveQQWSListenerEndpointUsesManagedEndpoint(t *testing.T) { + runtime := &fakeQQOneBotRuntime{wsURL: " ws://127.0.0.1:6098 ", token: " token "} + + wsURL, token := resolveQQWSListenerEndpoint(qqChannelConfig{}, runtime) + + if wsURL != "ws://127.0.0.1:6098" || token != "token" { + t.Fatalf("unexpected endpoint: ws=%q token=%q", wsURL, token) + } +} + +func TestResolveQQWSListenerEndpointKeepsExplicitConfig(t *testing.T) { + runtime := &fakeQQOneBotRuntime{wsURL: "ws://127.0.0.1:6098", token: "runtime-token"} + cfg := qqChannelConfig{OneBotWSURL: " ws://10.0.0.2:6098 ", OneBotToken: " configured-token "} + + wsURL, token := resolveQQWSListenerEndpoint(cfg, runtime) + + if wsURL != "ws://10.0.0.2:6098" || token != "configured-token" { + t.Fatalf("unexpected endpoint: ws=%q token=%q", wsURL, token) + } +} + +func TestQQAutoQuickLoginAttemptsConfiguredAvailableUin(t *testing.T) { + runtime := &fakeQQOneBotRuntime{uins: []string{"10001", "10002"}} + lastAttempt := time.Now().Add(-time.Minute) + + qqAutoQuickLogin(runtime, qqChannelConfig{AutoLoginUin: "10002"}, &lastAttempt) + + if len(runtime.loginCalls) != 1 || runtime.loginCalls[0] != "10002" { + t.Fatalf("unexpected quick login calls: %#v", runtime.loginCalls) + } +} + +func TestQQAutoQuickLoginDoesNotCooldownUnavailableUin(t *testing.T) { + runtime := &fakeQQOneBotRuntime{uins: []string{"10001"}} + var lastAttempt time.Time + + qqAutoQuickLogin(runtime, qqChannelConfig{AutoLoginUin: "10002"}, &lastAttempt) + + if len(runtime.loginCalls) != 0 { + t.Fatalf("unexpected quick login calls: %#v", runtime.loginCalls) + } + if !lastAttempt.IsZero() { + t.Fatalf("lastAttempt should stay zero, got %s", lastAttempt) + } +} + +func TestQQAutoQuickLoginCooldownAfterAttempt(t *testing.T) { + runtime := &fakeQQOneBotRuntime{uins: []string{"10001"}, loginErr: errors.New("failed")} + lastAttempt := time.Now().Add(-time.Minute) + + qqAutoQuickLogin(runtime, qqChannelConfig{AutoLoginUin: "10001"}, &lastAttempt) + qqAutoQuickLogin(runtime, qqChannelConfig{AutoLoginUin: "10001"}, &lastAttempt) + + if len(runtime.loginCalls) != 1 { + t.Fatalf("expected one login call during cooldown, got %#v", runtime.loginCalls) + } +} diff --git a/src/services/api/internal/http/accountapi/channels_telegram.go b/src/services/api/internal/http/accountapi/channels_telegram.go index b6d5287a2..d35c4266f 100644 --- a/src/services/api/internal/http/accountapi/channels_telegram.go +++ b/src/services/api/internal/http/accountapi/channels_telegram.go @@ -198,6 +198,10 @@ func normalizeChannelConfigJSON(channelType string, raw json.RawMessage) (json.R normalized, _, err := normalizeQQBotChannelConfig(raw) return normalized, nil, err } + if channelType == "qq" { + normalized, _, err := normalizeQQChannelConfig(raw) + return normalized, nil, err + } if channelType == "feishu" { normalized, _, err := normalizeFeishuChannelConfig(raw) return normalized, nil, err