| title | Notification System |
|---|
Implemented -- internal/channel/notifier.go, internal/channel/notify_tool.go, internal/channel/telegram/telegram.go.
Anna supports proactive notifications so the agent, scheduled jobs, and other internal triggers can push messages to users without waiting for a request. The system uses a multi-channel dispatcher that routes notifications to one or more configured channels (Telegram, QQ, WeChat, with Slack/Discord planned).
+-------------------+
| Agent (notify |--+
| tool call) | |
+-------------------+ |
| Notification{Channel, ChatID, Text, Silent}
+-------------------+ | |
| Scheduler job result |--+----------v------------------+
+-------------------+ | Dispatcher |
| +----------------------+ |
+-------------------+ | | Route by Channel | |
| Future triggers |--+ | or broadcast all | |
+-------------------+ +----------+-----------+ |
| |
+------------+----------+ |
| | | |
v v v |
+----------+ +--------+ +-------+ |
| Telegram | | Slack | |Discord| |
| Channel | |(future)| |(future)| |
+----------+ +--------+ +-------+ |
type Notification struct {
Channel string // optional: route to specific backend ("telegram", "slack")
ChatID string // target chat/channel within the backend
Text string // markdown content
Silent bool // send without notification sound
}Channelempty -- broadcast to all registered channelsChannelset -- route to that specific channel onlyChatIDempty -- resolved from auth identities viaNotifyUser, or channel-specific fallback
Interface that all messaging platforms implement:
type Channel interface {
Name() string
Start(ctx context.Context) error
Stop()
Notify(ctx context.Context, n Notification) error
}Currently implemented: telegram.Bot, qq.Bot, weixin.Bot.
Routes notifications to registered channels:
d := channel.NewDispatcher()
d.Register(tgBot) // telegram channel
d.Register(qqBot) // qq channel
// Broadcast to all channels:
d.Notify(ctx, channel.Notification{Text: "hello"})
// Route to specific channel:
d.Notify(ctx, channel.Notification{Channel: "telegram", Text: "hello"})
// Specify target chat:
d.Notify(ctx, channel.Notification{Channel: "telegram", ChatID: "999", Text: "hello"})Partial failures: if one channel fails during broadcast, the others still receive the notification. Errors are joined via errors.Join.
Agent-facing tool that wraps the dispatcher:
tool := channel.NewNotifyTool(dispatcher)The LLM can call it with:
{
"message": "Build finished, 3 tests failed",
"channel": "telegram",
"chat_id": "136345060",
"silent": false
}message(required) -- the notification textchannel(optional) -- target a specific channel; omit to broadcastchat_id(optional) -- override the channel's default targetsilent(optional) -- suppress notification sound
setup()
+-- Create Dispatcher
+-- Create NotifyTool(dispatcher) -> builtinTools
+-- Create runner factory with builtinTools
+-- Create PoolManager
runGateway()
+-- Create telegram.Bot
+-- dispatcher.Register(tgBot) <- channel registered
+-- wireSchedulerNotifier(schedulerSvc, poolManager, dispatcher) <- scheduler output -> dispatcher
+-- tgBot.Start(ctx) <- begin polling
The dispatcher is created early (in setup) so the notify tool can reference it. Channels are registered later (in runGateway) when they are created. This avoids circular dependencies. The wireSchedulerNotifier function routes through PoolManager rather than a single pool.
When a scheduled job fires:
- The job runs through
PoolManager.Chat()using the job'sagent_idanduser_idto reach the correct agent - The full response text is collected
- The text is broadcast via
dispatcher.Notify()to all channels
When no notification channels are registered, the notify tool is not exposed to the agent. This avoids a broken tool path.
Channel configuration is managed through the admin panel. Each channel's settings (tokens, chat IDs, group modes, allowed IDs) are stored as JSON in the database. Configure notification channels from the admin panel UI rather than editing configuration files directly.
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.
To add Slack, Discord, or any other channel:
- Implement
channel.Channel:
// channel/slack/slack.go
type Bot struct { ... }
func (b *Bot) Name() string { return "slack" }
func (b *Bot) Start(ctx context.Context) error { /* start listening */ }
func (b *Bot) Stop() { /* graceful shutdown */ }
func (b *Bot) Notify(ctx context.Context, n channel.Notification) error {
// Send n.Text to n.ChatID via Slack API
}Use channel.NewCommander(pool, listFn, switchFn) for shared /new, /compact, /model command logic. /whoami is handled per-channel since each platform returns different ID formats. Use channel.SplitMessage() and channel.FormatDuration() for shared utilities.
- Register in
runGateway():
if slackCfg.Token != "" {
slackBot := slack.New(slackCfg)
channels = append(channels, slackBot)
s.notifier.Register(slackBot)
}- Add channel config via the admin panel. Channel configuration is stored as JSON in the database settings table.
No changes needed to the dispatcher, notify tool, or scheduler wiring -- they work through the Channel interface.
The bot can operate in Telegram groups with configurable behavior:
mention(default) -- respond only when @mentioned or replied toalways-- respond to every message in the groupdisabled-- ignore all group messages (including commands)
Session ID for groups = group chat ID (shared context per group).
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.
telegram.Bot.Notify() supports:
- Numeric chat IDs (
"136345060") - Channel usernames (
"@my_channel") - Markdown rendering with MarkdownV2 fallback to plain text
- Message splitting at 4000-char boundaries
- Silent mode (
DisableNotification)