feat(controller): enhance message handling and user options for automatic notifications

This commit is contained in:
2026-09-03 11:57:20 +08:00
parent 2a1f5e11f8
commit 6c819f8c52
6 changed files with 134 additions and 34 deletions
+10
View File
@@ -121,6 +121,16 @@ type BotUserOptions struct {
// EventStatusNotify indicates whether this member is subscribed to node // EventStatusNotify indicates whether this member is subscribed to node
// status change notifications. // status change notifications.
EventStatusNotify bool `json:"event_status_notify"` EventStatusNotify bool `json:"event_status_notify"`
// EventBotStarted indicates whether this member receives the automatic
// messages the bot pushes on startup (welcome message and startup server
// list).
EventBotStarted bool `json:"event_bot_started"`
// EventReply indicates whether this member receives automatic replies to
// their commands (e.g. /status, /list). If false, the bot will not send any
// reply to this member's commands.
EventReply bool `json:"event_reply"`
} }
// BotUserMembers maps a member ID (QQ number, Telegram @username or numeric // BotUserMembers maps a member ID (QQ number, Telegram @username or numeric
+53 -8
View File
@@ -21,6 +21,37 @@ type Command struct {
SenderID int64 // User ID of the sender SenderID int64 // User ID of the sender
} }
// Message represents a message to be sent by a controller.
type Message struct {
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
Content string // The message content
ChatID int64 // Chat/group ID where the message should be sent
Type string // Message type (see MessageType*), used for per-member opt-outs
}
// Message type labels. They let bot pipes apply per-member opt-out options from
// bot_user_config.json (see MemberReceives) to automatic messages.
const (
// MessageTypeBotStarted marks the automatic welcome/server-list messages the
// bot pushes on startup. Gated by BotUserOptions.EventBotStarted.
MessageTypeBotStarted = "event_bot_started"
// MessageTypeReply marks a direct reply to a user command. Reserved for the
// BotUserOptions.EventReply opt-out.
MessageTypeReply = "event_reply"
)
// MemberReceives reports whether a member whose bot_user_config.json options are
// opts receives an automatic message of the given type. Only member-controllable
// types are gated; anything else is always delivered.
func MemberReceives(opts config.BotUserOptions, messageType string) bool {
switch messageType {
case MessageTypeBotStarted:
return opts.EventBotStarted
default:
return true
}
}
// Controller defines the interface for all notification/bot controllers. // Controller defines the interface for all notification/bot controllers.
type Controller interface { type Controller interface {
Name() string Name() string
@@ -38,7 +69,7 @@ type Controller interface {
// pipes (email, ntfy, webhook) do not. // pipes (email, ntfy, webhook) do not.
type BotController interface { type BotController interface {
Controller Controller
SendMessage(message string) error SendMessage(message Message) error
} }
// Manager manages all controller instances and routes events. // Manager manages all controller instances and routes events.
@@ -72,14 +103,16 @@ func (m *Manager) Register(c Controller) {
// ShowBotInitMessage sends the bot initialization message to all enabled // ShowBotInitMessage sends the bot initialization message to all enabled
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do // bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
// not implement BotController are skipped. // not implement BotController are skipped. The message is typed
// MessageTypeBotStarted so each controller can honor its members' per-recipient
// EventBotStarted opt-out.
func (m *Manager) ShowBotInitMessage() { func (m *Manager) ShowBotInitMessage() {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
cfg := config.C_globalConfig cfg := config.C_globalConfig
params := template.BuildBotInitializationMsgParams() params := template.BuildBotInitializationMsgParams()
message := template.Render(cfg.ControllerMessage.BotStarted, params) content := template.Render(cfg.ControllerMessage.BotStarted, params)
for _, ctrl := range m.controllers { for _, ctrl := range m.controllers {
if !ctrl.IsEnabled() { if !ctrl.IsEnabled() {
@@ -89,22 +122,29 @@ func (m *Manager) ShowBotInitMessage() {
if !ok { if !ok {
continue // Notification-only pipe (email/ntfy/webhook), not a bot. continue // Notification-only pipe (email/ntfy/webhook), not a bot.
} }
message := Message{
Source: bot.Name(),
Content: content,
Type: MessageTypeBotStarted,
}
if err := bot.SendMessage(message); err != nil { if err := bot.SendMessage(message); err != nil {
postLog.Warning(fmt.Sprintf("Controller %s failed to send init message: %v", bot.Name(), err)) postLog.Warning(fmt.Sprintf("Controller %s failed to send init message: %v", bot.Name(), err))
} }
} }
} }
// ShowBotServerList sends the server list to all enabled bot controllers. The // ShowBotServerList sends the startup server list to all enabled bot
// message content is identical to the /list command (same template and // controllers. The message content is identical to the /list command (same
// parameters). // template and parameters). Like the init message it is typed
// MessageTypeBotStarted so members who opted out of bot-started pushes do not
// receive it.
func (m *Manager) ShowBotServerList() { func (m *Manager) ShowBotServerList() {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
cfg := config.C_globalConfig cfg := config.C_globalConfig
params := template.BuildParamsFromServerList() params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params) content := template.Render(cfg.ControllerMessage.ServerList, params)
for _, ctrl := range m.controllers { for _, ctrl := range m.controllers {
if !ctrl.IsEnabled() { if !ctrl.IsEnabled() {
@@ -114,6 +154,11 @@ func (m *Manager) ShowBotServerList() {
if !ok { if !ok {
continue // Notification-only pipe (email/ntfy/webhook), not a bot. continue // Notification-only pipe (email/ntfy/webhook), not a bot.
} }
message := Message{
Source: bot.Name(),
Content: content,
Type: MessageTypeBotStarted,
}
if err := bot.SendMessage(message); err != nil { if err := bot.SendMessage(message); err != nil {
postLog.Warning(fmt.Sprintf("Controller %s failed to send server list: %v", bot.Name(), err)) postLog.Warning(fmt.Sprintf("Controller %s failed to send server list: %v", bot.Name(), err))
} }
@@ -163,4 +208,4 @@ func (m *Manager) NotifyAllAdmins(message string) {
} }
postLog.Info(fmt.Sprintf("Notifying via %s: %s", ctrl.Name(), message)) postLog.Info(fmt.Sprintf("Notifying via %s: %s", ctrl.Name(), message))
} }
} }
+19 -8
View File
@@ -221,18 +221,29 @@ func (q *QQController) trustedGroupIDs() []string {
return nil return nil
} }
// SendMessage sends an arbitrary message (e.g. the bot initialization message) // SendMessage sends an automatic message (e.g. the bot initialization message
// to all QQ trusted groups and admins. // and the startup server list) to all QQ trusted groups and admins. Each
func (q *QQController) SendMessage(message string) error { // member's opt-out options in bot_user_config.json (e.g. EventBotStarted for
// event_bot_started messages) are honored per recipient.
func (q *QQController) SendMessage(message controller.Message) error {
if !q.cfg.Enabled { if !q.cfg.Enabled {
return nil return nil
} }
for _, groupID := range q.trustedGroupIDs() { // Only notify trusted groups and admins whose options allow this message type.
q.sendGroupMessage(groupID, message) if uc := config.C_botUserConfig; uc != nil {
} for groupID, opts := range uc.QQ.TrustedGroups {
for _, adminID := range q.adminIDs() { if !controller.MemberReceives(opts, message.Type) {
q.sendPrivateMessage(adminID, message) continue
}
q.sendGroupMessage(groupID, message.Content)
}
for adminID, opts := range uc.QQ.Admins {
if !controller.MemberReceives(opts, message.Type) {
continue
}
q.sendPrivateMessage(adminID, message.Content)
}
} }
return nil return nil
} }
+6 -4
View File
@@ -6,6 +6,7 @@ import (
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"nukumizu-backend/internal/controller"
) )
// maxMessageLen is the safe chunk size for outbound messages. Telegram's hard // maxMessageLen is the safe chunk size for outbound messages. Telegram's hard
@@ -18,15 +19,16 @@ const maxMessageLen = 4000
// rich text. Templates must stay valid under Telegram's legacy Markdown: // rich text. Templates must stay valid under Telegram's legacy Markdown:
// unpaired '*' or '_' characters (e.g. a lone '*Event: ...' label) make the // unpaired '*' or '_' characters (e.g. a lone '*Event: ...' label) make the
// API reject the whole message. // API reject the whole message.
func (t *TelegramController) sendMessage(chatID int64, text string) error { func (t *TelegramController) sendMessage(message controller.Message) error {
if t.client == nil { if t.client == nil {
return nil return nil
} }
if strings.TrimSpace(text) == "" { if strings.TrimSpace(message.Content) == "" {
return nil return nil
} }
for _, chunk := range splitMessage(text, maxMessageLen) {
if err := t.sendMessageChunk(chatID, chunk); err != nil { for _, chunk := range splitMessage(message.Content, maxMessageLen) {
if err := t.sendMessageChunk(message.ChatID, chunk); err != nil {
return err return err
} }
} }
+36 -14
View File
@@ -180,7 +180,14 @@ func (t *TelegramController) handleUpdate(_ context.Context, _ *bot.Bot, update
return return
} }
if err := t.sendMessage(msg.Chat.ID, response); err != nil { message := controller.Message{
Source: "telegram",
Content: response,
ChatID: msg.Chat.ID,
Type: controller.MessageTypeReply,
}
if err := t.sendMessage(message); err != nil {
postLog.Warning("Failed to send Telegram reply: " + err.Error()) postLog.Warning("Failed to send Telegram reply: " + err.Error())
} }
} }
@@ -229,13 +236,30 @@ func (t *TelegramController) processCommand(cmd controller.Command) string {
return response return response
} }
// SendMessage sends an arbitrary message (e.g. the bot initialization message) // SendMessage sends an automatic message (e.g. the bot initialization message
// to all Telegram trusted groups and admins. // and the startup server list) to all Telegram trusted groups and admins. Each
func (t *TelegramController) SendMessage(message string) error { // member's opt-out options in bot_user_config.json (e.g. EventBotStarted for
// event_bot_started messages) are honored per recipient.
func (t *TelegramController) SendMessage(message controller.Message) error {
if !t.cfg.Enabled || t.client == nil { if !t.cfg.Enabled || t.client == nil {
return nil return nil
} }
t.sendToGroupsAndAdmins(message)
// Only notify trusted groups and admins whose options allow this message type.
if uc := config.C_botUserConfig; uc != nil {
for groupID, opts := range uc.Telegram.TrustedGroups {
if !controller.MemberReceives(opts, message.Type) {
continue
}
t.sendGroupMessage(groupID, message.Content)
}
for admin, opts := range uc.Telegram.Admins {
if !controller.MemberReceives(opts, message.Type) {
continue
}
t.sendAdminMessage(admin, message.Content)
}
}
return nil return nil
} }
@@ -382,14 +406,6 @@ func (t *TelegramController) resolvedAdminList() []string {
return result return result
} }
// sendToGroupsAndAdmins sends a message to all trusted groups and admins.
func (t *TelegramController) sendToGroupsAndAdmins(message string) {
t.sendToGroups(message)
for _, admin := range t.adminIDs() {
t.sendAdminMessage(admin, message)
}
}
// sendToGroups sends a message to all trusted groups. // sendToGroups sends a message to all trusted groups.
func (t *TelegramController) sendToGroups(message string) { func (t *TelegramController) sendToGroups(message string) {
for _, groupID := range t.trustedGroupIDs() { for _, groupID := range t.trustedGroupIDs() {
@@ -426,7 +442,13 @@ func (t *TelegramController) sendAdminMessage(admin string, message string) {
// sendToChat sends a message to a chat ID, logging failures. // sendToChat sends a message to a chat ID, logging failures.
func (t *TelegramController) sendToChat(chatID int64, message string) { func (t *TelegramController) sendToChat(chatID int64, message string) {
if err := t.sendMessage(chatID, message); err != nil { messaged := controller.Message{
Source: "telegram",
Content: message,
ChatID: chatID,
Type: controller.MessageTypeReply,
}
if err := t.sendMessage(messaged); err != nil {
postLog.Warning(fmt.Sprintf("Failed to send Telegram message to %d: %v", chatID, err)) postLog.Warning(fmt.Sprintf("Failed to send Telegram message to %d: %v", chatID, err))
} }
} }
+10
View File
@@ -0,0 +1,10 @@
package controller
func Contains(slice []string, target string) bool {
for _, s := range slice {
if s == target {
return true
}
}
return false
}