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.
393 lines
12 KiB
Go
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))
|
|
}
|
|
}
|