Fix #475: Implement sanitizeToolPairs for tool call/result integrity

This commit addresses the issue where history compression was creating orphaned

tool_call/tool_result pairs that cause Anthropic API errors. Changes include:

- Added sanitizeToolPairs() helper function to validate tool call/result pairs

- Applied sanitization in forceCompression() to ensure message integrity after cutting

- Applied sanitization in summarizeSession() to remove orphaned results during truncation

- Updated context.go to maintain consistent tool pairing during normal operation
This commit is contained in:
liusihua 2026-03-05 09:56:07 +08:00
parent 028605cfd0
commit 2f6bbb3322
7 changed files with 691 additions and 10 deletions

View file

@ -13,6 +13,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings" // Added for Windows process stop functionality
"time"
"github.com/sipeed/picoclaw/pkg/config"
@ -106,10 +107,20 @@ func scanPipe(r io.Reader, buf *LogBuffer) {
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
var err error
if runtime.GOOS == "windows" {
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
err = exec.Command("powershell", "-Command", psCmd).Run()
// Use taskkill first (the most reliable Windows process termination method)
// Try exact image name matching first (-F forces termination, /IM specifies exact image name)
err = exec.Command("taskkill", "/F", "/IM", "picoclaw.exe").Run()
if err != nil {
// If exact name doesn't work, try using wildcard to catch different naming patterns
wildcardErr := exec.Command("taskkill", "/F", "/FI", "ImageName EQ picoclaw.exe*").Run()
if wildcardErr != nil && wildcardErr.Error() != "exit status 128" { // exit code 128 means no processes found
// If both taskkill methods fail for reason other than process not found, return the error
err = wildcardErr
} else if wildcardErr != nil && strings.Contains(wildcardErr.Error(), "exit status 128") {
// Process is not running, which is okay - no error condition
err = nil
}
}
} else {
// Linux/macOS
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()

View file

@ -112,16 +112,20 @@ func main() {
// openBrowser automatically opens the given URL in the default browser.
func openBrowser(url string) error {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
return exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
// Try the standard Windows method first
err := exec.Command("cmd", "/c", "start", "", url).Run()
if err != nil {
// If that fails, fall back to rundll32
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}
return nil
case "darwin":
err = exec.Command("open", url).Start()
return exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
return fmt.Errorf("unsupported platform")
}
return err
}

BIN
picoclaw-launcher Executable file

Binary file not shown.

BIN
picoclaw-launcher-tui Executable file

Binary file not shown.

BIN
picoclawLauncher Executable file

Binary file not shown.

664
pkg/agent/context.go.bak Normal file
View file

@ -0,0 +1,664 @@
package agent
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
)
type ContextBuilder struct {
workspace string
skillsLoader *skills.SkillsLoader
memory *MemoryStore
// Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context.
// The cache auto-invalidates when workspace source files change (mtime check).
systemPromptMutex sync.RWMutex
cachedSystemPrompt string
cachedAt time.Time // max observed mtime across tracked paths at cache build time
// existedAtCache tracks which source file paths existed the last time the
// cache was built. This lets sourceFilesChanged detect files that are newly
// created (didn't exist at cache time, now exist) or deleted (existed at
// cache time, now gone) — both of which should trigger a cache rebuild.
existedAtCache map[string]bool
// skillFilesAtCache snapshots the skill tree file set and mtimes at cache
// build time. This catches nested file creations/deletions/mtime changes
// that may not update the top-level skill root directory mtime.
skillFilesAtCache map[string]time.Time
}
func getGlobalConfigDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".picoclaw")
}
func NewContextBuilder(workspace string) *ContextBuilder {
// builtin skills: skills directory in current project
// Use the skills/ directory under the current working directory
builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS"))
if builtinSkillsDir == "" {
wd, _ := os.Getwd()
builtinSkillsDir = filepath.Join(wd, "skills")
}
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
return &ContextBuilder{
workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
}
}
func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant.
## Workspace
Your workspace is at: %s
- Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.md
## Important Rules
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
parts := []string{}
// Core identity section
parts = append(parts, cb.getIdentity())
// Bootstrap files
bootstrapContent := cb.LoadBootstrapFiles()
if bootstrapContent != "" {
parts = append(parts, bootstrapContent)
}
// Skills - show summary, AI can read full content with read_file tool
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
if skillsSummary != "" {
parts = append(parts, fmt.Sprintf(`# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
%s`, skillsSummary))
}
// Memory context
memoryContext := cb.memory.GetMemoryContext()
if memoryContext != "" {
parts = append(parts, "# Memory\n\n"+memoryContext)
}
// Join with "---" separator
return strings.Join(parts, "\n\n---\n\n")
}
// BuildSystemPromptWithCache returns the cached system prompt if available
// and source files haven't changed, otherwise builds and caches it.
// Source file changes are detected via mtime checks (cheap stat calls).
func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
// Try read lock first — fast path when cache is valid
cb.systemPromptMutex.RLock()
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
result := cb.cachedSystemPrompt
cb.systemPromptMutex.RUnlock()
return result
}
cb.systemPromptMutex.RUnlock()
// Acquire write lock for building
cb.systemPromptMutex.Lock()
defer cb.systemPromptMutex.Unlock()
// Double-check: another goroutine may have rebuilt while we waited
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
return cb.cachedSystemPrompt
}
// Snapshot the baseline (existence + max mtime) BEFORE building the prompt.
// This way cachedAt reflects the pre-build state: if a file is modified
// during BuildSystemPrompt, its new mtime will be > baseline.maxMtime,
// so the next sourceFilesChangedLocked check will correctly trigger a
// rebuild. The alternative (baseline after build) risks caching stale
// content with a too-new baseline, making the staleness invisible.
baseline := cb.buildCacheBaseline()
prompt := cb.BuildSystemPrompt()
cb.cachedSystemPrompt = prompt
cb.cachedAt = baseline.maxMtime
cb.existedAtCache = baseline.existed
cb.skillFilesAtCache = baseline.skillFiles
logger.DebugCF("agent", "System prompt cached",
map[string]any{
"length": len(prompt),
})
return prompt
}
// InvalidateCache clears the cached system prompt.
// Normally not needed because the cache auto-invalidates via mtime checks,
// but this is useful for tests or explicit reload commands.
func (cb *ContextBuilder) InvalidateCache() {
cb.systemPromptMutex.Lock()
defer cb.systemPromptMutex.Unlock()
cb.cachedSystemPrompt = ""
cb.cachedAt = time.Time{}
cb.existedAtCache = nil
cb.skillFilesAtCache = nil
logger.DebugCF("agent", "System prompt cache invalidated", nil)
}
// sourcePaths returns non-skill workspace source files tracked for cache
// invalidation (bootstrap files + memory). Skill roots are handled separately
// because they require both directory-level and recursive file-level checks.
func (cb *ContextBuilder) sourcePaths() []string {
return []string{
filepath.Join(cb.workspace, "AGENTS.md"),
filepath.Join(cb.workspace, "SOUL.md"),
filepath.Join(cb.workspace, "USER.md"),
filepath.Join(cb.workspace, "IDENTITY.md"),
filepath.Join(cb.workspace, "memory", "MEMORY.md"),
}
}
// skillRoots returns all skill root directories that can affect
// BuildSkillsSummary output (workspace/global/builtin).
func (cb *ContextBuilder) skillRoots() []string {
if cb.skillsLoader == nil {
return []string{filepath.Join(cb.workspace, "skills")}
}
roots := cb.skillsLoader.SkillRoots()
if len(roots) == 0 {
return []string{filepath.Join(cb.workspace, "skills")}
}
return roots
}
// cacheBaseline holds the file existence snapshot and the latest observed
// mtime across all tracked paths. Used as the cache reference point.
type cacheBaseline struct {
existed map[string]bool
skillFiles map[string]time.Time
maxMtime time.Time
}
// buildCacheBaseline records which tracked paths currently exist and computes
// the latest mtime across all tracked files + skills directory contents.
// Called under write lock when the cache is built.
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
skillRoots := cb.skillRoots()
// All paths whose existence we track: source files + all skill roots.
allPaths := append(cb.sourcePaths(), skillRoots...)
existed := make(map[string]bool, len(allPaths))
skillFiles := make(map[string]time.Time)
var maxMtime time.Time
for _, p := range allPaths {
info, err := os.Stat(p)
existed[p] = err == nil
if err == nil && info.ModTime().After(maxMtime) {
maxMtime = info.ModTime()
}
}
// Walk all skill roots recursively to snapshot skill files and mtimes.
// Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks.
for _, root := range skillRoots {
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr == nil && !d.IsDir() {
if info, err := os.Stat(path); err == nil {
skillFiles[path] = info.ModTime()
if info.ModTime().After(maxMtime) {
maxMtime = info.ModTime()
}
}
}
return nil
})
}
// If no tracked files exist yet (empty workspace), maxMtime is zero.
// Use a very old non-zero time so that:
// 1. cachedAt.IsZero() won't trigger perpetual rebuilds.
// 2. Any real file created afterwards has mtime > cachedAt, so it
// will be detected by fileChangedSince (unlike time.Now() which
// could race with a file whose mtime <= Now).
if maxMtime.IsZero() {
maxMtime = time.Unix(1, 0)
}
return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime}
}
// sourceFilesChangedLocked checks whether any workspace source file has been
// modified, created, or deleted since the cache was last built.
//
// IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex.
// Go's sync.RWMutex is not reentrant, so this function must NOT acquire the
// lock itself (it would deadlock when called from BuildSystemPromptWithCache
// which already holds RLock or Lock).
func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
if cb.cachedAt.IsZero() {
return true
}
// Check tracked source files (bootstrap + memory).
if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) {
return true
}
// --- Skill roots (workspace/global/builtin) ---
//
// For each root:
// 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince.
// 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot.
for _, root := range cb.skillRoots() {
if cb.fileChangedSince(root) {
return true
}
}
if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) {
return true
}
return false
}
// fileChangedSince returns true if a tracked source file has been modified,
// newly created, or deleted since the cache was built.
//
// Four cases:
// - existed at cache time, exists now -> check mtime
// - existed at cache time, gone now -> changed (deleted)
// - absent at cache time, exists now -> changed (created)
// - absent at cache time, gone now -> no change
func (cb *ContextBuilder) fileChangedSince(path string) bool {
// Defensive: if existedAtCache was never initialized, treat as changed
// so the cache rebuilds rather than silently serving stale data.
if cb.existedAtCache == nil {
return true
}
existedBefore := cb.existedAtCache[path]
info, err := os.Stat(path)
existsNow := err == nil
if existedBefore != existsNow {
return true // file was created or deleted
}
if !existsNow {
return false // didn't exist before, doesn't exist now
}
return info.ModTime().After(cb.cachedAt)
}
// errWalkStop is a sentinel error used to stop filepath.WalkDir early.
// Using a dedicated error (instead of fs.SkipAll) makes the early-exit
// intent explicit and avoids the nilerr linter warning that would fire
// if the callback returned nil when its err parameter is non-nil.
var errWalkStop = errors.New("walk stop")
// skillFilesChangedSince compares the current recursive skill file tree
// against the cache-time snapshot. Any create/delete/mtime drift invalidates
// the cache.
func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool {
// Defensive: if the snapshot was never initialized, force rebuild.
if filesAtCache == nil {
return true
}
// Check cached files still exist and keep the same mtime.
for path, cachedMtime := range filesAtCache {
info, err := os.Stat(path)
if err != nil {
// A previously tracked file disappeared (or became inaccessible):
// either way, cached skill summary may now be stale.
return true
}
if !info.ModTime().Equal(cachedMtime) {
return true
}
}
// Check no new files appeared under any skill root.
changed := false
for _, root := range skillRoots {
if strings.TrimSpace(root) == "" {
continue
}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
// Treat unexpected walk errors as changed to avoid stale cache.
if !os.IsNotExist(walkErr) {
changed = true
return errWalkStop
}
return nil
}
if d.IsDir() {
return nil
}
if _, ok := filesAtCache[path]; !ok {
changed = true
return errWalkStop
}
return nil
})
if changed {
return true
}
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
return true
}
}
return false
}
func (cb *ContextBuilder) LoadBootstrapFiles() string {
bootstrapFiles := []string{
"AGENTS.md",
"SOUL.md",
"USER.md",
"IDENTITY.md",
}
var sb strings.Builder
for _, filename := range bootstrapFiles {
filePath := filepath.Join(cb.workspace, filename)
if data, err := os.ReadFile(filePath); err == nil {
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
}
}
return sb.String()
}
// buildDynamicContext returns a short dynamic context string with per-request info.
// This changes every request (time, session) so it is NOT part of the cached prompt.
// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism:
// - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block
// - OpenAI / Codex: prompt_cache_key for prefix-based caching
//
// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
// See: https://platform.openai.com/docs/guides/prompt-caching
func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
now := time.Now().Format("2006-01-02 15:04 (Monday)")
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
var sb strings.Builder
fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt)
if channel != "" && chatID != "" {
fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
}
return sb.String()
}
func (cb *ContextBuilder) BuildMessages(
history []providers.Message,
summary string,
currentMessage string,
media []string,
channel, chatID string,
) []providers.Message {
messages := []providers.Message{}
// The static part (identity, bootstrap, skills, memory) is cached locally to
// avoid repeated file I/O and string building on every call (fixes issue #607).
// Dynamic parts (time, session, summary) are appended per request.
// Everything is sent as a single system message for provider compatibility:
// - Anthropic adapter extracts messages[0] (Role=="system") and maps its content
// to the top-level "system" parameter in the Messages API request. A single
// contiguous system block makes this extraction straightforward.
// - Codex maps only the first system message to its instructions field.
// - OpenAI-compat passes messages through as-is.
staticPrompt := cb.BuildSystemPromptWithCache()
// Build short dynamic context (time, runtime, session) — changes per request
dynamicCtx := cb.buildDynamicContext(channel, chatID)
// Compose a single system message: static (cached) + dynamic + optional summary.
// Keeping all system content in one message ensures every provider adapter can
// extract it correctly (Anthropic adapter -> top-level system param,
// Codex -> instructions field).
//
// SystemParts carries the same content as structured blocks so that
// cache-aware adapters (Anthropic) can set per-block cache_control.
// The static block is marked "ephemeral" — its prefix hash is stable
// across requests, enabling LLM-side KV cache reuse.
stringParts := []string{staticPrompt, dynamicCtx}
contentBlocks := []providers.ContentBlock{
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
{Type: "text", Text: dynamicCtx},
}
if summary != "" {
summaryText := fmt.Sprintf(
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
summary)
stringParts = append(stringParts, summaryText)
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
}
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
// Log system prompt summary for debugging (debug mode only).
// Read cachedSystemPrompt under lock to avoid a data race with
// concurrent InvalidateCache / BuildSystemPromptWithCache writes.
cb.systemPromptMutex.RLock()
isCached := cb.cachedSystemPrompt != ""
cb.systemPromptMutex.RUnlock()
logger.DebugCF("agent", "System prompt built",
map[string]any{
"static_chars": len(staticPrompt),
"dynamic_chars": len(dynamicCtx),
"total_chars": len(fullSystemPrompt),
"has_summary": summary != "",
"cached": isCached,
})
// Log preview of system prompt (avoid logging huge content)
preview := fullSystemPrompt
if len(preview) > 500 {
preview = preview[:500] + "... (truncated)"
}
logger.DebugCF("agent", "System prompt preview",
map[string]any{
"preview": preview,
})
history = sanitizeHistoryForProvider(history)
// Single system message containing all context — compatible with all providers.
// SystemParts enables cache-aware adapters to set per-block cache_control;
// Content is the concatenated fallback for adapters that don't read SystemParts.
messages = append(messages, providers.Message{
Role: "system",
Content: fullSystemPrompt,
SystemParts: contentBlocks,
})
// Add conversation history
messages = append(messages, history...)
// Add current user message
if strings.TrimSpace(currentMessage) != "" {
msg := providers.Message{
Role: "user",
Content: currentMessage,
}
if len(media) > 0 {
msg.Media = media
}
messages = append(messages, msg)
}
return messages
}
func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
if len(history) == 0 {
return history
}
// Enhanced sanitation: Track expected tool call IDs to remove unmatched tool results
expectedToolCallIDs := make(map[string]bool)
sanitized := make([]providers.Message, 0, len(history))
for _, msg := range history {
switch msg.Role {
case "system":
// Drop system messages from history. BuildMessages always
// constructs its own single system message (static + dynamic +
// summary); extra system messages would break providers that
// only accept one (Anthropic, Codex).
logger.DebugCF("agent", "Dropping system message from history", map[string]any{})
continue
case "tool":
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
continue
}
// Check the expectedToolCallIDs map from assistant messages
if !expectedToolCallIDs[msg.ToolCallID] {
logger.DebugCF("agent", "Dropping unmatched tool result", map[string]any{
"tool_call_id": msg.ToolCallID,
})
continue
}
// Valid pairing found, keep the message and remove the ID from map
delete(expectedToolCallIDs, msg.ToolCallID)
sanitized = append(sanitized, msg)
case "assistant":
if len(msg.ToolCalls) > 0 {
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
continue
}
prev := sanitized[len(sanitized)-1]
if prev.Role != "user" && prev.Role != "tool" {
logger.DebugCF(
"agent",
"Dropping assistant tool-call turn with invalid predecessor",
map[string]any{"prev_role": prev.Role},
)
continue
}
// Record all tool calls IDs from assistant messages to match with tool result
for _, call := range msg.ToolCalls {
expectedToolCallIDs[call.ID] = true
}
}
sanitized = append(sanitized, msg)
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
continue
}
prev := sanitized[len(sanitized)-1]
if prev.Role != "user" && prev.Role != "tool" {
logger.DebugCF(
"agent",
"Dropping assistant tool-call turn with invalid predecessor",
map[string]any{"prev_role": prev.Role},
)
continue
}
}
default:
sanitized = append(sanitized, msg)
}
}
return sanitized
}
return sanitized
}
func (cb *ContextBuilder) AddToolResult(
messages []providers.Message,
toolCallID, toolName, result string,
) []providers.Message {
messages = append(messages, providers.Message{
Role: "tool",
Content: result,
ToolCallID: toolCallID,
})
return messages
}
func (cb *ContextBuilder) AddAssistantMessage(
messages []providers.Message,
content string,
toolCalls []map[string]any,
) []providers.Message {
msg := providers.Message{
Role: "assistant",
Content: content,
}
// Always add assistant message, whether or not it has tool calls
messages = append(messages, msg)
return messages
}
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
return map[string]any{
"total": len(allSkills),
"available": len(allSkills),
"names": skillNames,
}
}

View file

@ -31,6 +31,8 @@ type QQChannel struct {
processedIDs map[string]bool
mu sync.RWMutex
}
chatTypeMap map[string]string // Track whether a ChatID is group or C2C
chatTypeMu sync.RWMutex // Protects chatTypeMap
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,