package qq_napcat import ( "encoding/json" "fmt" "strings" "nukumizu-backend/config" "nukumizu-backend/internal/controller" "nukumizu-backend/internal/node" "nukumizu-backend/internal/template" "nukumizu-backend/postLog" ) // oneBotEvent mirrors a OneBot 11 event pushed over the NapCat WebSocket. // Only "message" events are handled; notice/request/meta_event are ignored. type oneBotEvent struct { PostType string `json:"post_type"` // Identifies "message", "notice", "request", or "meta_event" MessageType string `json:"message_type"` // Identifies "private" or "group" GroupID int64 `json:"group_id"` // Only present for group messages UserID int64 `json:"user_id"` // The sender's QQ ID RawMessage string `json:"raw_message"` // The raw message text Message json.RawMessage `json:"message"` // The message content, which may include CQ codes Sender struct { // The sender's information UserID int64 `json:"user_id"` // The sender's QQ ID Nickname string `json:"nickname"` // The sender's nickname } `json:"sender"` SelfID int64 `json:"self_id"` // The bot's QQ ID SubType string `json:"sub_type"` // The subtype of the message, e.g., "normal", "anonymous", etc. } // QQController handles QQ Bot interactions by connecting directly to NapCat. type QQController struct { cfg config.QQConfig napcatClient *Client } // NewQQController creates a new QQ (Napcat) controller. func NewQQController(cfg config.QQConfig) *QQController { q := &QQController{cfg: cfg} if cfg.Enabled { q.napcatClient = NewClient(cfg.NapcatAddr, cfg.NapcatPort, cfg.NapcatToken, cfg.NetworkUseProxy) } return q } // Name returns the controller name. func (q *QQController) Name() string { return "qq(napcat)" } // Start initializes the QQ controller and starts the NapCat WebSocket listener. func (q *QQController) Start() error { if !q.cfg.Enabled { // postLog.Info("QQ (Napcat) controller is disabled") return nil } if q.napcatClient == nil { postLog.Warning("QQ (Napcat) controller enabled but NapCat client is nil") return nil } postLog.Info("QQ (Napcat) controller started, connecting to NapCat WebSocket...") go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Napcat WS listener panic recovered: %v", r)) } }() q.napcatClient.Listen(q.handleNapcatEvent) }() return nil } // Stop shuts down the QQ controller and its WebSocket listener. func (q *QQController) Stop() { if q.napcatClient != nil { q.napcatClient.Stop() } postLog.Info("QQ (Napcat) controller stopped") } // IsEnabled returns whether the controller is enabled. func (q *QQController) IsEnabled() bool { return q.cfg.Enabled } // Reload is not implemented yet for this channel. // // Everything this controller uses — the NapCat address, port and token — is // captured in the client it connected with, and that client's WebSocket // listener is stopped through a sync.Once and cannot be re-pointed. Applying a // change therefore means stopping the client and building a replacement // controller, which lands together with the Telegram one. Until then the // divergence is reported rather than ignored, so an edit that needs a restart // does not look like it took effect. func (q *QQController) Reload() { global := config.Current() if global == nil || q.cfg == global.ControllerMethod.QQ { return } postLog.Warning("QQ (Napcat) settings changed but hot reload is not implemented for this channel yet; restart to apply them") } // IsMarkdown returns whether the channel renders Markdown, per its markdown // setting in config.json. func (q *QQController) IsMarkdown() bool { return q.cfg.Markdown } // handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket. func (q *QQController) handleNapcatEvent(raw []byte) { var ev oneBotEvent if err := json.Unmarshal(raw, &ev); err != nil { postLog.Debug("Failed to parse NapCat WS event: " + err.Error()) return } // The configuration is read once per event: the debug guards below are // evaluated several times, and taking them all from one version keeps a // concurrent reload from splitting them mid-event. cfg := config.Current() if cfg == nil { return } debugMode := cfg.System.DebugMode showAction := debugMode && cfg.Debug.ShowNapcatAction if debugMode && cfg.Debug.ShowNapcatMsg { postLog.Debug("Napcat WS event received: " + string(raw)) } // Only handle message events; ignore notice/request/meta_event. if ev.PostType != "message" { if showAction { postLog.Debug("Ignoring Napcat WS event: " + string(raw)) } return } // Ignore messages the bot itself sent (echo prevention). if q.isSelfMessage(ev) && !debugMode { return } if q.isSelfMessage(ev) && debugMode && cfg.Debug.NapcatIgnoreSelfMsg { if showAction { postLog.Debug("Ignoring Napcat WS self message: " + string(raw)) } return } // Determine chat information. chatID := ev.GroupID if ev.MessageType == "private" { chatID = ev.UserID } cmd := controller.Command{ RawText: ev.RawMessage, ChatID: chatID, ChatType: ev.MessageType, SenderID: ev.UserID, } response := q.processCommand(cmd) if response == "" { if showAction { postLog.Debug("Napcat WS command discarded: " + string(raw)) } return } // Reply in the same chat. if ev.MessageType == "private" { if _, err := q.napcatClient.SendMsg("private", ev.UserID, response, false, 0); err != nil { postLog.Warning("Failed to send NapCat private reply: " + err.Error()) } } else { if _, err := q.napcatClient.SendMsg("group", ev.GroupID, response, false, 0); err != nil { postLog.Warning("Failed to send NapCat group reply: " + err.Error()) } } } // processCommand validates an incoming message as a command and hands the // complete command to the unified processor. It returns the response text to // reply with; an empty response means the message was discarded. func (q *QQController) processCommand(cmd controller.Command) string { cfg := config.Current() text := cmd.RawText // In "at" listen mode, require an @mention of the bot and strip it before // parsing, otherwise raw_message like "[CQ:at,qq=123] /list" fails the // "/" prefix check. if q.cfg.ListenMethod == "at" { atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID) if !strings.Contains(text, atMention) { if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowNapcatAction { postLog.Debug("Napcat WS message ignored (no @mention): " + text) } return "" // Not mentioned, ignore. } text = strings.ReplaceAll(text, atMention, "") } // First check whether the received message is a command. parsed, ok := controller.ParseCommand(text) if !ok { return "" // Not a command, discard. } parsed.ChatID = cmd.ChatID parsed.ChatType = cmd.ChatType parsed.SenderID = cmd.SenderID parsed.Source = q.Name() // Hand the complete command to the unified processor, which checks group // vs private, trusted groups, admin permissions, and executes it. response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod) if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho { postLog.Debug(fmt.Sprintf("[qq_napcat] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID)) } if err != nil { postLog.Error("Napcat command processing failed: " + err.Error()) return "" } return response } // isSelfMessage returns true when the event was generated by the bot itself. func (q *QQController) isSelfMessage(ev oneBotEvent) bool { if q.cfg.BotQQID > 0 && ev.SelfID == q.cfg.BotQQID { return true } if ev.UserID == ev.SelfID { return true } return false } // adminIDs returns the QQ admin IDs from bot_user_config.json. func (q *QQController) adminIDs() []string { if c := config.BotUsers(); c != nil { return c.QQ.Admins.IDs() } return nil } // trustedGroupIDs returns the QQ trusted group IDs from bot_user_config.json. func (q *QQController) trustedGroupIDs() []string { if c := config.BotUsers(); c != nil { return c.QQ.TrustedGroups.IDs() } return nil } // 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 } // Only notify trusted groups and admins whose options allow this message type. if uc := config.BotUsers(); 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 } // SendStatusChange sends a status change notification via QQ. func (q *QQController) SendStatusChange(change node.StatusChange) error { if !q.cfg.Enabled { return nil } cfg := config.Current() params := template.BuildParamsFromStatusChange(change) message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, q.cfg.Markdown) // Only notify trusted groups and admins whose event_status_notify is true. if uc := config.BotUsers(); uc != nil { for groupID, opts := range uc.QQ.TrustedGroups { if !opts.EventStatusNotify { continue } q.sendGroupMessage(groupID, message) } for adminID, opts := range uc.QQ.Admins { if !opts.EventStatusNotify { continue } q.sendPrivateMessage(adminID, message) } } return nil } // SendServerList sends the server list via QQ. func (q *QQController) SendServerList(onlineServers, offlineServers string) error { if !q.cfg.Enabled { return nil } cfg := config.Current() params := template.BuildParamsFromServerList() message := template.Render(cfg.ControllerMessage.ServerList, params, q.cfg.Markdown) for _, groupID := range q.trustedGroupIDs() { q.sendGroupMessage(groupID, message) } return nil } // SendExecuteResult sends a command execution result via QQ. func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result string) error { if !q.cfg.Enabled { return nil } cfg := config.Current() params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result) message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, q.cfg.Markdown) for _, groupID := range q.trustedGroupIDs() { q.sendGroupMessage(groupID, message) } return nil } // SendAlert sends an alert submitted through the incoming webhook API to all QQ // trusted groups and admins. func (q *QQController) SendAlert(alert controller.Alert) error { if !q.cfg.Enabled { return nil } return q.SendMessage(controller.Message{ Source: q.Name(), Content: alert.Render(q.cfg.Markdown), Type: controller.MessageTypeAlert, }) } func (q *QQController) sendGroupMessage(groupID string, message string) { if q.napcatClient == nil { postLog.Warning("Cannot send QQ group message: NapCat client not initialized") return } var groupIDInt int64 if _, err := fmt.Sscanf(groupID, "%d", &groupIDInt); err != nil || groupIDInt == 0 { postLog.Warning(fmt.Sprintf("Invalid QQ group ID: %s", groupID)) return } if _, err := q.napcatClient.SendMsg("group", groupIDInt, message, false, 0); err != nil { postLog.Warning(fmt.Sprintf("Failed to send QQ group message to %s: %v", groupID, err)) } } func (q *QQController) sendPrivateMessage(userID string, message string) { if q.napcatClient == nil { postLog.Warning("Cannot send QQ private message: NapCat client not initialized") return } var userIDInt int64 if _, err := fmt.Sscanf(userID, "%d", &userIDInt); err != nil || userIDInt == 0 { postLog.Warning(fmt.Sprintf("Invalid QQ user ID: %s", userID)) return } if _, err := q.napcatClient.SendMsg("private", userIDInt, message, false, 0); err != nil { postLog.Warning(fmt.Sprintf("Failed to send QQ private message to %s: %v", userID, err)) } }