From 84bf100a12958868e0d8215da6fea41ef4f487c3 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 09:24:20 +0800 Subject: [PATCH] example/qq: add typing indicator on private messages - setInputStatus helper wrapping NapCat set_input_status API - startTyping/stopTyping with goroutine-based typing manager (5s interval, 30s auto-timeout) - start typing on webhook private message receipt - stop typing when output channel sends a response - fix plg.json UTF-8 BOM that broke plugindev parse --- example/qq/plg.json | 2 +- example/qq/plugin.go | 64 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/example/qq/plg.json b/example/qq/plg.json index eaa6eaf..099042d 100644 --- a/example/qq/plg.json +++ b/example/qq/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "qq", "name_zh": "QQ消息", "name_en": "qq", diff --git a/example/qq/plugin.go b/example/qq/plugin.go index 18da5f7..14e82a7 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -102,6 +102,13 @@ type Plugin struct { agentfsDir string downloadMu sync.Mutex downloadTasks []*DownloadTask + typingMu sync.Mutex + typingMap map[int64]*typingState +} + +type typingState struct { + userID int64 + stopCh chan struct{} } func (p *Plugin) Name() string { return p.name } @@ -759,6 +766,10 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { } } + if evt.MessageType == "private" { + p.startTyping(evt.UserID) + } + if p.sdk != nil { p.sdk.InjectInterruptText(p.name, p.name, interrupt) } @@ -956,6 +967,10 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, return nil, fmt.Errorf("meta 中需要 group_id 或 user_id 字段。用 output_send__qq_help 查看格式说明") } + if userID > 0 { + p.stopTyping(userID) + } + switch rawType { case "text": text := p.sensitiveFilter(payload) @@ -2137,6 +2152,54 @@ func (p *Plugin) napcat(action string, params map[string]interface{}) (interface return raw, nil } +func (p *Plugin) setInputStatus(userID int64, eventType int) (interface{}, error) { + return p.napcat("set_input_status", map[string]interface{}{ + "user_id": userID, + "event_type": eventType, + }) +} + +func (p *Plugin) startTyping(userID int64) { + p.typingMu.Lock() + if _, ok := p.typingMap[userID]; ok { + p.typingMu.Unlock() + return + } + ts := &typingState{userID: userID, stopCh: make(chan struct{})} + p.typingMap[userID] = ts + p.typingMu.Unlock() + go p.typingLoop(ts) +} + +func (p *Plugin) stopTyping(userID int64) { + p.typingMu.Lock() + ts, ok := p.typingMap[userID] + if ok { + delete(p.typingMap, userID) + } + p.typingMu.Unlock() + if ok { + close(ts.stopCh) + } +} + +func (p *Plugin) typingLoop(ts *typingState) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + timeout := time.After(30 * time.Second) + for { + select { + case <-ticker.C: + p.setInputStatus(ts.userID, 1) + case <-ts.stopCh: + return + case <-timeout: + p.stopTyping(ts.userID) + return + } + } +} + // ======== Helpers ======== // rawString extracts a string from napcat's return type (json.RawMessage or string). @@ -2192,6 +2255,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e allowFrom: make(map[int64]struct{}), groupAllowFrom: make(map[int64]struct{}), downloadTasks: make([]*DownloadTask, 0), + typingMap: make(map[int64]*typingState), dmPolicy: "open", groupPolicy: "open", }, nil