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(
+
关联。")
- 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