diff --git a/cmd/anna/gateway.go b/cmd/anna/gateway.go index e5c0e43c..815106d6 100644 --- a/cmd/anna/gateway.go +++ b/cmd/anna/gateway.go @@ -119,11 +119,9 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc slog.Info("starting telegram bot") tgBot, err := telegram.New(telegram.Config{ - Token: tgCfg.Token, - NotifyChat: tgCfg.NotifyChat, - ChannelID: tgCfg.ChannelID, - GroupMode: tgCfg.GroupMode, - AllowedIDs: tgCfg.AllowedIDs, + Token: tgCfg.Token, + ChannelID: tgCfg.ChannelID, + GroupMode: tgCfg.GroupMode, }, s.poolManager, s.store, listFn, switchFn, telegram.WithAuth(as, engine, linkCodes), ) @@ -131,13 +129,9 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc return fmt.Errorf("create telegram bot: %w", err) } - defaultChat := tgCfg.NotifyChat - if defaultChat == "" { - defaultChat = tgCfg.ChannelID - } channels = append(channels, tgBot) if tgCfg.EnableNotify { - s.notifier.Register(tgBot, defaultChat) + s.notifier.Register(tgBot) } } @@ -146,10 +140,9 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc slog.Info("starting qq bot") qqBot, err := qq.New(qq.Config{ - AppID: qqCfg.AppID, - AppSecret: qqCfg.AppSecret, - GroupMode: qqCfg.GroupMode, - AllowedIDs: qqCfg.AllowedIDs, + AppID: qqCfg.AppID, + AppSecret: qqCfg.AppSecret, + GroupMode: qqCfg.GroupMode, }, s.poolManager, s.store, listFn, switchFn, qq.WithAuth(as, engine, linkCodes), ) @@ -159,7 +152,7 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc channels = append(channels, qqBot) if qqCfg.EnableNotify { - s.notifier.Register(qqBot, "") + s.notifier.Register(qqBot) } } @@ -179,9 +172,7 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc AppSecret: fsCfg.AppSecret, EncryptKey: fsCfg.EncryptKey, VerificationToken: fsCfg.VerificationToken, - NotifyChat: fsCfg.NotifyChat, GroupMode: fsCfg.GroupMode, - AllowedIDs: fsCfg.AllowedIDs, Groups: fsCfg.Groups, RedirectURI: fsCfg.RedirectURI, }, s.poolManager, s.store, listFn, switchFn, @@ -193,7 +184,7 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc channels = append(channels, fsBot) if fsCfg.EnableNotify { - s.notifier.Register(fsBot, fsCfg.NotifyChat) + s.notifier.Register(fsBot) } } @@ -202,12 +193,10 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc slog.Info("starting weixin bot") wxBot, err := weixin.New(weixin.Config{ - BotToken: wxCfg.BotToken, - BaseURL: wxCfg.BaseURL, - BotID: wxCfg.BotID, - UserID: wxCfg.UserID, - NotifyChat: wxCfg.NotifyChat, - AllowedIDs: wxCfg.AllowedIDs, + BotToken: wxCfg.BotToken, + BaseURL: wxCfg.BaseURL, + BotID: wxCfg.BotID, + UserID: wxCfg.UserID, }, s.poolManager, s.store, listFn, switchFn, weixin.WithAuth(as, engine, linkCodes), ) @@ -217,7 +206,7 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc channels = append(channels, wxBot) if wxCfg.EnableNotify { - s.notifier.Register(wxBot, wxCfg.NotifyChat) + s.notifier.Register(wxBot) } } @@ -338,20 +327,17 @@ func launchBrowser(url string) { // --- Channel config types for JSON deserialization --- type telegramChannelConfig struct { - Token string `json:"token"` - NotifyChat string `json:"notify_chat"` - ChannelID string `json:"channel_id"` - GroupMode string `json:"group_mode"` - AllowedIDs []int64 `json:"allowed_ids"` - EnableNotify bool `json:"enable_notify"` + Token string `json:"token"` + ChannelID string `json:"channel_id"` + GroupMode string `json:"group_mode"` + EnableNotify bool `json:"enable_notify"` } type qqChannelConfig struct { - AppID string `json:"app_id"` - AppSecret string `json:"app_secret"` - GroupMode string `json:"group_mode"` - AllowedIDs []string `json:"allowed_ids"` - EnableNotify bool `json:"enable_notify"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + GroupMode string `json:"group_mode"` + EnableNotify bool `json:"enable_notify"` } type feishuChannelConfig struct { @@ -359,22 +345,18 @@ type feishuChannelConfig struct { AppSecret string `json:"app_secret"` EncryptKey string `json:"encrypt_key"` VerificationToken string `json:"verification_token"` - NotifyChat string `json:"notify_chat"` GroupMode string `json:"group_mode"` - AllowedIDs []string `json:"allowed_ids"` Groups map[string]feishu.GroupConfig `json:"groups"` RedirectURI string `json:"redirect_uri"` EnableNotify bool `json:"enable_notify"` } type weixinChannelConfig struct { - BotToken string `json:"bot_token"` - BaseURL string `json:"base_url"` - BotID string `json:"bot_id"` - UserID string `json:"user_id"` - NotifyChat string `json:"notify_chat"` - AllowedIDs []string `json:"allowed_ids"` - EnableNotify bool `json:"enable_notify"` + BotToken string `json:"bot_token"` + BaseURL string `json:"base_url"` + BotID string `json:"bot_id"` + UserID string `json:"user_id"` + EnableNotify bool `json:"enable_notify"` } // loadChannelConfig loads a channel's JSON config from the store and diff --git a/docs/content/docs/features/notification-system.ja.md b/docs/content/docs/features/notification-system.ja.md index 07b630b6..ef0bac1e 100644 --- a/docs/content/docs/features/notification-system.ja.md +++ b/docs/content/docs/features/notification-system.ja.md @@ -73,8 +73,8 @@ type Channel interface { ```go d := channel.NewDispatcher() -d.Register(tgBot, "136345060") // デフォルトチャット付きtelegramチャネル -d.Register(qqBot, "") // qqチャネル +d.Register(tgBot) // telegramチャネル +d.Register(qqBot) // qqチャネル // すべてのチャネルにブロードキャスト(各チャネルはデフォルトチャットを使用): d.Notify(ctx, channel.Notification{Text: "hello"}) @@ -125,7 +125,7 @@ setup() runGateway() +-- Create telegram.Bot - +-- dispatcher.Register(tgBot, notifyChat) <- チャネル登録 + +-- dispatcher.Register(tgBot) <- チャネル登録 +-- wireSchedulerNotifier(schedulerSvc, poolManager, dispatcher) <- スケジューラー出力 -> ディスパッチャー +-- tgBot.Start(ctx) <- ポーリング開始 ``` @@ -146,15 +146,15 @@ CLIモード(`anna chat`)では、通知チャネルは登録されないた ## 設定 -チャネル設定は管理パネルで管理されます。各チャネルの設定(トークン、チャットID、グループモード、許可されたID)は、データベースにJSONとして保存されます。設定ファイルを直接編集するのではなく、管理パネルUIから通知チャネルを設定してください。 +チャネル設定は管理パネルで管理されます。各チャネルの設定(トークン、チャットID、グループモード)は、データベースにJSONとして保存されます。設定ファイルを直接編集するのではなく、管理パネルUIから通知チャネルを設定してください。 ### 通知ターゲット解決 `Notify()`が呼び出されると、ターゲットチャットは次の順序で解決されます。 1. `Notification.ChatID`(呼び出しで明示的) -2. チャネルの登録されたデフォルトチャット(`dispatcher.Register`から) -3. Telegramの場合: `notify_chat` -> `channel_id` -> エラー +2. チャネルのデフォルトターゲット(チャネル設定から) +3. Telegramの場合: `channel_id` -> エラー ## 新しいチャネルの追加 @@ -182,7 +182,7 @@ func (b *Bot) Notify(ctx context.Context, n channel.Notification) error { if slackCfg.Token != "" { slackBot := slack.New(slackCfg) channels = append(channels, slackBot) - s.notifier.Register(slackBot, slackCfg.NotifyChannel) + s.notifier.Register(slackBot) } ``` @@ -204,7 +204,7 @@ if slackCfg.Token != "" { ### アクセス制御 -`allowed_ids`は、ボットとのインタラクションを特定のユーザーID(Telegram数値ID、QQ OpenID、Feishu open_id、WeChat iLinkユーザーID)に制限します。リストが空の場合、すべてのユーザーが許可されます。権限のないユーザーは静かに無視されます。すべてのハンドラー(コマンド、コールバック、テキスト)はアクセスチェックでラップされます。ユーザーは自分のIDを確認するために`/whoami`をボットに送信できます。 +アクセス制御はRBACシステムで管理されます。ユーザーはプラットフォームID(Telegram数値ID、QQ OpenID、Feishu open_id、WeChat iLinkユーザーID)を通じて認証システムに自動的に関連付けられます。権限のないユーザーは静かに無視されます。すべてのハンドラー(コマンド、コールバック、テキスト)はアクセスチェックでラップされます。ユーザーは自分のIDを確認するために`/whoami`をボットに送信できます。 ### 通知配信 diff --git a/docs/content/docs/features/notification-system.md b/docs/content/docs/features/notification-system.md index 7da514ab..bad45b74 100644 --- a/docs/content/docs/features/notification-system.md +++ b/docs/content/docs/features/notification-system.md @@ -50,7 +50,7 @@ type Notification struct { - `Channel` empty -- broadcast to **all** registered channels - `Channel` set -- route to that specific channel only -- `ChatID` empty -- each channel uses its configured default +- `ChatID` empty -- resolved from auth identities via `NotifyUser`, or channel-specific fallback ### `channel.Channel` @@ -73,16 +73,16 @@ Routes notifications to registered channels: ```go d := channel.NewDispatcher() -d.Register(tgBot, "136345060") // telegram channel with default chat -d.Register(qqBot, "") // qq channel +d.Register(tgBot) // telegram channel +d.Register(qqBot) // qq channel -// Broadcast to all channels (each uses its default chat): +// Broadcast to all channels: d.Notify(ctx, channel.Notification{Text: "hello"}) // Route to specific channel: d.Notify(ctx, channel.Notification{Channel: "telegram", Text: "hello"}) -// Override the default chat: +// Specify target chat: d.Notify(ctx, channel.Notification{Channel: "telegram", ChatID: "999", Text: "hello"}) ``` @@ -125,7 +125,7 @@ setup() runGateway() +-- Create telegram.Bot - +-- dispatcher.Register(tgBot, notifyChat) <- channel registered + +-- dispatcher.Register(tgBot) <- channel registered +-- wireSchedulerNotifier(schedulerSvc, poolManager, dispatcher) <- scheduler output -> dispatcher +-- tgBot.Start(ctx) <- begin polling ``` @@ -150,11 +150,7 @@ Channel configuration is managed through the admin panel. Each channel's setting ### Notify Target Resolution -When `Notify()` is called, the target chat is resolved in this order: - -1. `Notification.ChatID` (explicit in the call) -2. Channel's registered default chat (from `dispatcher.Register`) -3. For Telegram: `notify_chat` -> `channel_id` -> error +For user-owned jobs, `NotifyUser()` resolves the target via `auth_identities` — each user's linked platform identity provides the chat ID. For system jobs, `Notify()` broadcasts to all registered channels using the explicit `ChatID` in the notification. ## Adding a New Channel @@ -182,7 +178,7 @@ Use `channel.NewCommander(pool, listFn, switchFn)` for shared `/new`, `/compact` if slackCfg.Token != "" { slackBot := slack.New(slackCfg) channels = append(channels, slackBot) - s.notifier.Register(slackBot, slackCfg.NotifyChannel) + s.notifier.Register(slackBot) } ``` @@ -204,7 +200,7 @@ Session ID for groups = group chat ID (shared context per group). ### Access Control -`allowed_ids` restricts bot interaction to specific user IDs (Telegram numeric IDs, QQ OpenIDs, Feishu open_ids, WeChat iLink user IDs). When the list is empty, all users are allowed. Unauthorized users are silently ignored -- all handlers (commands, callbacks, text) are wrapped in the access check. Users can send `/whoami` to the bot to discover their ID. +Access control is managed through the RBAC system. Users are authenticated via `auth_identities` when they send messages, and agent access is enforced by the policy engine. Use the admin panel to manage user roles and agent assignments. ### Notification Delivery diff --git a/docs/content/docs/features/notification-system.zh.md b/docs/content/docs/features/notification-system.zh.md index 8d46b6b0..77992050 100644 --- a/docs/content/docs/features/notification-system.zh.md +++ b/docs/content/docs/features/notification-system.zh.md @@ -73,8 +73,8 @@ type Channel interface { ```go d := channel.NewDispatcher() -d.Register(tgBot, "136345060") // telegram 通道,带默认聊天 -d.Register(qqBot, "") // qq 通道 +d.Register(tgBot) // telegram 通道 +d.Register(qqBot) // qq 通道 // 广播到所有通道(每个使用其默认聊天): d.Notify(ctx, channel.Notification{Text: "hello"}) @@ -125,7 +125,7 @@ setup() runGateway() +-- Create telegram.Bot - +-- dispatcher.Register(tgBot, notifyChat) <- 通道已注册 + +-- dispatcher.Register(tgBot) <- 通道已注册 +-- wireSchedulerNotifier(schedulerSvc, poolManager, dispatcher) <- 调度器输出 -> 分发器 +-- tgBot.Start(ctx) <- 开始轮询 ``` @@ -146,15 +146,15 @@ runGateway() ## 配置 -通道配置通过管理面板管理。每个通道的设置(令牌、聊天 ID、群组模式、允许的 ID)作为 JSON 存储在数据库中。从管理面板 UI 配置通知通道,而不是直接编辑配置文件。 +通道配置通过管理面板管理。每个通道的设置(令牌、聊天 ID、群组模式)作为 JSON 存储在数据库中。从管理面板 UI 配置通知通道,而不是直接编辑配置文件。 ### 通知目标解析 当调用 `Notify()` 时,目标聊天按以下顺序解析: 1. `Notification.ChatID`(调用中的显式值) -2. 通道注册的默认聊天(来自 `dispatcher.Register`) -3. 对于 Telegram: `notify_chat` -> `channel_id` -> 错误 +2. 通道的默认目标(来自通道配置) +3. 对于 Telegram: `channel_id` -> 错误 ## 添加新通道 @@ -182,7 +182,7 @@ func (b *Bot) Notify(ctx context.Context, n channel.Notification) error { if slackCfg.Token != "" { slackBot := slack.New(slackCfg) channels = append(channels, slackBot) - s.notifier.Register(slackBot, slackCfg.NotifyChannel) + s.notifier.Register(slackBot) } ``` @@ -204,7 +204,7 @@ if slackCfg.Token != "" { ### 访问控制 -`allowed_ids` 限制机器人交互到特定用户 ID(Telegram 数字 ID、QQ OpenID、Feishu open_id、微信 iLink 用户 ID)。当列表为空时,允许所有用户。未授权用户会被静默忽略——所有处理器(命令、回调、文本)都包装在访问检查中。用户可以向机器人发送 `/whoami` 来发现他们的 ID。 +访问控制通过 RBAC 系统管理。用户通过平台身份(Telegram 数字 ID、QQ OpenID、Feishu open_id、微信 iLink 用户 ID)自动关联到认证系统。未授权用户会被静默忽略——所有处理器(命令、回调、文本)都包装在访问检查中。用户可以向机器人发送 `/whoami` 来发现他们的 ID。 ### 通知传递 diff --git a/internal/admin/server.go b/internal/admin/server.go index fbb3c1ba..d5c682c1 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -123,6 +123,7 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine // User APIs (admin-only) — memory management and default agent. s.mux.Handle("PUT /api/users/{id}/default-agent", adminAPI(s.updateUserDefaultAgent)) + s.mux.Handle("PUT /api/users/{id}/notify-identity", adminAPI(s.updateUserNotifyIdentity)) s.mux.Handle("GET /api/users/{id}/memories", adminAPI(s.listUserMemories)) s.mux.Handle("PUT /api/users/{id}/memories/{agentId}", adminAPI(s.setUserMemory)) s.mux.Handle("DELETE /api/users/{id}/memories/{agentId}", adminAPI(s.deleteUserMemory)) diff --git a/internal/admin/ui/pages/channels.templ b/internal/admin/ui/pages/channels.templ index 93edd99e..68129b63 100644 --- a/internal/admin/ui/pages/channels.templ +++ b/internal/admin/ui/pages/channels.templ @@ -28,16 +28,6 @@ templ ChannelsPage() { }
-
- @ui.FormField("Notify Chat") { - - } -
@ui.FormField("Channel ID") { }
-
- @ui.FormField("Allowed IDs") { - - } -
} @@ -113,17 +92,6 @@ templ ChannelsPage() { } -
- @ui.FormField("Allowed IDs") { - - } -
} @@ -177,15 +145,6 @@ templ ChannelsPage() { /> } -
- @ui.FormField("Notify Chat") { - - } -
@ui.FormField("Group Mode") { - } -
} @@ -221,32 +169,6 @@ templ ChannelsPage() { /> Notifications -
-
- @ui.FormField("Notify Chat") { - -

- Requires cached context_token (repopulated when user sends a message after restart). -

- } -
-
- @ui.FormField("Allowed IDs") { - - } -
-

To connect, go to Profile and use "Link Weixin" to scan the QR code.

diff --git a/internal/admin/ui/pages/channels_templ.go b/internal/admin/ui/pages/channels_templ.go index 22900fd8..1205fa1b 100644 --- a/internal/admin/ui/pages/channels_templ.go +++ b/internal/admin/ui/pages/channels_templ.go @@ -97,13 +97,13 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Notify Chat").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("Channel ID").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -123,69 +123,17 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Channel ID").Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("Group Mode").Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = ui.FormField("Group Mode").Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = ui.FormField("Allowed IDs").Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -195,11 +143,11 @@ func ChannelsPage() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -211,37 +159,11 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var9 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = ui.FormField("App ID").Render(templ.WithChildren(ctx, templ_7745c5c3_Var9), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -253,21 +175,21 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("App Secret").Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("App ID").Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var11 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -279,21 +201,21 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Group Mode").Render(templ.WithChildren(ctx, templ_7745c5c3_Var11), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("App Secret").Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var12 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var9 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -305,31 +227,31 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Allowed IDs").Render(templ.WithChildren(ctx, templ_7745c5c3_Var12), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("Group Mode").Render(templ.WithChildren(ctx, templ_7745c5c3_Var9), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = channelBlock("QQ", "qq").Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = channelBlock("QQ", "qq").Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -341,63 +263,11 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var14 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = ui.FormField("App ID").Render(templ.WithChildren(ctx, templ_7745c5c3_Var14), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var15 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = ui.FormField("App Secret").Render(templ.WithChildren(ctx, templ_7745c5c3_Var15), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var16 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var11 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -409,21 +279,21 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Encrypt Key").Render(templ.WithChildren(ctx, templ_7745c5c3_Var16), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("App ID").Render(templ.WithChildren(ctx, templ_7745c5c3_Var11), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var17 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var12 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -435,21 +305,21 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Verification Token").Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("App Secret").Render(templ.WithChildren(ctx, templ_7745c5c3_Var12), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var18 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -461,21 +331,21 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Notify Chat").Render(templ.WithChildren(ctx, templ_7745c5c3_Var18), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("Encrypt Key").Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var19 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var14 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -487,21 +357,21 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Group Mode").Render(templ.WithChildren(ctx, templ_7745c5c3_Var19), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("Verification Token").Render(templ.WithChildren(ctx, templ_7745c5c3_Var14), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var20 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var15 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -513,31 +383,31 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("Allowed IDs").Render(templ.WithChildren(ctx, templ_7745c5c3_Var20), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("Group Mode").Render(templ.WithChildren(ctx, templ_7745c5c3_Var15), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = channelBlock("Feishu", "feishu").Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = channelBlock("Feishu", "feishu").Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var21 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var16 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -549,69 +419,17 @@ func ChannelsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var22 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "

Requires cached context_token (repopulated when user sends a message after restart).

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = ui.FormField("Notify Chat").Render(templ.WithChildren(ctx, templ_7745c5c3_Var22), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var23 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = ui.FormField("Allowed IDs").Render(templ.WithChildren(ctx, templ_7745c5c3_Var23), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

To connect, go to Profile and use \"Link Weixin\" to scan the QR code.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

To connect, go to Profile and use \"Link Weixin\" to scan the QR code.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = channelBlock("Weixin", "weixin").Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = channelBlock("Weixin", "weixin").Render(templ.WithChildren(ctx, templ_7745c5c3_Var16), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -636,98 +454,98 @@ func channelBlock(name string, platform string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var24 := templ.GetChildren(ctx) - if templ_7745c5c3_Var24 == nil { - templ_7745c5c3_Var24 = templ.NopComponent + templ_7745c5c3_Var17 := templ.GetChildren(ctx) + if templ_7745c5c3_Var17 == nil { + templ_7745c5c3_Var17 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(name) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 264, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/pages/channels.templ`, Line: 186, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
Enable
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" x-transition x-cloak class=\"space-y-4 pl-0 md:pl-6\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templ_7745c5c3_Var24.Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = templ_7745c5c3_Var17.Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/admin/ui/pages/users.templ b/internal/admin/ui/pages/users.templ index 8538b158..864630da 100644 --- a/internal/admin/ui/pages/users.templ +++ b/internal/admin/ui/pages/users.templ @@ -135,7 +135,22 @@ templ UsersPage() { >No linked identities. - + +
+

Notify Channel

+ +

Which channel receives scheduler and tool notifications.

+
+

Agent Assignments

diff --git a/internal/admin/ui/pages/users_templ.go b/internal/admin/ui/pages/users_templ.go index 23b52432..a928484a 100644 --- a/internal/admin/ui/pages/users_templ.go +++ b/internal/admin/ui/pages/users_templ.go @@ -47,7 +47,7 @@ func UsersPage() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Role

Linked Identities

No linked identities.

Agent Assignments

No agent assignments (has access to all system-scope agents).

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Role

Linked Identities

No linked identities.
0\">

Notify Channel

Which channel receives scheduler and tool notifications.

Agent Assignments

No agent assignments (has access to all system-scope agents).

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/admin/ui/static/js/pages/channels.js b/internal/admin/ui/static/js/pages/channels.js index 3649a18c..cbe5b7a1 100644 --- a/internal/admin/ui/static/js/pages/channels.js +++ b/internal/admin/ui/static/js/pages/channels.js @@ -1,28 +1,5 @@ import { api } from '/static/js/api.js' -/** - * Parses a comma-separated string into an array. - * When numeric is true, values are converted to numbers (for Telegram IDs). - * - * @param {string} str - * @param {boolean} numeric - * @returns {Array} - */ -function parseAllowedIds(str, numeric = false) { - const parts = str.split(',').map(s => s.trim()).filter(Boolean) - return numeric ? parts.map(Number) : parts -} - -/** - * Formats an array of IDs into a comma-separated display string. - * - * @param {Array} arr - * @returns {string} - */ -function formatAllowedIds(arr) { - return (arr || []).join(', ') -} - /** * Registers the channelsPage Alpine.data component. * @@ -33,30 +10,22 @@ export function register(Alpine) { channelData: { telegram: { enabled: false, enable_notify: false, - token: '', notify_chat: '', channel_id: '', - group_mode: '', allowed_ids: [], + token: '', channel_id: '', group_mode: '', }, qq: { enabled: false, enable_notify: false, - app_id: '', app_secret: '', - group_mode: '', allowed_ids: [], + app_id: '', app_secret: '', group_mode: '', }, feishu: { enabled: false, enable_notify: false, app_id: '', app_secret: '', encrypt_key: '', - verification_token: '', notify_chat: '', - group_mode: '', allowed_ids: [], + verification_token: '', group_mode: '', }, weixin: { enabled: false, enable_notify: false, - notify_chat: '', allowed_ids: [], }, }, - // Expose helpers to templates - parseAllowedIds, - formatAllowedIds, - async init() { await this.loadChannels() }, @@ -72,10 +41,8 @@ export function register(Alpine) { enabled: ch.enabled, enable_notify: cfg.enable_notify || false, token: cfg.token || '', - notify_chat: cfg.notify_chat || '', channel_id: cfg.channel_id || '', group_mode: cfg.group_mode || '', - allowed_ids: cfg.allowed_ids || [], } } else if (ch.id === 'qq') { this.channelData.qq = { @@ -84,7 +51,6 @@ export function register(Alpine) { app_id: cfg.app_id || '', app_secret: cfg.app_secret || '', group_mode: cfg.group_mode || '', - allowed_ids: cfg.allowed_ids || [], } } else if (ch.id === 'feishu') { this.channelData.feishu = { @@ -94,16 +60,12 @@ export function register(Alpine) { app_secret: cfg.app_secret || '', encrypt_key: cfg.encrypt_key || '', verification_token: cfg.verification_token || '', - notify_chat: cfg.notify_chat || '', group_mode: cfg.group_mode || '', - allowed_ids: cfg.allowed_ids || [], } } else if (ch.id === 'weixin') { this.channelData.weixin = { enabled: ch.enabled, enable_notify: cfg.enable_notify || false, - notify_chat: cfg.notify_chat || '', - allowed_ids: cfg.allowed_ids || [], } } } diff --git a/internal/admin/ui/static/js/pages/users.js b/internal/admin/ui/static/js/pages/users.js index f980a9f9..52e80856 100644 --- a/internal/admin/ui/static/js/pages/users.js +++ b/internal/admin/ui/static/js/pages/users.js @@ -132,6 +132,20 @@ export function register(Alpine) { } }, + async setNotifyIdentity(identityId) { + if (!this.selectedUser) return + try { + const val = identityId ? parseInt(identityId, 10) : null + await api('PUT', '/api/users/' + this.selectedUser.id + '/notify-identity', { + notify_identity_id: val, + }) + this.selectedUser = await api('GET', '/api/auth/users/' + this.selectedUser.id) + this.$store.toast.show('Notify channel updated') + } catch (e) { + this.$store.toast.show(e.message, 'error') + } + }, + async removeAgentFromUser(agentId) { if (!this.selectedUser) return const newIds = this.userAgentIds.filter(id => id !== agentId) diff --git a/internal/admin/users.go b/internal/admin/users.go index 2c90dc5b..7716c275 100644 --- a/internal/admin/users.go +++ b/internal/admin/users.go @@ -25,6 +25,26 @@ func (s *Server) updateUserDefaultAgent(w http.ResponseWriter, r *http.Request) writeData(w, http.StatusOK, map[string]string{"status": "updated"}) } +func (s *Server) updateUserNotifyIdentity(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + var body struct { + NotifyIdentityID *int64 `json:"notify_identity_id"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if err := s.authStore.UpdateUserNotifyIdentity(r.Context(), id, body.NotifyIdentityID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeData(w, http.StatusOK, map[string]string{"status": "updated"}) +} + func (s *Server) listUserMemories(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { diff --git a/internal/agent/runner/builtin/anna/references/channels.md b/internal/agent/runner/builtin/anna/references/channels.md index 79a0600f..8f11f8d0 100644 --- a/internal/agent/runner/builtin/anna/references/channels.md +++ b/internal/agent/runner/builtin/anna/references/channels.md @@ -25,10 +25,8 @@ Telegram channel config (JSON): ```json { "token": "BOT_TOKEN", - "notify_chat": "123456789", "channel_id": "@my_channel", "group_mode": "mention", - "allowed_ids": [136345060], "enable_notify": true } ``` @@ -53,11 +51,11 @@ Set `group_mode` in the channel config: ### Access control -Add user IDs to `allowed_ids` array. Leave empty to allow all users. Send `/whoami` to the bot to get your user ID. +Access control is handled by the RBAC system (auth_identities + policy engine). Use the admin panel to manage user roles and permissions. ### Notifications -Set `enable_notify: true` and `notify_chat` to a chat ID for proactive messages (scheduler results, notify tool). +Set `enable_notify: true` for proactive messages (scheduler results, notify tool). Notification targets are resolved automatically from auth_identities. ## QQ bot @@ -71,7 +69,6 @@ QQ channel config (JSON): "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", "group_mode": "mention", - "allowed_ids": [], "enable_notify": false } ``` @@ -101,9 +98,7 @@ Feishu channel config (JSON): "app_secret": "YOUR_APP_SECRET", "encrypt_key": "", "verification_token": "", - "notify_chat": "oc_xxx", "group_mode": "mention", - "allowed_ids": [], "enable_notify": false } ``` @@ -131,9 +126,7 @@ WeChat channel config (JSON): "bot_token": "OBTAINED_VIA_QR", "base_url": "https://ilinkai.weixin.qq.com", "bot_id": "AUTO", - "user_id": "AUTO", - "notify_chat": "", - "allowed_ids": [] + "user_id": "AUTO" } ``` diff --git a/internal/agent/runner/builtin/anna/references/configuration.md b/internal/agent/runner/builtin/anna/references/configuration.md index b8023dba..31806036 100644 --- a/internal/agent/runner/builtin/anna/references/configuration.md +++ b/internal/agent/runner/builtin/anna/references/configuration.md @@ -44,15 +44,15 @@ Channels are stored as JSON blobs in the `settings_channels` table. Configure vi **Telegram config fields:** - `token` -- Bot token -- `notify_chat` -- Default chat ID for notifications - `channel_id` -- Broadcast channel ID or @username - `group_mode` -- "mention" | "always" | "disabled" -- `allowed_ids` -- Array of user IDs (empty = allow all) - `enable_notify` -- Allow notify tool for this channel -**QQ config fields:** `app_id`, `app_secret`, `group_mode`, `allowed_ids`, `enable_notify` +Access control is handled by RBAC (auth_identities + policy engine). Notification targets are resolved from auth_identities. -**Feishu config fields:** `app_id`, `app_secret`, `encrypt_key`, `verification_token`, `notify_chat`, `group_mode`, `allowed_ids`, `enable_notify` +**QQ config fields:** `app_id`, `app_secret`, `group_mode`, `enable_notify` + +**Feishu config fields:** `app_id`, `app_secret`, `encrypt_key`, `verification_token`, `group_mode`, `enable_notify` ## Settings (key-value) diff --git a/internal/auth/store.go b/internal/auth/store.go index e5def5fd..14990e4e 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -16,6 +16,7 @@ type AuthStore interface { UpdateUser(ctx context.Context, u AuthUser) error UpdateUserRole(ctx context.Context, userID int64, role string) error UpdateUserDefaultAgent(ctx context.Context, userID int64, agentID string) error + UpdateUserNotifyIdentity(ctx context.Context, userID int64, identityID *int64) error DeleteUser(ctx context.Context, id int64) error CountUsers(ctx context.Context) (int64, error) diff --git a/internal/auth/types.go b/internal/auth/types.go index 129e2a51..77f3c566 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -4,14 +4,15 @@ import "time" // AuthUser represents a system user with login credentials and preferences. type AuthUser struct { - ID int64 `json:"id"` - Username string `json:"username"` - PasswordHash string `json:"-"` - Role string `json:"role"` - IsActive bool `json:"is_active"` - DefaultAgentID string `json:"default_agent_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Role string `json:"role"` + IsActive bool `json:"is_active"` + DefaultAgentID string `json:"default_agent_id,omitempty"` + NotifyIdentityID *int64 `json:"notify_identity_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // IsAdmin returns true if the user has the admin role. diff --git a/internal/channel/feishu/feishu.go b/internal/channel/feishu/feishu.go index 068eb841..57365923 100644 --- a/internal/channel/feishu/feishu.go +++ b/internal/channel/feishu/feishu.go @@ -49,9 +49,7 @@ type Config struct { AppSecret string `json:"app_secret"` EncryptKey string `json:"encrypt_key"` VerificationToken string `json:"verification_token"` - NotifyChat string `json:"notify_chat"` GroupMode string `json:"group_mode"` // "mention" | "always" | "disabled" - AllowedIDs []string `json:"allowed_ids"` // user open_ids allowed (empty = allow all) Groups map[string]GroupConfig `json:"groups"` // per-group overrides keyed by chat_id RedirectURI string `json:"redirect_uri"` // OAuth redirect URI (default: https://anna.vaayne.com/oauth/callback) } @@ -78,10 +76,9 @@ type Bot struct { lastSeenSweep time.Time // last time seenMsgs was swept activeStreams map[string]context.CancelFunc // streamKey -> cancel func - allowed map[string]struct{} - cfg Config - ctx context.Context - cancel context.CancelFunc + cfg Config + ctx context.Context + cancel context.CancelFunc } // BotOption configures the Feishu Bot. @@ -116,11 +113,6 @@ func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn ModelList cfg.GroupMode = "mention" } - allowed := make(map[string]struct{}, len(cfg.AllowedIDs)) - for _, id := range cfg.AllowedIDs { - allowed[id] = struct{}{} - } - b := &Bot{ poolManager: pm, store: store, @@ -130,7 +122,6 @@ func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn ModelList chatModels: make(map[string]ModelOption), seenMsgs: make(map[string]time.Time), activeStreams: make(map[string]context.CancelFunc), - allowed: allowed, cfg: cfg, } @@ -226,9 +217,6 @@ func (b *Bot) Name() string { return "feishu" } // Supports both chat IDs (oc_ prefix) and user open IDs (ou_ prefix). func (b *Bot) Notify(ctx context.Context, n channel.Notification) error { chatID := n.ChatID - if chatID == "" { - chatID = b.cfg.NotifyChat - } if chatID == "" { return fmt.Errorf("feishu: no target chat ID") } @@ -261,16 +249,6 @@ func (b *Bot) Notify(ctx context.Context, n channel.Notification) error { return nil } -// isAllowed returns true if the sender is in the allowed list. -// An empty allowed list means everyone is allowed. -func (b *Bot) isAllowed(openID string) bool { - if len(b.allowed) == 0 { - return true - } - _, ok := b.allowed[openID] - return ok -} - // textContent builds the JSON content string for a Feishu text message. func textContent(text string) string { data, _ := json.Marshal(map[string]string{"text": text}) diff --git a/internal/channel/feishu/feishu_test.go b/internal/channel/feishu/feishu_test.go index 51a6809e..3f414d31 100644 --- a/internal/channel/feishu/feishu_test.go +++ b/internal/channel/feishu/feishu_test.go @@ -247,37 +247,6 @@ func TestNewCustomGroupMode(t *testing.T) { } } -func TestNewAllowedIDs(t *testing.T) { - cfg := Config{AppID: "1", AppSecret: "s", AllowedIDs: []string{"u1", "u2"}} - bot, _ := New(cfg, nil, nil, nil, nil) - if len(bot.allowed) != 2 { - t.Errorf("allowed len = %d, want 2", len(bot.allowed)) - } -} - -// --- isAllowed --- - -func TestIsAllowedEmptyList(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{}} - if !bot.isAllowed("anyone") { - t.Error("empty allowed list should allow everyone") - } -} - -func TestIsAllowedMatch(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{"u1": {}}} - if !bot.isAllowed("u1") { - t.Error("u1 should be allowed") - } -} - -func TestIsAllowedNoMatch(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{"u1": {}}} - if bot.isAllowed("u2") { - t.Error("u2 should not be allowed") - } -} - // --- resolve session key format --- func TestResolveSessionKeyFormat(t *testing.T) { @@ -733,11 +702,11 @@ func TestStripMentionsRegexCleanup(t *testing.T) { // --- Notify with fallback --- -func TestNotifyFallbackToConfig(t *testing.T) { - bot := &Bot{cfg: Config{NotifyChat: ""}} +func TestNotifyNoChatID(t *testing.T) { + bot := &Bot{cfg: Config{}} err := bot.Notify(context.Background(), channel.Notification{Text: "hi"}) if err == nil { - t.Fatal("expected error when no chat ID configured") + t.Fatal("expected error when no chat ID") } if !strings.Contains(err.Error(), "no target chat ID") { t.Errorf("unexpected error: %v", err) @@ -1198,7 +1167,7 @@ func TestOnReactionNilEventData(t *testing.T) { } func TestOnReactionAppOperator(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{}} + bot := &Bot{} opType := "app" err := bot.onReaction(context.Background(), &larkim.P2MessageReactionCreatedV1{ Event: &larkim.P2MessageReactionCreatedV1Data{ @@ -1211,7 +1180,7 @@ func TestOnReactionAppOperator(t *testing.T) { } func TestOnReactionSelfReaction(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{}} + bot := &Bot{} bot.botOpenID.Store("ou_bot123") opType := "user" openID := "ou_bot123" @@ -1225,18 +1194,3 @@ func TestOnReactionSelfReaction(t *testing.T) { t.Errorf("self-reaction should be ignored, got %v", err) } } - -func TestOnReactionUnauthorized(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{"ou_allowed": {}}} - opType := "user" - openID := "ou_unauthorized" - err := bot.onReaction(context.Background(), &larkim.P2MessageReactionCreatedV1{ - Event: &larkim.P2MessageReactionCreatedV1Data{ - OperatorType: &opType, - UserId: &larkim.UserId{OpenId: &openID}, - }, - }) - if err != nil { - t.Errorf("unauthorized reaction should be ignored, got %v", err) - } -} diff --git a/internal/channel/feishu/handler.go b/internal/channel/feishu/handler.go index 09aa9fd5..6e63f1a4 100644 --- a/internal/channel/feishu/handler.go +++ b/internal/channel/feishu/handler.go @@ -46,10 +46,6 @@ func (b *Bot) onReaction(ctx context.Context, event *larkim.P2MessageReactionCre return nil } - if !b.isAllowed(openID) { - return nil - } - messageID := derefStr(data.MessageId) if messageID == "" { return nil @@ -101,11 +97,6 @@ func (b *Bot) onMessage(ctx context.Context, event *larkim.P2MessageReceiveV1) e return nil } - if !b.isAllowed(openID) { - logger().Warn("unauthorized access", "open_id", openID) - return nil - } - chatID := derefStr(msg.ChatId) chatType := derefStr(msg.ChatType) messageID := derefStr(msg.MessageId) diff --git a/internal/channel/notifier.go b/internal/channel/notifier.go index 5e108d70..ac63f7c4 100644 --- a/internal/channel/notifier.go +++ b/internal/channel/notifier.go @@ -25,8 +25,7 @@ type Notifier interface { } type channelEntry struct { - channel Channel - defaultChat string + channel Channel } // Dispatcher routes notifications to one or more registered channels. @@ -42,10 +41,10 @@ func NewDispatcher() *Dispatcher { return &Dispatcher{} } -// Register adds a channel with its default chat/channel target. -func (d *Dispatcher) Register(ch Channel, defaultChat string) { +// Register adds a channel to the dispatcher. +func (d *Dispatcher) Register(ch Channel) { d.mu.Lock() - d.channels = append(d.channels, channelEntry{channel: ch, defaultChat: defaultChat}) + d.channels = append(d.channels, channelEntry{channel: ch}) d.mu.Unlock() } @@ -65,9 +64,6 @@ func (d *Dispatcher) Notify(ctx context.Context, n Notification) error { if n.Channel != "" { for _, e := range entries { if e.channel.Name() == n.Channel { - if n.ChatID == "" { - n.ChatID = e.defaultChat - } return e.channel.Notify(ctx, n) } } @@ -77,11 +73,7 @@ func (d *Dispatcher) Notify(ctx context.Context, n Notification) error { // Broadcast to all channels. var errs []error for _, e := range entries { - nn := n - if nn.ChatID == "" { - nn.ChatID = e.defaultChat - } - if err := e.channel.Notify(ctx, nn); err != nil { + if err := e.channel.Notify(ctx, n); err != nil { errs = append(errs, fmt.Errorf("%s: %w", e.channel.Name(), err)) } } @@ -95,9 +87,14 @@ func (d *Dispatcher) SetAuthStore(store auth.AuthStore) { d.mu.Unlock() } -// NotifyUser sends a notification to a specific user by resolving their -// channel identities. Falls back to broadcast if the user has no linked -// identities or if no auth store is configured. +// NotifyUser sends a notification to a specific user via a single channel. +// +// Resolution order: +// 1. If the user has a notify_identity_id preference, use that identity. +// 2. Otherwise use the first linked identity (earliest linked_at). +// +// Falls back to broadcast if the user has no linked identities or if no +// auth store is configured. func (d *Dispatcher) NotifyUser(ctx context.Context, userID int64, n Notification) error { d.mu.RLock() as := d.authStore @@ -127,36 +124,58 @@ func (d *Dispatcher) NotifyUser(ctx context.Context, userID int64, n Notificatio return d.Notify(ctx, n) } - // Build a map of platform -> external_id for quick lookup. - platformIDs := make(map[string]string, len(identities)) - for _, id := range identities { - platformIDs[id.Platform] = id.ExternalID - } + // Pick the target identity: preferred > first linked. + target := pickNotifyIdentity(ctx, as, userID, identities) - // Send to each channel where the user has a linked identity. - var errs []error - sent := false + // Find the matching registered channel and send. for _, e := range entries { - externalID, ok := platformIDs[e.channel.Name()] - if !ok { - continue + if e.channel.Name() == target.Platform { + nn := n + nn.ChatID = target.ExternalID + return e.channel.Notify(ctx, nn) } - nn := n - nn.ChatID = externalID - if err := e.channel.Notify(ctx, nn); err != nil { - errs = append(errs, fmt.Errorf("%s: %w", e.channel.Name(), err)) - } else { - sent = true + } + + // Preferred platform has no registered channel — use first linked + // identity that has a registered channel. + for _, id := range identities { + for _, e := range entries { + if e.channel.Name() == id.Platform { + slog.Warn("notifyUser: preferred channel not registered, using first available", + "user_id", userID, "preferred", target.Platform, "fallback", id.Platform) + nn := n + nn.ChatID = id.ExternalID + return e.channel.Notify(ctx, nn) + } } } - // If nothing was sent (no matching channels), fall back to broadcast. - if !sent && len(errs) == 0 { - slog.Debug("notifyUser: no matching channels for user identities, falling back to broadcast", "user_id", userID) - return d.Notify(ctx, n) + slog.Debug("notifyUser: no matching channels for user identities, falling back to broadcast", "user_id", userID) + return d.Notify(ctx, n) +} + +// pickNotifyIdentity returns the identity to use for notifications. +// If the user has a notify_identity_id preference that matches one of their +// linked identities, that identity is returned. Otherwise the first identity +// (earliest linked_at from the DB query) is used. +func pickNotifyIdentity(ctx context.Context, as auth.AuthStore, userID int64, identities []auth.Identity) auth.Identity { + user, err := as.GetUser(ctx, userID) + if err != nil { + slog.Warn("notifyUser: failed to get user, using first identity", "user_id", userID, "error", err) + return identities[0] } - return errors.Join(errs...) + if user.NotifyIdentityID != nil { + for _, id := range identities { + if id.ID == *user.NotifyIdentityID { + return id + } + } + slog.Warn("notifyUser: preferred identity not found in linked identities, using first", + "user_id", userID, "notify_identity_id", *user.NotifyIdentityID) + } + + return identities[0] } // Channels returns the names of all registered channels. diff --git a/internal/channel/notifier_test.go b/internal/channel/notifier_test.go index 710470c9..b043fc28 100644 --- a/internal/channel/notifier_test.go +++ b/internal/channel/notifier_test.go @@ -4,6 +4,9 @@ import ( "context" "errors" "testing" + "time" + + "github.com/vaayne/anna/internal/auth" ) // mockChannel is a test Channel that records Notify calls. @@ -25,8 +28,8 @@ func TestDispatcherBroadcast(t *testing.T) { d := NewDispatcher() tg := &mockChannel{name: "telegram"} slack := &mockChannel{name: "slack"} - d.Register(tg, "tg-chat") - d.Register(slack, "slack-channel") + d.Register(tg) + d.Register(slack) err := d.Notify(context.Background(), Notification{Text: "hello"}) if err != nil { @@ -36,23 +39,17 @@ func TestDispatcherBroadcast(t *testing.T) { if len(tg.calls) != 1 { t.Fatalf("telegram got %d calls, want 1", len(tg.calls)) } - if tg.calls[0].ChatID != "tg-chat" { - t.Errorf("telegram ChatID = %q, want %q", tg.calls[0].ChatID, "tg-chat") - } if len(slack.calls) != 1 { t.Fatalf("slack got %d calls, want 1", len(slack.calls)) } - if slack.calls[0].ChatID != "slack-channel" { - t.Errorf("slack ChatID = %q, want %q", slack.calls[0].ChatID, "slack-channel") - } } func TestDispatcherRouteToSpecific(t *testing.T) { d := NewDispatcher() tg := &mockChannel{name: "telegram"} slack := &mockChannel{name: "slack"} - d.Register(tg, "tg-chat") - d.Register(slack, "slack-channel") + d.Register(tg) + d.Register(slack) err := d.Notify(context.Background(), Notification{ Channel: "slack", @@ -68,15 +65,12 @@ func TestDispatcherRouteToSpecific(t *testing.T) { if len(slack.calls) != 1 { t.Fatalf("slack got %d calls, want 1", len(slack.calls)) } - if slack.calls[0].ChatID != "slack-channel" { - t.Errorf("slack ChatID = %q, want default %q", slack.calls[0].ChatID, "slack-channel") - } } func TestDispatcherExplicitChatID(t *testing.T) { d := NewDispatcher() tg := &mockChannel{name: "telegram"} - d.Register(tg, "default-chat") + d.Register(tg) err := d.Notify(context.Background(), Notification{ Channel: "telegram", @@ -93,7 +87,7 @@ func TestDispatcherExplicitChatID(t *testing.T) { func TestDispatcherUnknownChannel(t *testing.T) { d := NewDispatcher() - d.Register(&mockChannel{name: "telegram"}, "chat") + d.Register(&mockChannel{name: "telegram"}) err := d.Notify(context.Background(), Notification{Channel: "discord", Text: "test"}) if err == nil { @@ -113,8 +107,8 @@ func TestDispatcherPartialFailure(t *testing.T) { d := NewDispatcher() good := &mockChannel{name: "telegram"} bad := &mockChannel{name: "slack", err: errors.New("slack down")} - d.Register(good, "chat") - d.Register(bad, "channel") + d.Register(good) + d.Register(bad) err := d.Notify(context.Background(), Notification{Text: "test"}) if err == nil { @@ -128,8 +122,8 @@ func TestDispatcherPartialFailure(t *testing.T) { func TestDispatcherChannels(t *testing.T) { d := NewDispatcher() - d.Register(&mockChannel{name: "telegram"}, "") - d.Register(&mockChannel{name: "slack"}, "") + d.Register(&mockChannel{name: "telegram"}) + d.Register(&mockChannel{name: "slack"}) names := d.Channels() if len(names) != 2 { @@ -156,7 +150,7 @@ func TestNotifyToolDefinition(t *testing.T) { func TestNotifyToolExecuteBroadcast(t *testing.T) { d := NewDispatcher() tg := &mockChannel{name: "telegram"} - d.Register(tg, "chat-123") + d.Register(tg) tool := NewNotifyTool(d) result, err := tool.Execute(context.Background(), map[string]any{ @@ -179,7 +173,7 @@ func TestNotifyToolExecuteBroadcast(t *testing.T) { func TestNotifyToolExecuteTargeted(t *testing.T) { d := NewDispatcher() tg := &mockChannel{name: "telegram"} - d.Register(tg, "default-chat") + d.Register(tg) tool := NewNotifyTool(d) result, err := tool.Execute(context.Background(), map[string]any{ @@ -215,7 +209,7 @@ func TestNotifyToolExecuteEmptyMessage(t *testing.T) { func TestNotifyToolExecuteError(t *testing.T) { d := NewDispatcher() bad := &mockChannel{name: "telegram", err: errors.New("send failed")} - d.Register(bad, "chat") + d.Register(bad) tool := NewNotifyTool(d) _, err := tool.Execute(context.Background(), map[string]any{ @@ -226,3 +220,106 @@ func TestNotifyToolExecuteError(t *testing.T) { t.Fatal("expected error") } } + +// --- NotifyUser tests --- + +// mockAuthStore is a minimal auth.AuthStore for testing NotifyUser. +type mockAuthStore struct { + auth.AuthStore // embed to satisfy interface; panics on unimplemented methods + user auth.AuthUser + identities []auth.Identity +} + +func (m *mockAuthStore) GetUser(_ context.Context, _ int64) (auth.AuthUser, error) { + return m.user, nil +} +func (m *mockAuthStore) ListIdentitiesByUser(_ context.Context, _ int64) ([]auth.Identity, error) { + return m.identities, nil +} + +func TestNotifyUserSendsToFirstLinked(t *testing.T) { + d := NewDispatcher() + tg := &mockChannel{name: "telegram"} + fs := &mockChannel{name: "feishu"} + d.Register(tg) + d.Register(fs) + d.SetAuthStore(&mockAuthStore{ + user: auth.AuthUser{ID: 1}, + identities: []auth.Identity{ + {ID: 10, Platform: "telegram", ExternalID: "tg-123", LinkedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, + {ID: 11, Platform: "feishu", ExternalID: "fs-456", LinkedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, + }, + }) + + err := d.NotifyUser(context.Background(), 1, Notification{Text: "hello"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should send to first linked (telegram) only. + if len(tg.calls) != 1 { + t.Errorf("telegram got %d calls, want 1", len(tg.calls)) + } + if tg.calls[0].ChatID != "tg-123" { + t.Errorf("telegram ChatID = %q, want %q", tg.calls[0].ChatID, "tg-123") + } + if len(fs.calls) != 0 { + t.Errorf("feishu got %d calls, want 0", len(fs.calls)) + } +} + +func TestNotifyUserSendsToPreferred(t *testing.T) { + d := NewDispatcher() + tg := &mockChannel{name: "telegram"} + fs := &mockChannel{name: "feishu"} + d.Register(tg) + d.Register(fs) + + preferredID := int64(11) + d.SetAuthStore(&mockAuthStore{ + user: auth.AuthUser{ID: 1, NotifyIdentityID: &preferredID}, + identities: []auth.Identity{ + {ID: 10, Platform: "telegram", ExternalID: "tg-123", LinkedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, + {ID: 11, Platform: "feishu", ExternalID: "fs-456", LinkedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, + }, + }) + + err := d.NotifyUser(context.Background(), 1, Notification{Text: "hello"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should send to preferred (feishu) only. + if len(fs.calls) != 1 { + t.Errorf("feishu got %d calls, want 1", len(fs.calls)) + } + if fs.calls[0].ChatID != "fs-456" { + t.Errorf("feishu ChatID = %q, want %q", fs.calls[0].ChatID, "fs-456") + } + if len(tg.calls) != 0 { + t.Errorf("telegram got %d calls, want 0", len(tg.calls)) + } +} + +func TestNotifyUserPreferredNotFoundFallsToFirst(t *testing.T) { + d := NewDispatcher() + tg := &mockChannel{name: "telegram"} + d.Register(tg) + + staleID := int64(999) // identity that no longer exists + d.SetAuthStore(&mockAuthStore{ + user: auth.AuthUser{ID: 1, NotifyIdentityID: &staleID}, + identities: []auth.Identity{ + {ID: 10, Platform: "telegram", ExternalID: "tg-123"}, + }, + }) + + err := d.NotifyUser(context.Background(), 1, Notification{Text: "test"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tg.calls) != 1 { + t.Fatalf("telegram got %d calls, want 1", len(tg.calls)) + } + if tg.calls[0].ChatID != "tg-123" { + t.Errorf("ChatID = %q, want %q", tg.calls[0].ChatID, "tg-123") + } +} diff --git a/internal/channel/qq/handler.go b/internal/channel/qq/handler.go index 698304ab..24cbdc3f 100644 --- a/internal/channel/qq/handler.go +++ b/internal/channel/qq/handler.go @@ -20,10 +20,6 @@ func (b *Bot) c2cMessageHandler() event.C2CMessageEventHandler { return func(_ *dto.WSPayload, data *dto.WSC2CMessageData) error { msg := (*dto.Message)(data) authorID := msg.Author.ID - if !b.isAllowed(authorID) { - logger().Warn("unauthorized c2c access", "user_id", authorID) - return nil - } // Try link code before anything else. text := strings.TrimSpace(msg.Content) @@ -65,10 +61,6 @@ func (b *Bot) groupATMessageHandler() event.GroupATMessageEventHandler { msg := (*dto.Message)(data) authorID := msg.Author.ID groupID := msg.GroupID - if !b.isAllowed(authorID) { - logger().Warn("unauthorized group access", "user_id", authorID, "group_id", groupID) - return nil - } if !b.shouldRespondInGroup() { return nil diff --git a/internal/channel/qq/qq.go b/internal/channel/qq/qq.go index f3e819aa..94d9225f 100644 --- a/internal/channel/qq/qq.go +++ b/internal/channel/qq/qq.go @@ -26,10 +26,9 @@ func logger() *slog.Logger { return slog.With("component", "qq") } // Config holds QQ Bot settings. type Config struct { - AppID string - AppSecret string - GroupMode string // "mention" | "always" | "disabled" - AllowedIDs []string // user OpenIDs allowed (empty = allow all) + AppID string + AppSecret string + GroupMode string // "mention" | "always" | "disabled" } // Bot wraps a QQ bot with agent pool integration. @@ -51,10 +50,9 @@ type Bot struct { mu sync.RWMutex chatModels map[string]channel.ModelOption - allowed map[string]struct{} - cfg Config - ctx context.Context - cancel context.CancelFunc + cfg Config + ctx context.Context + cancel context.CancelFunc } // BotOption configures the QQ Bot. @@ -81,11 +79,6 @@ func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn channel.M cfg.GroupMode = "mention" } - allowed := make(map[string]struct{}, len(cfg.AllowedIDs)) - for _, id := range cfg.AllowedIDs { - allowed[id] = struct{}{} - } - b := &Bot{ poolManager: pm, store: store, @@ -93,7 +86,6 @@ func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn channel.M listFn: listFn, switchFn: switchFn, chatModels: make(map[string]channel.ModelOption), - allowed: allowed, cfg: cfg, } @@ -190,16 +182,6 @@ func (b *Bot) Notify(ctx context.Context, n channel.Notification) error { return nil } -// isAllowed returns true if the sender is in the allowed list. -// An empty allowed list means everyone is allowed. -func (b *Bot) isAllowed(authorID string) bool { - if len(b.allowed) == 0 { - return true - } - _, ok := b.allowed[authorID] - return ok -} - // channelForC2C returns the channel identifier for a C2C (private) chat. func channelForC2C(userID string) string { return "qq:c2c:" + userID diff --git a/internal/channel/qq/qq_test.go b/internal/channel/qq/qq_test.go index 8ee08c4e..b0ce82fa 100644 --- a/internal/channel/qq/qq_test.go +++ b/internal/channel/qq/qq_test.go @@ -245,37 +245,6 @@ func TestNewCustomGroupMode(t *testing.T) { } } -func TestNewAllowedIDs(t *testing.T) { - cfg := Config{AppID: "1", AppSecret: "s", AllowedIDs: []string{"u1", "u2"}} - bot, _ := New(cfg, nil, nil, nil, nil) - if len(bot.allowed) != 2 { - t.Errorf("allowed len = %d, want 2", len(bot.allowed)) - } -} - -// --- isAllowed --- - -func TestIsAllowedEmptyList(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{}} - if !bot.isAllowed("anyone") { - t.Error("empty allowed list should allow everyone") - } -} - -func TestIsAllowedMatch(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{"u1": {}}} - if !bot.isAllowed("u1") { - t.Error("u1 should be allowed") - } -} - -func TestIsAllowedNoMatch(t *testing.T) { - bot := &Bot{allowed: map[string]struct{}{"u1": {}}} - if bot.isAllowed("u2") { - t.Error("u2 should not be allowed") - } -} - // --- channelForC2C / channelForGroup --- func TestChannelForC2C(t *testing.T) { diff --git a/internal/channel/telegram/handler.go b/internal/channel/telegram/handler.go index 6a0be59c..e10516f9 100644 --- a/internal/channel/telegram/handler.go +++ b/internal/channel/telegram/handler.go @@ -46,7 +46,7 @@ func (b *Bot) registerHandlers() { if c.Sender() == nil { return c.Send("Cannot determine user ID (no sender info).") } - msg := fmt.Sprintf("Your user ID: `%d`\nThis chat ID: `%d`\n\nUse the user ID in `allowed_ids` and the chat ID in `notify_chat` or as `chat_id` for notifications.", + msg := fmt.Sprintf("Your user ID: `%d`\nThis chat ID: `%d`", c.Sender().ID, c.Chat().ID) return c.Send(msg, tele.ModeMarkdown) })) diff --git a/internal/channel/telegram/telegram.go b/internal/channel/telegram/telegram.go index 4681e4ca..c4f5b082 100644 --- a/internal/channel/telegram/telegram.go +++ b/internal/channel/telegram/telegram.go @@ -27,11 +27,9 @@ func logger() *slog.Logger { return slog.With("component", "telegram") } // Config holds Telegram bot settings. type Config struct { - Token string // bot token - NotifyChat string // default chat ID for proactive notifications - ChannelID string // broadcast channel ID or @username - GroupMode string // "mention" | "always" | "disabled" - AllowedIDs []int64 // user IDs allowed to use the bot (empty = allow all) + Token string // bot token + ChannelID string // broadcast channel ID or @username + GroupMode string // "mention" | "always" | "disabled" } // Bot wraps a Telegram bot with agent pool integration. @@ -51,9 +49,8 @@ type Bot struct { mu sync.RWMutex chatModels map[int64]channel.ModelOption - allowed map[int64]struct{} // empty map = allow all - cfg Config - ctx context.Context + cfg Config + ctx context.Context } // New creates a Telegram bot and registers handlers. Call Start to begin polling. @@ -73,11 +70,6 @@ func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn channel.M cfg.GroupMode = "mention" } - allowed := make(map[int64]struct{}, len(cfg.AllowedIDs)) - for _, id := range cfg.AllowedIDs { - allowed[id] = struct{}{} - } - b := &Bot{ bot: bot, poolManager: pm, @@ -87,7 +79,6 @@ func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn channel.M switchFn: switchFn, md: tgmd.TGMD(), chatModels: make(map[int64]channel.ModelOption), - allowed: allowed, cfg: cfg, } @@ -145,9 +136,6 @@ func (b *Bot) Name() string { return "telegram" } // Notify sends a message to the specified chat. Implements channel.Channel. func (b *Bot) Notify(_ context.Context, n channel.Notification) error { chatID := n.ChatID - if chatID == "" { - chatID = b.cfg.NotifyChat - } if chatID == "" { chatID = b.cfg.ChannelID } @@ -182,15 +170,9 @@ type chatRef string func (c chatRef) Recipient() string { return string(c) } -// guard wraps a handler with access control and group mode checks. +// guard wraps a handler with group mode checks. func (b *Bot) guard(h tele.HandlerFunc) tele.HandlerFunc { return func(c tele.Context) error { - if !b.isAllowed(c) { - if s := c.Sender(); s != nil { - logger().Warn("unauthorized access", "user_id", s.ID) - } - return nil - } // Skip group filtering for callback queries — they originate from // the bot's own inline keyboards (e.g. model selection) and don't // carry mention/reply context. @@ -205,19 +187,6 @@ func (b *Bot) guard(h tele.HandlerFunc) tele.HandlerFunc { } } -// isAllowed returns true if the sender is in the allowed list. -// An empty allowed list means everyone is allowed. -func (b *Bot) isAllowed(c tele.Context) bool { - if len(b.allowed) == 0 { - return true - } - if c.Sender() == nil { - return false - } - _, ok := b.allowed[c.Sender().ID] - return ok -} - // isGroup returns true if the message is from a group or supergroup. func isGroup(c tele.Context) bool { t := c.Chat().Type diff --git a/internal/channel/weixin/handler.go b/internal/channel/weixin/handler.go index 3760050a..803b2421 100644 --- a/internal/channel/weixin/handler.go +++ b/internal/channel/weixin/handler.go @@ -27,12 +27,6 @@ func (b *Bot) handleUpdates(msgs []WeixinMessage) { continue } - // Check allowlist. - if !b.isAllowed(msg.FromUserID) { - logger().Warn("unauthorized access", "user_id", msg.FromUserID) - continue - } - // Cache context_token for this user. if msg.ContextToken != "" { b.contextTokens.Store(msg.FromUserID, msg.ContextToken) diff --git a/internal/channel/weixin/weixin.go b/internal/channel/weixin/weixin.go index ba242b2c..cecb7bd3 100644 --- a/internal/channel/weixin/weixin.go +++ b/internal/channel/weixin/weixin.go @@ -16,12 +16,10 @@ import ( // Config holds WeChat iLink bot settings. type Config struct { - BotToken string `json:"bot_token"` // iLink bot_token - BaseURL string `json:"base_url"` // iLink base URL (default: https://ilinkai.weixin.qq.com) - BotID string `json:"bot_id"` // ilink_bot_id - UserID string `json:"user_id"` // ilink_user_id - NotifyChat string `json:"notify_chat"` // default user ID for notifications (requires context_token) - AllowedIDs []string `json:"allowed_ids"` // user IDs allowed (empty = allow all) + BotToken string `json:"bot_token"` // iLink bot_token + BaseURL string `json:"base_url"` // iLink base URL (default: https://ilinkai.weixin.qq.com) + BotID string `json:"bot_id"` // ilink_bot_id + UserID string `json:"user_id"` // ilink_user_id } // dbConfig is the JSON shape persisted in settings_channels.config. @@ -47,10 +45,9 @@ type Bot struct { contextTokens sync.Map // key: userID string, value: contextToken string typingTickets sync.Map // key: userID string, value: typingTicket string - allowed map[string]struct{} // empty map = allow all - cfg Config - ctx context.Context - cancel context.CancelFunc + cfg Config + ctx context.Context + cancel context.CancelFunc } // BotOption configures the WeChat Bot. @@ -73,18 +70,12 @@ func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn channel.M return nil, fmt.Errorf("weixin: bot_token is required") } - allowed := make(map[string]struct{}, len(cfg.AllowedIDs)) - for _, id := range cfg.AllowedIDs { - allowed[id] = struct{}{} - } - b := &Bot{ poolManager: pm, store: store, agentCmd: channel.NewAgentCommander(store, nil), listFn: listFn, switchFn: switchFn, - allowed: allowed, cfg: cfg, } @@ -106,16 +97,6 @@ func (b *Bot) Stop() { } } -// isAllowed returns true if the user is in the allowed list. -// An empty allowed list means everyone is allowed. -func (b *Bot) isAllowed(userID string) bool { - if len(b.allowed) == 0 { - return true - } - _, ok := b.allowed[userID] - return ok -} - // Start begins long-polling for messages. It blocks until ctx is cancelled. func (b *Bot) Start(ctx context.Context) error { b.ctx, b.cancel = context.WithCancel(ctx) @@ -198,9 +179,6 @@ func (b *Bot) Notify(_ context.Context, n channel.Notification) error { } targetUser := n.ChatID - if targetUser == "" { - targetUser = b.cfg.NotifyChat - } if targetUser == "" { return fmt.Errorf("weixin: no target user ID for notification") } diff --git a/internal/channel/weixin/weixin_test.go b/internal/channel/weixin/weixin_test.go index 41770bea..60703a08 100644 --- a/internal/channel/weixin/weixin_test.go +++ b/internal/channel/weixin/weixin_test.go @@ -335,34 +335,6 @@ func TestNewSuccess(t *testing.T) { } } -func TestNewBuildsAllowedMap(t *testing.T) { - t.Parallel() - - cfg := Config{BotToken: "tok", AllowedIDs: []string{"user1", "user2", "user3"}} - bot, err := New(cfg, nil, nil, nil, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(bot.allowed) != 3 { - t.Errorf("allowed map len = %d, want 3", len(bot.allowed)) - } - for _, id := range []string{"user1", "user2", "user3"} { - if _, ok := bot.allowed[id]; !ok { - t.Errorf("expected %q in allowed map", id) - } - } -} - -func TestNewEmptyAllowedIDs(t *testing.T) { - t.Parallel() - - cfg := Config{BotToken: "tok"} - bot, _ := New(cfg, nil, nil, nil, nil) - if len(bot.allowed) != 0 { - t.Errorf("allowed map len = %d, want 0", len(bot.allowed)) - } -} - func TestNewWithAuth(t *testing.T) { t.Parallel() @@ -388,47 +360,6 @@ func TestBotName(t *testing.T) { } } -// --- isAllowed --- - -func TestIsAllowedEmptyList(t *testing.T) { - t.Parallel() - - bot := &Bot{allowed: map[string]struct{}{}} - if !bot.isAllowed("anyone") { - t.Error("empty allowed list should allow everyone") - } -} - -func TestIsAllowedMatch(t *testing.T) { - t.Parallel() - - bot := &Bot{allowed: map[string]struct{}{"user1": {}}} - if !bot.isAllowed("user1") { - t.Error("user1 should be allowed") - } -} - -func TestIsAllowedNoMatch(t *testing.T) { - t.Parallel() - - bot := &Bot{allowed: map[string]struct{}{"user1": {}}} - if bot.isAllowed("user2") { - t.Error("user2 should not be allowed") - } -} - -func TestIsAllowedMultipleUsers(t *testing.T) { - t.Parallel() - - bot := &Bot{allowed: map[string]struct{}{"a": {}, "b": {}, "c": {}}} - if !bot.isAllowed("b") { - t.Error("b should be allowed") - } - if bot.isAllowed("d") { - t.Error("d should not be allowed") - } -} - // --- SplitMessage (shared, with weixin limit) --- func TestSplitMessageShort(t *testing.T) { @@ -512,7 +443,7 @@ func TestSplitMessageMultibyteUTF8(t *testing.T) { func TestHandleUpdatesSkipsBotEchoes(t *testing.T) { t.Parallel() - bot := &Bot{allowed: map[string]struct{}{}} + bot := &Bot{} msgs := []WeixinMessage{ { MessageType: MessageTypeBot, // bot echo, should be skipped @@ -536,7 +467,7 @@ func TestHandleUpdatesSkipsBotEchoes(t *testing.T) { func TestHandleUpdatesSkipsPartialState(t *testing.T) { t.Parallel() - bot := &Bot{allowed: map[string]struct{}{}} + bot := &Bot{} msgs := []WeixinMessage{ { MessageType: MessageTypeUser, @@ -554,34 +485,13 @@ func TestHandleUpdatesSkipsPartialState(t *testing.T) { } } -func TestHandleUpdatesSkipsUnauthorized(t *testing.T) { - t.Parallel() - - bot := &Bot{allowed: map[string]struct{}{"allowed_user": {}}} - msgs := []WeixinMessage{ - { - MessageType: MessageTypeUser, - MessageState: MessageStateFinish, - FromUserID: "unauthorized_user", - ContextToken: "tok1", - ItemList: []MessageItem{{Type: ItemTypeText, TextItem: &TextItem{Text: "hello"}}}, - }, - } - - bot.handleUpdates(msgs) - - if _, ok := bot.contextTokens.Load("unauthorized_user"); ok { - t.Error("context_token should not be cached for unauthorized users") - } -} - func TestHandleUpdatesCachesContextToken(t *testing.T) { t.Parallel() // Create a minimal bot that won't crash when processing text. // We set allowed empty (allow all) and provide no pool so it will // error at resolve() — but the context_token should be cached before that. - bot := &Bot{allowed: map[string]struct{}{}} + bot := &Bot{} ctx, cancel := context.WithCancel(context.Background()) defer cancel() bot.ctx = ctx @@ -610,7 +520,7 @@ func TestHandleUpdatesCachesContextToken(t *testing.T) { func TestHandleUpdatesSkipsEmptyItemList(t *testing.T) { t.Parallel() - bot := &Bot{allowed: map[string]struct{}{}} + bot := &Bot{} msgs := []WeixinMessage{ { MessageType: MessageTypeUser, @@ -772,25 +682,6 @@ func TestNotifyErrorWhenNoContextToken(t *testing.T) { } } -func TestNotifyFallsBackToNotifyChat(t *testing.T) { - t.Parallel() - - bot := &Bot{ - client: NewClient("", "", "tok"), - cfg: Config{NotifyChat: "default_user"}, - } - - // No context_token for default_user either, so it will fail with context_token error. - err := bot.Notify(context.Background(), channel.Notification{Text: "hello"}) - if err == nil { - t.Fatal("expected error (no context_token)") - } - // The important thing: it should try default_user, not fail with "no target user". - if !strings.Contains(err.Error(), "no context_token for user default_user") { - t.Errorf("error should be about context_token for default_user: %v", err) - } -} - // --- Stop --- func TestStopWithCancel(t *testing.T) { diff --git a/internal/db/authstore.go b/internal/db/authstore.go index ba44b1aa..65099ab3 100644 --- a/internal/db/authstore.go +++ b/internal/db/authstore.go @@ -90,6 +90,20 @@ func (s *AuthStore) UpdateUserDefaultAgent(ctx context.Context, userID int64, ag return nil } +func (s *AuthStore) UpdateUserNotifyIdentity(ctx context.Context, userID int64, identityID *int64) error { + var v sql.NullInt64 + if identityID != nil { + v = sql.NullInt64{Int64: *identityID, Valid: true} + } + if err := s.q.UpdateAuthUserNotifyIdentity(ctx, sqlc.UpdateAuthUserNotifyIdentityParams{ + NotifyIdentityID: v, + ID: userID, + }); err != nil { + return fmt.Errorf("update notify identity for user %d: %w", userID, err) + } + return nil +} + func (s *AuthStore) DeleteUser(ctx context.Context, id int64) error { if err := s.q.DeleteAuthUser(ctx, id); err != nil { return fmt.Errorf("delete auth user %d: %w", id, err) @@ -371,6 +385,10 @@ func userFromDB(r sqlc.AuthUser) auth.AuthUser { if r.DefaultAgentID.Valid { u.DefaultAgentID = r.DefaultAgentID.String } + if r.NotifyIdentityID.Valid { + id := r.NotifyIdentityID.Int64 + u.NotifyIdentityID = &id + } return u } diff --git a/internal/db/migrations/20260322151434_add-notify-identity.sql b/internal/db/migrations/20260322151434_add-notify-identity.sql new file mode 100644 index 00000000..55a2d325 --- /dev/null +++ b/internal/db/migrations/20260322151434_add-notify-identity.sql @@ -0,0 +1,26 @@ +-- Disable the enforcement of foreign-keys constraints +PRAGMA foreign_keys = off; +-- Create "new_auth_users" table +CREATE TABLE `new_auth_users` ( + `id` integer NULL PRIMARY KEY AUTOINCREMENT, + `username` text NOT NULL, + `password_hash` text NOT NULL DEFAULT '', + `role` text NOT NULL DEFAULT 'user', + `is_active` integer NOT NULL DEFAULT 1, + `default_agent_id` text NULL, + `notify_identity_id` integer NULL, + `created_at` text NOT NULL DEFAULT (datetime('now')), + `updated_at` text NOT NULL DEFAULT (datetime('now')), + CONSTRAINT `0` FOREIGN KEY (`notify_identity_id`) REFERENCES `auth_identities` (`id`) ON UPDATE NO ACTION ON DELETE SET NULL, + CONSTRAINT `1` FOREIGN KEY (`default_agent_id`) REFERENCES `settings_agents` (`id`) ON UPDATE NO ACTION ON DELETE NO ACTION +); +-- Copy rows from old table "auth_users" to new temporary table "new_auth_users" +INSERT INTO `new_auth_users` (`id`, `username`, `password_hash`, `role`, `is_active`, `default_agent_id`, `created_at`, `updated_at`) SELECT `id`, `username`, `password_hash`, `role`, `is_active`, `default_agent_id`, `created_at`, `updated_at` FROM `auth_users`; +-- Drop "auth_users" table after copying rows +DROP TABLE `auth_users`; +-- Rename temporary table "new_auth_users" to "auth_users" +ALTER TABLE `new_auth_users` RENAME TO `auth_users`; +-- Create index "auth_users_username" to table: "auth_users" +CREATE UNIQUE INDEX `auth_users_username` ON `auth_users` (`username`); +-- Enable back the enforcement of foreign-keys constraints +PRAGMA foreign_keys = on; diff --git a/internal/db/migrations/atlas.sum b/internal/db/migrations/atlas.sum index 8b27b8a7..e33f1855 100644 --- a/internal/db/migrations/atlas.sum +++ b/internal/db/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:5pYuxHu2mTFi1L1DiDa3fStTnQmleLK0Xj7WQJ8Dfc8= +h1:wGcEYUAqFeMyTlTSjkJ+7SKD7quE/3apiKxBQN1BREU= 20260317041843_rename_tables_with_prefixes.sql h1:Nfk81sAWddxDQvpo8pBhXJ5Sec5JcCF6tchqgt+777M= 20260317065837_drop_agent_provider_id.sql h1:gb4CoI8GlT4P3QbksGw07M8SruOcR9pQI0Sj7/Q2rao= 20260320104110_add-auth-tables.sql h1:6TjzFFeMMTJzy3/mGN4nrm4+SoYGhanOQY24nUbL3H4= @@ -7,3 +7,4 @@ h1:5pYuxHu2mTFi1L1DiDa3fStTnQmleLK0Xj7WQJ8Dfc8= 20260321074750_simplify_roles.sql h1:cyXbo3r4spyibp+biedZjuAL88fz1Mp2rYBWkbQUnMk= 20260321145321_add_agent_creator_id.sql h1:2HiuO2XWSU8Zk8Scirx5mof09VNYpvSxHK7ZMYkxgUM= 20260321163635_add_feishu_tokens.sql h1:hM/g/wZ9vpR/H3ZoJOyH8NUKRJpLy5Mu8Rmv90OUz5w= +20260322151434_add-notify-identity.sql h1:+QlRGAqKwce8YXkU/6gdMv73IHmgt/wqQ8NP/6r22SA= diff --git a/internal/db/queries/auth_users.sql b/internal/db/queries/auth_users.sql index a584e0e6..56636c46 100644 --- a/internal/db/queries/auth_users.sql +++ b/internal/db/queries/auth_users.sql @@ -32,6 +32,12 @@ UPDATE auth_users SET updated_at = datetime('now') WHERE id = ?; +-- name: UpdateAuthUserNotifyIdentity :exec +UPDATE auth_users SET + notify_identity_id = ?, + updated_at = datetime('now') +WHERE id = ?; + -- name: DeleteAuthUser :exec DELETE FROM auth_users WHERE id = ?; diff --git a/internal/db/schemas/tables/auth_users.sql b/internal/db/schemas/tables/auth_users.sql index f83695a4..83164a1c 100644 --- a/internal/db/schemas/tables/auth_users.sql +++ b/internal/db/schemas/tables/auth_users.sql @@ -1,10 +1,11 @@ CREATE TABLE auth_users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL DEFAULT '', - role TEXT NOT NULL DEFAULT 'user', - is_active INTEGER NOT NULL DEFAULT 1, - default_agent_id TEXT REFERENCES settings_agents(id), - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'user', + is_active INTEGER NOT NULL DEFAULT 1, + default_agent_id TEXT REFERENCES settings_agents(id), + notify_identity_id INTEGER REFERENCES auth_identities(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/internal/db/sqlc/auth_users.sql.go b/internal/db/sqlc/auth_users.sql.go index 58766248..22d66a00 100644 --- a/internal/db/sqlc/auth_users.sql.go +++ b/internal/db/sqlc/auth_users.sql.go @@ -24,7 +24,7 @@ func (q *Queries) CountAuthUsers(ctx context.Context) (int64, error) { const createAuthUser = `-- name: CreateAuthUser :one INSERT INTO auth_users (username, password_hash) VALUES (?, ?) -RETURNING id, username, password_hash, role, is_active, default_agent_id, created_at, updated_at +RETURNING id, username, password_hash, role, is_active, default_agent_id, notify_identity_id, created_at, updated_at ` type CreateAuthUserParams struct { @@ -42,6 +42,7 @@ func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) &i.Role, &i.IsActive, &i.DefaultAgentID, + &i.NotifyIdentityID, &i.CreatedAt, &i.UpdatedAt, ) @@ -58,7 +59,7 @@ func (q *Queries) DeleteAuthUser(ctx context.Context, id int64) error { } const getAuthUser = `-- name: GetAuthUser :one -SELECT id, username, password_hash, role, is_active, default_agent_id, created_at, updated_at FROM auth_users WHERE id = ? +SELECT id, username, password_hash, role, is_active, default_agent_id, notify_identity_id, created_at, updated_at FROM auth_users WHERE id = ? ` func (q *Queries) GetAuthUser(ctx context.Context, id int64) (AuthUser, error) { @@ -71,6 +72,7 @@ func (q *Queries) GetAuthUser(ctx context.Context, id int64) (AuthUser, error) { &i.Role, &i.IsActive, &i.DefaultAgentID, + &i.NotifyIdentityID, &i.CreatedAt, &i.UpdatedAt, ) @@ -78,7 +80,7 @@ func (q *Queries) GetAuthUser(ctx context.Context, id int64) (AuthUser, error) { } const getAuthUserByUsername = `-- name: GetAuthUserByUsername :one -SELECT id, username, password_hash, role, is_active, default_agent_id, created_at, updated_at FROM auth_users WHERE username = ? +SELECT id, username, password_hash, role, is_active, default_agent_id, notify_identity_id, created_at, updated_at FROM auth_users WHERE username = ? ` func (q *Queries) GetAuthUserByUsername(ctx context.Context, username string) (AuthUser, error) { @@ -91,6 +93,7 @@ func (q *Queries) GetAuthUserByUsername(ctx context.Context, username string) (A &i.Role, &i.IsActive, &i.DefaultAgentID, + &i.NotifyIdentityID, &i.CreatedAt, &i.UpdatedAt, ) @@ -98,7 +101,7 @@ func (q *Queries) GetAuthUserByUsername(ctx context.Context, username string) (A } const listAuthUsers = `-- name: ListAuthUsers :many -SELECT id, username, password_hash, role, is_active, default_agent_id, created_at, updated_at FROM auth_users ORDER BY username +SELECT id, username, password_hash, role, is_active, default_agent_id, notify_identity_id, created_at, updated_at FROM auth_users ORDER BY username ` func (q *Queries) ListAuthUsers(ctx context.Context) ([]AuthUser, error) { @@ -117,6 +120,7 @@ func (q *Queries) ListAuthUsers(ctx context.Context) ([]AuthUser, error) { &i.Role, &i.IsActive, &i.DefaultAgentID, + &i.NotifyIdentityID, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -176,6 +180,23 @@ func (q *Queries) UpdateAuthUserDefaultAgent(ctx context.Context, arg UpdateAuth return err } +const updateAuthUserNotifyIdentity = `-- name: UpdateAuthUserNotifyIdentity :exec +UPDATE auth_users SET + notify_identity_id = ?, + updated_at = datetime('now') +WHERE id = ? +` + +type UpdateAuthUserNotifyIdentityParams struct { + NotifyIdentityID sql.NullInt64 `json:"notify_identity_id"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAuthUserNotifyIdentity(ctx context.Context, arg UpdateAuthUserNotifyIdentityParams) error { + _, err := q.db.ExecContext(ctx, updateAuthUserNotifyIdentity, arg.NotifyIdentityID, arg.ID) + return err +} + const updateAuthUserRole = `-- name: UpdateAuthUserRole :exec UPDATE auth_users SET role = ?, diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index d15e50fd..a628cef9 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -39,14 +39,15 @@ type AuthSession struct { } type AuthUser struct { - ID int64 `json:"id"` - Username string `json:"username"` - PasswordHash string `json:"password_hash"` - Role string `json:"role"` - IsActive int64 `json:"is_active"` - DefaultAgentID sql.NullString `json:"default_agent_id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + Role string `json:"role"` + IsActive int64 `json:"is_active"` + DefaultAgentID sql.NullString `json:"default_agent_id"` + NotifyIdentityID sql.NullInt64 `json:"notify_identity_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type AuthUserAgent struct {