Files
Nukumizu/internal/controller/pipes/qq_napcat/qq.go
T
NanamiAdmin 0691e85ccf
Build / ubuntu-latest (push) Failing after 3m13s
Build / windows-latest (push) Canceled after 6m23s
feat(controller): hot-reload the notification pipes
Email, ntfy and webhook kept their settings in a plain struct field copied at
construction, so editing controllerMethod in config.json wrote the file and
replaced the in-memory configuration while the running channels stayed on their
boot values.

Add Reload to the Controller interface and implement it for the three
notification pipes; Manager.ReloadAll fans an update out to every registered
controller and is wired into the reload hook from main.

Reload runs on the goroutine serving the settings update while the send methods
run on the status-change and incoming-webhook goroutines, so storing the new
settings in a plain field would be a data race. Each pipe publishes its settings
— and the HTTP client derived from them — through atomic pointers, and every
send takes one snapshot so a reload landing mid-send cannot split it across two
configurations.

Only a change to networkUseProxy rebuilds the client; everything else is read
from the settings at send time, so a reload that changes nothing relevant leaves
the client alone. Email needed one more fix: gomail exposes its dial hook as a
package-level variable with no unset, and the constructor only installed the
proxy dialer when the flag was set, so turning networkUseProxy off left SMTP
tunnelled through a proxy. applyEmailProxy now restores net.DialTimeout, which
is gomail's own default.

QQ (Napcat) and Telegram implement Reload because the interface requires it, but
neither can apply a change in place: the NapCat client is stopped through a
sync.Once and the Telegram polling context is created with the controller, so
both need to be rebuilt and swapped into the manager. Rather than no-op silently
and let an edit look applied, they log a warning while their settings diverge.
That rebuild is the next step.
2026-09-28 23:15:11 +08:00

393 lines
12 KiB
Go

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))
}
}