refactor: qq output channel to payload/meta/type interface

- handleChannelOutput: read payload/meta/type instead of content JSON
- Route by type: text/voice/image/file
- buildOutputHelp: show new parameter format
- Remove fixJSON (no longer needed without JSON-within-JSON)
This commit is contained in:
root
2026-07-19 10:29:22 +08:00
parent 02997a0dca
commit bf08764c29

View File

@ -170,13 +170,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
// ---- 注册输出通道 ----
s.RegisterOutputChannel("qq", sdk.CapText|sdk.CapFile|sdk.CapImage|sdk.CapAudio,
`发送QQ群聊/私聊消息,支持文字语音。content JSON 格式:
`发送QQ群聊/私聊消息,支持文字语音、图片、文件。
meta JSON 格式:
{
"content": "消息正文(必填)",
"group_id": 123456, // 群号(与 user_id 二选一)
"user_id": 123456, // QQ号与 group_id 二选一)
"as_voice": false // 可选true 则将文本转为语音发送(使用 edge-tts
}`,
"reply_to": 12345 // 可选,回复指定消息 ID
}
type 枚举: text文字/ voice语音转文字后发送/ image图片URL/ file文件URL`,
p.handleChannelOutput)
// ---- 消息 ----
@ -841,85 +842,56 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
return result, nil
}
// fixJSON 尝试修复 LLM 生成的常见 JSON 格式错误:
// 未转义的双引号出现在字符串值内(如 "content":"他说"你好""
func fixJSON(raw string) string {
var test interface{}
if json.Unmarshal([]byte(raw), &test) == nil {
return raw
}
contentPrefix := `"content":"`
idx := strings.Index(raw, contentPrefix)
if idx < 0 {
return raw
}
start := idx + len(contentPrefix)
suffixPatterns := []string{`","user_id`, `","group_id`, `","as_voice`, `"}`}
bestEnd := -1
for _, suffix := range suffixPatterns {
if j := strings.Index(raw[start:], suffix); j >= 0 {
end := start + j
if bestEnd < 0 || end < bestEnd {
bestEnd = end
}
}
}
if bestEnd < 0 {
return raw
}
contentVal := raw[start:bestEnd]
var b strings.Builder
b.Grow(len(contentVal) + 4)
for i := 0; i < len(contentVal); i++ {
if contentVal[i] == '\\' && i+1 < len(contentVal) {
b.WriteByte(contentVal[i])
i++
b.WriteByte(contentVal[i])
continue
}
if contentVal[i] == '"' {
b.WriteByte('\\')
}
b.WriteByte(contentVal[i])
}
escaped := b.String()
return raw[:start] + escaped + raw[bestEnd:]
}
// handleChannelOutput — output_send(channel="qq") 的处理器
// content 参数为 JSON 字符串,格式:
// {"content":"消息正文","group_id":123}
// {"content":"消息正文","user_id":456,"as_voice":true}
// args 包含 payload, type, (可选 meta)
func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
log.Printf("[qq] handleChannelOutput content=%q type=%T len=%d", content, args["content"], len(content))
if content == "" {
return nil, fmt.Errorf("content 参数是必需的 JSON 字符串。请用 output_send__qq_help 查看格式说明")
payload, _ := args["payload"].(string)
rawType, _ := args["type"].(string)
meta, _ := args["meta"].(string)
log.Printf("[qq] handleChannelOutput payload=%q type=%s meta=%s", payload, rawType, meta)
if payload == "" || rawType == "" {
return nil, fmt.Errorf("payload 和 type 参数不能为空")
}
var msg struct {
Content string `json:"content"`
UserID int64 `json:"user_id,omitempty"`
GroupID int64 `json:"group_id,omitempty"`
AsVoice bool `json:"as_voice,omitempty"`
}
if err := json.Unmarshal([]byte(content), &msg); err != nil || msg.Content == "" {
fixed := fixJSON(content)
if err2 := json.Unmarshal([]byte(fixed), &msg); err2 != nil || msg.Content == "" {
help := p.buildOutputHelp()
return nil, fmt.Errorf("content 参数 JSON 格式错误(%v。检查 content 字段值内的双引号是否已用 \\ 转义,以及整个 JSON 是否合法。\n\n正确的格式示例\n%s", err, help)
// 解析 meta
var groupID, userID int64
var replyTo int64
if meta != "" {
var m struct {
GroupID int64 `json:"group_id"`
UserID int64 `json:"user_id"`
ReplyTo int64 `json:"reply_to"`
}
log.Printf("[qq] JSON auto-fixed by escaping quotes: %q -> %q", content, fixed)
if err := json.Unmarshal([]byte(meta), &m); err == nil {
groupID = m.GroupID
userID = m.UserID
replyTo = m.ReplyTo
}
}
if groupID == 0 && userID == 0 {
return nil, fmt.Errorf("meta 中需要 group_id 或 user_id 字段。用 output_send__qq_help 查看格式说明")
}
text := p.sensitiveFilter(msg.Content)
switch rawType {
case "text":
text := p.sensitiveFilter(payload)
msg := map[string]interface{}{"message": text}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
}
if replyTo > 0 {
msg["reply_to"] = replyTo
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
}
return p.napcat("send_private_msg", msg)
// 语音模式edge-tts 转语音后通过 NapCat 发送
if msg.AsVoice {
audioFile, err := p.ttsToFile(msg.Content)
case "voice", "audio":
text := p.sensitiveFilter(payload)
audioFile, err := p.ttsToFile(text)
if err != nil {
return nil, fmt.Errorf("语音生成失败: %w", err)
}
@ -935,34 +907,61 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
os.Remove(audioFile)
uri := fmt.Sprintf("file:///app/files/%s", filepath.Base(dest))
cqMsg := fmt.Sprintf("[CQ:record,file=%s]", uri)
if msg.GroupID != 0 {
return p.napcat("send_group_msg", map[string]interface{}{"group_id": msg.GroupID, "message": cqMsg})
msg := map[string]interface{}{"message": cqMsg}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
}
if msg.UserID != 0 {
return p.napcat("send_private_msg", map[string]interface{}{"user_id": msg.UserID, "message": cqMsg})
if groupID != 0 {
return p.napcat("send_group_msg", msg)
}
return nil, fmt.Errorf("JSON 中需要 group_id 或 user_id")
}
return p.napcat("send_private_msg", msg)
// 文字模式
if msg.GroupID != 0 {
return p.napcat("send_group_msg", map[string]interface{}{"group_id": msg.GroupID, "message": text})
case "image":
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
}
return p.napcat("send_private_msg", msg)
case "file":
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
}
return p.napcat("send_private_msg", msg)
default:
return nil, fmt.Errorf("不支持的 type: %s枚举值: text/voice/image/file", rawType)
}
if msg.UserID != 0 {
return p.napcat("send_private_msg", map[string]interface{}{"user_id": msg.UserID, "message": text})
}
return nil, fmt.Errorf("content JSON 中需要 group_id 或 user_id 字段。请用 output_send__qq_help 查看格式说明")
}
func (p *Plugin) buildOutputHelp() string {
return `群聊回复:{"content":"你的消息正文","group_id":123456789}
私聊回复:{"content":"你的消息正文","user_id":123456789}
语音发送:{"content":"你的消息正文","group_id":123456789,"as_voice":true}
return `【参数】
payload — 消息载荷。type=text时直接填文字type=voice时填文字自动转语音type=image/file时填URL
meta — JSON 元数据,必含 group_id群聊或 user_id私聊可选 reply_to
type — text / voice / image / file
注意content 字段值内部如果再出现双引号,必须用反斜杠转义!!!
错误的例子(双引号未转义):{"content":"他说"你好"","user_id":123}
正确的例子(双引号已转义):{"content":"他说\\"你好\\"","user_id":123}
另外常见错误:如果正文包含网址或代码,也可能出现未转义的双引号,同样需要转义。`
【示例】
群聊文字output_send__qq(payload="你好", meta="{\"group_id\":123456789}", type="text")
私聊语音output_send__qq(payload="你好", meta="{\"user_id\":123456789}", type="voice")
发送图片output_send__qq(payload="https://example.com/img.jpg", meta="{\"group_id\":123456789}", type="image")
【注意】
- type=text 时 payload 直接是文字,无需 JSON 包裹
- type=voice 时 payload 是文字内容,自动转语音发送
- type=image/file 时 payload 是 URL 或路径`
}
// ttsToFile 用 edge-tts 将文本转为音频文件,返回临时文件路径