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() {
}
")
- 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, "
Default Agent
No memories yet.
")
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 {