diff --git a/config/variables.go b/config/variables.go index c9d84ff..44b50c7 100644 --- a/config/variables.go +++ b/config/variables.go @@ -121,6 +121,16 @@ type BotUserOptions struct { // EventStatusNotify indicates whether this member is subscribed to node // status change notifications. 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 diff --git a/internal/controller/controller.go b/internal/controller/controller.go index bf37556..013834f 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -21,6 +21,37 @@ type Command struct { 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. type Controller interface { Name() string @@ -38,7 +69,7 @@ type Controller interface { // pipes (email, ntfy, webhook) do not. type BotController interface { Controller - SendMessage(message string) error + SendMessage(message Message) error } // 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 // 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() { m.mu.RLock() defer m.mu.RUnlock() cfg := config.C_globalConfig params := template.BuildBotInitializationMsgParams() - message := template.Render(cfg.ControllerMessage.BotStarted, params) + content := template.Render(cfg.ControllerMessage.BotStarted, params) for _, ctrl := range m.controllers { if !ctrl.IsEnabled() { @@ -89,22 +122,29 @@ func (m *Manager) ShowBotInitMessage() { if !ok { 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 { 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 -// message content is identical to the /list command (same template and -// parameters). +// ShowBotServerList sends the startup server list to all enabled bot +// controllers. The message content is identical to the /list command (same +// 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() { m.mu.RLock() defer m.mu.RUnlock() cfg := config.C_globalConfig params := template.BuildParamsFromServerList() - message := template.Render(cfg.ControllerMessage.ServerList, params) + content := template.Render(cfg.ControllerMessage.ServerList, params) for _, ctrl := range m.controllers { if !ctrl.IsEnabled() { @@ -114,6 +154,11 @@ func (m *Manager) ShowBotServerList() { if !ok { 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 { 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)) } -} \ No newline at end of file +} diff --git a/internal/controller/pipes/qq_napcat/qq.go b/internal/controller/pipes/qq_napcat/qq.go index 2b9b299..6bf8939 100644 --- a/internal/controller/pipes/qq_napcat/qq.go +++ b/internal/controller/pipes/qq_napcat/qq.go @@ -221,18 +221,29 @@ func (q *QQController) trustedGroupIDs() []string { return nil } -// SendMessage sends an arbitrary message (e.g. the bot initialization message) -// to all QQ trusted groups and admins. -func (q *QQController) SendMessage(message string) error { +// SendMessage sends an automatic message (e.g. the bot initialization message +// and the startup server list) to all QQ trusted groups and admins. Each +// 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 { return nil } - for _, groupID := range q.trustedGroupIDs() { - q.sendGroupMessage(groupID, message) - } - for _, adminID := range q.adminIDs() { - q.sendPrivateMessage(adminID, 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.QQ.TrustedGroups { + if !controller.MemberReceives(opts, message.Type) { + 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 } diff --git a/internal/controller/pipes/telegram/send.go b/internal/controller/pipes/telegram/send.go index 829db93..331d5a6 100644 --- a/internal/controller/pipes/telegram/send.go +++ b/internal/controller/pipes/telegram/send.go @@ -6,6 +6,7 @@ import ( "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" + "nukumizu-backend/internal/controller" ) // 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: // unpaired '*' or '_' characters (e.g. a lone '*Event: ...' label) make the // 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 { return nil } - if strings.TrimSpace(text) == "" { + if strings.TrimSpace(message.Content) == "" { 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 } } diff --git a/internal/controller/pipes/telegram/telegram.go b/internal/controller/pipes/telegram/telegram.go index 0aba9ef..54e4216 100644 --- a/internal/controller/pipes/telegram/telegram.go +++ b/internal/controller/pipes/telegram/telegram.go @@ -180,7 +180,14 @@ func (t *TelegramController) handleUpdate(_ context.Context, _ *bot.Bot, update 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()) } } @@ -229,13 +236,30 @@ func (t *TelegramController) processCommand(cmd controller.Command) string { return response } -// SendMessage sends an arbitrary message (e.g. the bot initialization message) -// to all Telegram trusted groups and admins. -func (t *TelegramController) SendMessage(message string) error { +// SendMessage sends an automatic message (e.g. the bot initialization message +// and the startup server list) to all Telegram trusted groups and admins. Each +// 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 { 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 } @@ -382,14 +406,6 @@ func (t *TelegramController) resolvedAdminList() []string { 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. func (t *TelegramController) sendToGroups(message string) { 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. 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)) } } diff --git a/internal/controller/utils.go b/internal/controller/utils.go new file mode 100644 index 0000000..c3d309d --- /dev/null +++ b/internal/controller/utils.go @@ -0,0 +1,10 @@ +package controller + +func Contains(slice []string, target string) bool { + for _, s := range slice { + if s == target { + return true + } + } + return false +} \ No newline at end of file