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
7 changes: 7 additions & 0 deletions changelog/unreleased/issue-249-disabled-account-checkin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Add a system setting to let disabled accounts continue their opted-in automatic daily check-in without making them eligible for chat routing.

### 中文

- 新增系统设置,允许停用账号继续执行已开启的自动签到,同时不会让它们重新参与聊天调度。
3 changes: 2 additions & 1 deletion frontend/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type SystemUpdateInfo = {

export type SystemSettings = {
cross_provider_model_pool: boolean
checkin_disabled_accounts: boolean
proxy_url: string
routing_strategy: 'round-robin' | 'weighted-round-robin' | 'fill-first'
workbuddy_checkin_time: string
Expand Down Expand Up @@ -98,7 +99,7 @@ export function fetchSystemSettings() {
return api<SystemSettings>('/api/system/settings')
}

export function updateSystemSettings(input: { cross_provider_model_pool?: boolean; routing_strategy?: SystemSettings['routing_strategy']; proxy_url?: string; workbuddy_checkin_time?: string; checkin_times?: Record<string, string> }) {
export function updateSystemSettings(input: { cross_provider_model_pool?: boolean; checkin_disabled_accounts?: boolean; routing_strategy?: SystemSettings['routing_strategy']; proxy_url?: string; workbuddy_checkin_time?: string; checkin_times?: Record<string, string> }) {
return api<SystemSettings>('/api/system/settings', {
method: 'PATCH',
body: JSON.stringify(input),
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export const messages: Record<Lang, Dict> = {
navAccounts: 'Accounts',
checkinDefaultsTitle: 'Automatic check-in',
checkinDefaultsHint: 'Set a default time for each provider. Accounts following the default pick up changes automatically; custom times stay unchanged.',
checkinDisabledAccountsTitle: 'Check in disabled accounts',
checkinDisabledAccountsHint: 'When enabled, accounts disabled for chat can still run their opted-in automatic check-in. Manual check-in remains unavailable for disabled accounts.',
checkinDisabledAccountsStatus: 'Current status',
checkinDisabledAccountsAriaLabel: 'Allow automatic check-in for disabled accounts',
checkinInherit: 'Follow provider default',
checkinDefaultValue: 'Provider default: {time}',
checkinCustom: 'Account override',
Expand Down Expand Up @@ -686,6 +690,10 @@ export const messages: Record<Lang, Dict> = {
navAccounts: '账号',
checkinDefaultsTitle: '自动签到',
checkinDefaultsHint: '按供应商设置默认时间。跟随默认的账号会立即采用新时间,账号自定义时间不受影响。',
checkinDisabledAccountsTitle: '停用账号也自动签到',
checkinDisabledAccountsHint: '开启后,停用聊天功能的账号仍可执行已开启的自动签到;停用账号仍不可手动签到。',
checkinDisabledAccountsStatus: '当前状态',
checkinDisabledAccountsAriaLabel: '允许停用账号自动签到',
checkinInherit: '跟随供应商默认',
checkinDefaultValue: '供应商默认:{time}',
checkinCustom: '账号自定义',
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,21 @@ export function SystemPage() {
}
}

async function updateCheckinDisabledAccounts(enabled: boolean) {
const previous = settings?.checkin_disabled_accounts ?? false
setSettings((current) => current ? { ...current, checkin_disabled_accounts: enabled } : current)
setSettingsBusy(true)
setError('')
try {
setSettings(await updateSystemSettings({ checkin_disabled_accounts: enabled }))
} catch (err) {
setSettings((current) => current ? { ...current, checkin_disabled_accounts: previous } : current)
setError(err instanceof Error ? err.message : String(err))
} finally {
setSettingsBusy(false)
}
}

async function updateProxyURL(value: string) {
const saved = settings?.proxy_url || ''
// Nothing changed in the field: keep the draft as-is and skip the PATCH so
Expand Down Expand Up @@ -395,6 +410,28 @@ export function SystemPage() {

<CheckinDefaults settings={settings} onSaved={setSettings} />

<Card data-gsap-reveal>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-secondary text-foreground"><CheckCircle size={15} /></div>
<div>
<h3 className="font-semibold">{t('checkinDisabledAccountsTitle')}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t('checkinDisabledAccountsHint')}</p>
</div>
</div>
<CompactSwitch
isSelected={settings?.checkin_disabled_accounts ?? false}
isDisabled={settingsBusy || !settings}
ariaLabel={t('checkinDisabledAccountsAriaLabel')}
onChange={(selected) => void updateCheckinDisabledAccounts(selected)}
/>
</div>
<div className="mt-4 flex items-center justify-between border-t border-separator pt-3 text-xs text-muted">
<span>{t('checkinDisabledAccountsStatus')}</span>
<span className="font-medium text-foreground">{settings?.checkin_disabled_accounts ? t('enabled') : t('disabled')}</span>
</div>
</Card>

<Card data-gsap-reveal>
<div className="flex items-start gap-3">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-secondary text-foreground"><SlidersHorizontal size={15} /></div>
Expand Down
5 changes: 4 additions & 1 deletion internal/accounts/checkin.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import (
"github.com/caigee-cmd/cli2api/internal/providers"
)

const DefaultCheckinTime = "09:00"
const (
DefaultCheckinTime = "09:00"
CheckinDisabledAccountsSecret = "checkin_disabled_accounts"
)

type CheckinSettingsReader interface {
GetSecret(context.Context, string) (string, bool, error)
Expand Down
4 changes: 4 additions & 0 deletions internal/api/system_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ func ensureCrossProviderModelPool(ctx context.Context, store app.SecretStore) (b
func ensureWorkBuddyCheckinTime(ctx context.Context, store app.SecretStore) (string, error) {
return app.EnsureWorkBuddyCheckinTime(ctx, store)
}

func ensureCheckinDisabledAccounts(ctx context.Context, store app.SecretStore) (bool, error) {
return app.EnsureCheckinDisabledAccounts(ctx, store)
}
45 changes: 45 additions & 0 deletions internal/api/system_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ func TestEnsureWorkBuddyCheckinTimeDefaultsToNine(t *testing.T) {
}
}

func TestEnsureCheckinDisabledAccountsDefaultsToFalse(t *testing.T) {
store, err := sqlstore.OpenStore(filepath.Join(t.TempDir(), "qoder.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()

value, err := ensureCheckinDisabledAccounts(context.Background(), store)
if err != nil || value {
t.Fatalf("value=%v err=%v", value, err)
}
stored, ok, err := store.GetSecret(context.Background(), accounts.CheckinDisabledAccountsSecret)
if err != nil || !ok || stored != "0" {
t.Fatalf("stored=%q ok=%v err=%v", stored, ok, err)
}
}

func TestSystemSettingsRoutePersistsWorkBuddyCheckinTime(t *testing.T) {
srv := New(config.Config{
Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret",
Expand Down Expand Up @@ -131,6 +148,34 @@ func TestSystemSettingsRoutePersistsWorkBuddyCheckinTime(t *testing.T) {
}
}

func TestSystemSettingsRoutePersistsDisabledAccountCheckin(t *testing.T) {
srv := New(config.Config{
Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret",
QoderHome: t.TempDir(), DataDir: t.TempDir(),
})
defer srv.Close()

request := httptest.NewRequest(http.MethodGet, "/api/system/settings", nil)
request.Header.Set("Authorization", "Bearer secret")
response := httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte(`"checkin_disabled_accounts":false`)) {
t.Fatalf("default settings: %d %s", response.Code, response.Body.String())
}

request = httptest.NewRequest(http.MethodPatch, "/api/system/settings", bytes.NewBufferString(`{"checkin_disabled_accounts":true}`))
request.Header.Set("Authorization", "Bearer secret")
response = httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte(`"checkin_disabled_accounts":true`)) {
t.Fatalf("updated settings: %d %s", response.Code, response.Body.String())
}
stored, ok, err := srv.Manager.Store().GetSecret(context.Background(), accounts.CheckinDisabledAccountsSecret)
if err != nil || !ok || stored != "1" {
t.Fatalf("persisted=%q ok=%v err=%v", stored, ok, err)
}
}

func TestSystemSettingsRejectsInvalidWorkBuddyCheckinTime(t *testing.T) {
srv := New(config.Config{
Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret",
Expand Down
3 changes: 3 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ func New(cfg config.Config) *App {
if _, err := EnsureWorkBuddyCheckinTime(context.Background(), store); err != nil {
panic(err)
}
if _, err := EnsureCheckinDisabledAccounts(context.Background(), store); err != nil {
panic(err)
}
cfg.ProxyAPIKey = proxyAPIKey
runtimeDir := cfg.RuntimeDir
if runtimeDir == "" {
Expand Down
1 change: 1 addition & 0 deletions internal/app/system_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ var EnsureProxyURL = control.EnsureProxyURL
var EnsureCrossProviderModelPool = control.EnsureCrossProviderModelPool
var EnsureRoutingStrategy = control.EnsureRoutingStrategy
var EnsureWorkBuddyCheckinTime = control.EnsureWorkBuddyCheckinTime
var EnsureCheckinDisabledAccounts = control.EnsureCheckinDisabledAccounts
20 changes: 20 additions & 0 deletions internal/control/duty_services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,23 @@ func TestSystemPatchPersistsAndAppliesRuntime(t *testing.T) {
t.Fatalf("current=%+v", current)
}
}

func TestSystemPatchPersistsDisabledAccountCheckinSetting(t *testing.T) {
svc, _, store, _ := newTestServices()
pool := executor.NewPool(nil, nil)
var cross atomic.Bool
sys := &System{
Settings: svc.Settings, Accounts: svc.Accounts, Pool: pool,
CrossProviderPool: &cross, Mu: &sync.Mutex{},
}
enabled := true
if err := sys.Patch(context.Background(), SystemSettingsPatch{CheckinDisabledAccounts: &enabled}); err != nil {
t.Fatal(err)
}
if store.secrets[checkinDisabledAccountsSecret] != "1" {
t.Fatalf("stored setting=%q", store.secrets[checkinDisabledAccountsSecret])
}
if !sys.Current(context.Background()).CheckinDisabledAccounts {
t.Fatal("current setting is disabled")
}
}
26 changes: 23 additions & 3 deletions internal/control/settings_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import (
)

const (
crossProviderModelPoolSecret = "cross_provider_model_pool"
routingStrategySecret = "routing_strategy"
proxyURLSecret = "proxy_url"
crossProviderModelPoolSecret = "cross_provider_model_pool"
routingStrategySecret = "routing_strategy"
proxyURLSecret = "proxy_url"
checkinDisabledAccountsSecret = accounts.CheckinDisabledAccountsSecret
)

func EnsureProxyURL(ctx context.Context, store SecretStore, bootstrap string) (string, error) {
Expand Down Expand Up @@ -95,6 +96,25 @@ func EnsureWorkBuddyCheckinTime(ctx context.Context, store SecretStore) (string,
return normalized, nil
}

func EnsureCheckinDisabledAccounts(ctx context.Context, store SecretStore) (bool, error) {
value, ok, err := store.GetSecret(ctx, checkinDisabledAccountsSecret)
if err != nil {
return false, err
}
if !ok || strings.TrimSpace(value) == "" {
if err := store.SetSecret(ctx, checkinDisabledAccountsSecret, "0"); err != nil {
return false, fmt.Errorf("initialize check-in settings: %w", err)
}
return false, nil
}

enabled, err := parseSettingBool(value)
if err != nil {
return false, fmt.Errorf("invalid %s setting: %w", checkinDisabledAccountsSecret, err)
}
return enabled, nil
}

func parseSettingBool(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "on", "yes":
Expand Down
53 changes: 35 additions & 18 deletions internal/control/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,43 @@ type System struct {
Mu *sync.Mutex
}
type SystemSettingsPatch struct {
CrossProviderModelPool *bool `json:"cross_provider_model_pool"`
RoutingStrategy *string `json:"routing_strategy"`
ProxyURL *string `json:"proxy_url"`
WorkBuddyCheckinTime *string `json:"workbuddy_checkin_time"`
CheckinTimes map[string]string `json:"checkin_times"`
CrossProviderModelPool *bool `json:"cross_provider_model_pool"`
CheckinDisabledAccounts *bool `json:"checkin_disabled_accounts"`
RoutingStrategy *string `json:"routing_strategy"`
ProxyURL *string `json:"proxy_url"`
WorkBuddyCheckinTime *string `json:"workbuddy_checkin_time"`
CheckinTimes map[string]string `json:"checkin_times"`
}
type SystemSettings struct {
CrossProviderModelPool bool `json:"cross_provider_model_pool"`
RoutingStrategy string `json:"routing_strategy"`
ProxyURL string `json:"proxy_url"`
WorkBuddyCheckinTime string `json:"workbuddy_checkin_time"`
CheckinTimes map[string]string `json:"checkin_times"`
Timezone string `json:"timezone"`
SessionAffinity executor.SessionAffinityStats `json:"session_affinity"`
CrossProviderModelPool bool `json:"cross_provider_model_pool"`
CheckinDisabledAccounts bool `json:"checkin_disabled_accounts"`
RoutingStrategy string `json:"routing_strategy"`
ProxyURL string `json:"proxy_url"`
WorkBuddyCheckinTime string `json:"workbuddy_checkin_time"`
CheckinTimes map[string]string `json:"checkin_times"`
Timezone string `json:"timezone"`
SessionAffinity executor.SessionAffinityStats `json:"session_affinity"`
}

func (h *System) Current(ctx context.Context) SystemSettings {
var proxyURL string
checkin := ""
checkinDisabledAccounts := false
if h.Settings != nil {
proxyURL, _, _ = h.Settings.GetSecret(ctx, proxyURLSecret)
checkin = h.Settings.WorkBuddyCheckinTimeDefault(ctx)
value, ok, err := h.Settings.GetSecret(ctx, checkinDisabledAccountsSecret)
if ok && err == nil {
checkinDisabledAccounts, _ = parseSettingBool(value)
}
}
settings := SystemSettings{
CrossProviderModelPool: h.CrossProviderPool.Load(),
ProxyURL: proxy.Redact(proxyURL),
WorkBuddyCheckinTime: checkin,
CheckinTimes: map[string]string{},
Timezone: time.Now().Format("MST -07:00"),
CrossProviderModelPool: h.CrossProviderPool.Load(),
CheckinDisabledAccounts: checkinDisabledAccounts,
ProxyURL: proxy.Redact(proxyURL),
WorkBuddyCheckinTime: checkin,
CheckinTimes: map[string]string{},
Timezone: time.Now().Format("MST -07:00"),
}
for _, descriptor := range providers.List() {
if descriptor.SupportsCheckin() && h.Settings != nil {
Expand All @@ -66,7 +74,7 @@ func (h *System) Current(ctx context.Context) SystemSettings {
}

func (h *System) Patch(ctx context.Context, input SystemSettingsPatch) error {
if input.CrossProviderModelPool == nil && input.RoutingStrategy == nil && input.ProxyURL == nil && input.WorkBuddyCheckinTime == nil && len(input.CheckinTimes) == 0 {
if input.CrossProviderModelPool == nil && input.CheckinDisabledAccounts == nil && input.RoutingStrategy == nil && input.ProxyURL == nil && input.WorkBuddyCheckinTime == nil && len(input.CheckinTimes) == 0 {
return operationError("invalid_request", "a system setting is required")
}
var strategy string
Expand Down Expand Up @@ -150,6 +158,15 @@ func (h *System) Patch(ctx context.Context, input SystemSettingsPatch) error {
}
h.CrossProviderPool.Store(enabled)
}
if input.CheckinDisabledAccounts != nil {
value := "0"
if *input.CheckinDisabledAccounts {
value = "1"
}
if err := h.Settings.SetSecret(ctx, checkinDisabledAccountsSecret, value); err != nil {
return operationError("system_settings_save_failed", err.Error())
}
}
if input.RoutingStrategy != nil {
if err := h.Settings.SetSecret(ctx, routingStrategySecret, strategy); err != nil {
return operationError("system_settings_save_failed", err.Error())
Expand Down
25 changes: 25 additions & 0 deletions internal/runtime/checkin_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,28 @@ func TestCheckinRejectsDisabledAndUnsupportedAccounts(t *testing.T) {
t.Fatalf("err=%v", err)
}
}

func TestScheduledCheckinCanIncludeDisabledAccountsWhenEnabled(t *testing.T) {
var calls atomic.Int64
manager, account := newCheckinManager(t, checkinFunc(func(context.Context, string) (providers.CheckinResult, error) {
calls.Add(1)
return providers.CheckinResult{Status: "success", Message: "claimed"}, nil
}))
disabled := false
if err := manager.store.Update(context.Background(), account.ID, accounts.UpdateAccount{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
if err := manager.store.SetSecret(context.Background(), accounts.CheckinDisabledAccountsSecret, "1"); err != nil {
t.Fatal(err)
}

location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
now := time.Now().In(location)
manager.runScheduledCheckins(context.Background(), time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 0, 0, location))
if calls.Load() != 1 {
t.Fatalf("scheduled check-in calls=%d", calls.Load())
}
}
Loading
Loading