feat(claudecode): add session management and persistence support
Add session listing capability via Agent interface Implement session persistence in SessionManager Improve Feishu markdown formatting support Add verbose logging and error handling Clean up session switching logic Support session resume via --resume flag Add agent session metadata scanning generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
615710a528
commit
92164b38fb
7 files changed
+438
-85
No files matched your search
@@ -1,5 +1,6 @@
|
||||
cc-connect
|
||||
config.toml
|
||||
.cc-connect/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
|
||||
@@ -2,11 +2,15 @@ package claudecode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
@@ -61,9 +65,9 @@ func New(opts map[string]any) (core.Agent, error) {
|
||||
func (a *Agent) Name() string { return "claudecode" }
|
||||
|
||||
func (a *Agent) Execute(ctx context.Context, sessionID string, prompt string) (<-chan core.Event, error) {
|
||||
args := []string{"-p", prompt, "--output-format", "stream-json"}
|
||||
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose"}
|
||||
if sessionID != "" {
|
||||
args = append(args, "--session-id", sessionID)
|
||||
args = append(args, "--resume", sessionID)
|
||||
}
|
||||
if a.model != "" {
|
||||
args = append(args, "--model", a.model)
|
||||
@@ -78,6 +82,8 @@ func (a *Agent) Execute(ctx context.Context, sessionID string, prompt string) (<
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("claudecode: executing", "args", args, "dir", a.workDir)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "claude", args...)
|
||||
cmd.Dir = a.workDir
|
||||
|
||||
@@ -86,6 +92,9 @@ func (a *Agent) Execute(ctx context.Context, sessionID string, prompt string) (<
|
||||
return nil, fmt.Errorf("claudecode: stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("claudecode: start: %w", err)
|
||||
}
|
||||
@@ -96,7 +105,11 @@ func (a *Agent) Execute(ctx context.Context, sessionID string, prompt string) (<
|
||||
defer close(ch)
|
||||
defer func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
slog.Debug("claudecode: process exited", "error", err)
|
||||
stderrMsg := strings.TrimSpace(stderrBuf.String())
|
||||
slog.Error("claudecode: process failed", "error", err, "stderr", stderrMsg)
|
||||
if stderrMsg != "" {
|
||||
ch <- core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -169,6 +182,97 @@ func (a *Agent) Execute(ctx context.Context, sessionID string, prompt string) (<
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claudecode: cannot determine home dir: %w", err)
|
||||
}
|
||||
|
||||
absWorkDir, err := filepath.Abs(a.workDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claudecode: resolve work_dir: %w", err)
|
||||
}
|
||||
|
||||
projectKey := strings.ReplaceAll(absWorkDir, string(filepath.Separator), "-")
|
||||
projectDir := filepath.Join(homeDir, ".claude", "projects", projectKey)
|
||||
|
||||
entries, err := os.ReadDir(projectDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("claudecode: read project dir: %w", err)
|
||||
}
|
||||
|
||||
var sessions []core.AgentSessionInfo
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
|
||||
sessionID := strings.TrimSuffix(name, ".jsonl")
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
summary, msgCount := scanSessionMeta(filepath.Join(projectDir, name))
|
||||
|
||||
sessions = append(sessions, core.AgentSessionInfo{
|
||||
ID: sessionID,
|
||||
Summary: summary,
|
||||
MessageCount: msgCount,
|
||||
ModifiedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
|
||||
})
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// scanSessionMeta reads a session JSONL and returns (firstUserMessage, messageCount).
|
||||
// Only counts "user" and "assistant" type entries as messages.
|
||||
func scanSessionMeta(path string) (string, int) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
|
||||
var summary string
|
||||
var count int
|
||||
|
||||
for scanner.Scan() {
|
||||
var entry struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry.Type == "user" || entry.Type == "assistant" {
|
||||
count++
|
||||
if summary == "" && entry.Type == "user" && entry.Message.Content != "" {
|
||||
s := entry.Message.Content
|
||||
if len(s) > 40 {
|
||||
s = s[:40] + "..."
|
||||
}
|
||||
summary = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary, count
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() error { return nil }
|
||||
|
||||
// strOr returns the first non-empty string value found for the given keys.
|
||||
|
||||
+132
-49
@@ -20,13 +20,13 @@ type Engine struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewEngine(name string, ag Agent, platforms []Platform) *Engine {
|
||||
func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath string) *Engine {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Engine{
|
||||
name: name,
|
||||
agent: ag,
|
||||
platforms: platforms,
|
||||
sessions: NewSessionManager(),
|
||||
sessions: NewSessionManager(sessionStorePath),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
@@ -127,48 +127,93 @@ func (e *Engine) cmdNew(p Platform, msg *Message, args []string) {
|
||||
}
|
||||
|
||||
func (e *Engine) cmdList(p Platform, msg *Message) {
|
||||
sessions := e.sessions.ListSessions(msg.SessionKey)
|
||||
if len(sessions) == 0 {
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, "No sessions yet. Send a message to start one.")
|
||||
agentSessions, err := e.agent.ListSessions(e.ctx)
|
||||
if err != nil {
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ Failed to list sessions: %v", err))
|
||||
return
|
||||
}
|
||||
if len(agentSessions) == 0 {
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, "No Claude Code sessions found for this project.")
|
||||
return
|
||||
}
|
||||
|
||||
activeID := e.sessions.ActiveSessionID(msg.SessionKey)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📋 Sessions:\n\n")
|
||||
for _, s := range sessions {
|
||||
marker := " "
|
||||
if s.ID == activeID {
|
||||
marker = "▶ "
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s%s | %s | %d msgs | updated %s\n",
|
||||
marker, s.ID, s.Name, len(s.History), s.UpdatedAt.Format("01-02 15:04")))
|
||||
activeSession := e.sessions.GetOrCreateActive(msg.SessionKey)
|
||||
activeAgentID := activeSession.AgentSessionID
|
||||
|
||||
limit := 20
|
||||
if len(agentSessions) < limit {
|
||||
limit = len(agentSessions)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("**Claude Code Sessions** (%d)\n\n", len(agentSessions)))
|
||||
for i := 0; i < limit; i++ {
|
||||
s := agentSessions[i]
|
||||
marker := "◻"
|
||||
if s.ID == activeAgentID {
|
||||
marker = "▶"
|
||||
}
|
||||
shortID := s.ID
|
||||
if len(shortID) > 8 {
|
||||
shortID = shortID[:8]
|
||||
}
|
||||
summary := s.Summary
|
||||
if summary == "" {
|
||||
summary = "(empty)"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s `%s` · %s · **%d** msgs · %s\n",
|
||||
marker, shortID, summary, s.MessageCount, s.ModifiedAt.Format("01-02 15:04")))
|
||||
}
|
||||
if len(agentSessions) > limit {
|
||||
sb.WriteString(fmt.Sprintf("\n... and %d more\n", len(agentSessions)-limit))
|
||||
}
|
||||
sb.WriteString("\n`/switch <id>` to switch session")
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, sb.String())
|
||||
}
|
||||
|
||||
func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) {
|
||||
if len(args) == 0 {
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, "Usage: /switch <session_id or name>")
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, "Usage: /switch <session_id_prefix>")
|
||||
return
|
||||
}
|
||||
target := strings.Join(args, " ")
|
||||
s, err := e.sessions.SwitchSession(msg.SessionKey, target)
|
||||
prefix := strings.TrimSpace(args[0])
|
||||
|
||||
agentSessions, err := e.agent.ListSessions(e.ctx)
|
||||
if err != nil {
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var matched *AgentSessionInfo
|
||||
for i := range agentSessions {
|
||||
if strings.HasPrefix(agentSessions[i].ID, prefix) {
|
||||
matched = &agentSessions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ No session matching prefix %q", prefix))
|
||||
return
|
||||
}
|
||||
|
||||
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
||||
session.AgentSessionID = matched.ID
|
||||
session.Name = matched.Summary
|
||||
e.sessions.Save()
|
||||
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx,
|
||||
fmt.Sprintf("✅ Switched to: %s (id: %s, %d msgs)", s.Name, s.ID, len(s.History)))
|
||||
fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", matched.Summary, matched.ID[:8], matched.MessageCount))
|
||||
}
|
||||
|
||||
func (e *Engine) cmdCurrent(p Platform, msg *Message) {
|
||||
s := e.sessions.GetOrCreateActive(msg.SessionKey)
|
||||
agentID := s.AgentSessionID
|
||||
if agentID == "" {
|
||||
agentID = "(new — not yet started)"
|
||||
}
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf(
|
||||
"📌 Current session: %s (id: %s)\nMessages: %d\nCreated: %s\nUpdated: %s",
|
||||
s.Name, s.ID, len(s.History),
|
||||
s.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
s.UpdatedAt.Format("2006-01-02 15:04:05")))
|
||||
"📌 Current session\nName: %s\nClaude Session: %s\nLocal messages: %d",
|
||||
s.Name, agentID, len(s.History)))
|
||||
}
|
||||
|
||||
func (e *Engine) cmdHistory(p Platform, msg *Message, args []string) {
|
||||
@@ -202,12 +247,12 @@ func (e *Engine) cmdHistory(p Platform, msg *Message, args []string) {
|
||||
}
|
||||
|
||||
func (e *Engine) cmdHelp(p Platform, msg *Message) {
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, `/new [name] — Create a new session
|
||||
/list — List all sessions
|
||||
/switch <id|name> — Switch to a session
|
||||
/current — Show current session info
|
||||
/history [n] — Show last n messages (default 10)
|
||||
/help — Show this help`)
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, `/new [name] — Start a brand-new Claude session
|
||||
/list — List Claude Code sessions for this project
|
||||
/switch <id_prefix> — Resume an existing Claude session
|
||||
/current — Show current active session
|
||||
/history [n] — Show last n messages (default 10)
|
||||
/help — Show this help`)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -217,21 +262,67 @@ func (e *Engine) cmdHelp(p Platform, msg *Message) {
|
||||
func (e *Engine) processMessage(p Platform, msg *Message, session *Session) {
|
||||
session.AddHistory("user", msg.Content)
|
||||
|
||||
responseCh, err := e.agent.Execute(e.ctx, session.AgentSessionID, msg.Content)
|
||||
fullResponse, toolSummary, err := e.runAgent(session, msg.Content)
|
||||
|
||||
if err != nil && strings.Contains(err.Error(), "already in use") {
|
||||
oldID := session.AgentSessionID
|
||||
session.AgentSessionID = ""
|
||||
slog.Warn("session in use, starting new session", "old_session", oldID)
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, "⚠️ Previous Claude session is locked, starting new session...")
|
||||
fullResponse, toolSummary, err = e.runAgent(session, msg.Content)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("agent execution failed", "error", err)
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ Execution failed: %v", err))
|
||||
slog.Error("agent event error", "error", err)
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ Error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if fullResponse == "" {
|
||||
fullResponse = "(empty response)"
|
||||
}
|
||||
|
||||
if len(toolSummary) > 0 {
|
||||
fullResponse += "\n\n---\n📎 Tools used:\n" + strings.Join(toolSummary, "\n")
|
||||
}
|
||||
|
||||
session.AddHistory("assistant", fullResponse)
|
||||
|
||||
slog.Debug("agent response",
|
||||
"session", session.ID,
|
||||
"agent_session", session.AgentSessionID,
|
||||
"tools", len(toolSummary),
|
||||
"response_len", len(fullResponse),
|
||||
"response_preview", truncate(fullResponse, 200),
|
||||
)
|
||||
|
||||
for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) {
|
||||
if err := p.Reply(e.ctx, msg.ReplyCtx, chunk); err != nil {
|
||||
slog.Error("failed to send reply", "platform", p.Name(), "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
e.sessions.Save()
|
||||
}
|
||||
|
||||
// runAgent executes the agent and collects the full response.
|
||||
// Returns (response, toolSummary, error). On the first fatal event error it
|
||||
// drains remaining events and returns the error.
|
||||
func (e *Engine) runAgent(session *Session, prompt string) (string, []string, error) {
|
||||
responseCh, err := e.agent.Execute(e.ctx, session.AgentSessionID, prompt)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var fullResponse string
|
||||
var toolSummary []string
|
||||
|
||||
for ev := range responseCh {
|
||||
if ev.Error != nil {
|
||||
slog.Error("agent event error", "error", ev.Error)
|
||||
_ = p.Reply(e.ctx, msg.ReplyCtx, fmt.Sprintf("❌ Error: %v", ev.Error))
|
||||
return
|
||||
for range responseCh { // drain
|
||||
}
|
||||
return "", nil, ev.Error
|
||||
}
|
||||
|
||||
switch ev.Type {
|
||||
@@ -252,22 +343,14 @@ func (e *Engine) processMessage(p Platform, msg *Message, session *Session) {
|
||||
}
|
||||
}
|
||||
|
||||
if fullResponse == "" {
|
||||
fullResponse = "(empty response)"
|
||||
}
|
||||
return fullResponse, toolSummary, nil
|
||||
}
|
||||
|
||||
if len(toolSummary) > 0 {
|
||||
fullResponse += "\n\n---\n📎 Tools used:\n" + strings.Join(toolSummary, "\n")
|
||||
}
|
||||
|
||||
session.AddHistory("assistant", fullResponse)
|
||||
|
||||
for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) {
|
||||
if err := p.Reply(e.ctx, msg.ReplyCtx, chunk); err != nil {
|
||||
slog.Error("failed to send reply", "platform", p.Name(), "error", err)
|
||||
return
|
||||
}
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
func splitMessage(text string, maxLen int) []string {
|
||||
|
||||
+2
-3
@@ -14,11 +14,10 @@ type Platform interface {
|
||||
type MessageHandler func(p Platform, msg *Message)
|
||||
|
||||
// Agent abstracts an AI coding assistant (Claude Code, Cursor, Gemini CLI, etc.).
|
||||
// Execute sends a prompt and returns a channel that streams Event values.
|
||||
// The channel may emit tool_use events (showing what the agent did), text events,
|
||||
// and a final result event.
|
||||
type Agent interface {
|
||||
Name() string
|
||||
Execute(ctx context.Context, sessionID string, prompt string) (<-chan Event, error)
|
||||
// ListSessions returns sessions known to the agent backend (e.g. from Claude Code's session index).
|
||||
ListSessions(ctx context.Context) ([]AgentSessionInfo, error)
|
||||
Stop() error
|
||||
}
|
||||
+12
-3
@@ -35,7 +35,16 @@ type Event struct {
|
||||
|
||||
// HistoryEntry is one turn in a conversation.
|
||||
type HistoryEntry struct {
|
||||
Role string // "user" or "assistant"
|
||||
Content string
|
||||
Timestamp time.Time
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// AgentSessionInfo describes one session as reported by the agent backend.
|
||||
type AgentSessionInfo struct {
|
||||
ID string
|
||||
Summary string
|
||||
MessageCount int
|
||||
ModifiedAt time.Time
|
||||
GitBranch string
|
||||
}
|
||||
+102
-24
@@ -1,23 +1,26 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session tracks one conversation between a user and the agent.
|
||||
// A single user can own multiple sessions and switch between them.
|
||||
type Session struct {
|
||||
ID string
|
||||
Name string
|
||||
AgentSessionID string // opaque ID managed by the agent (e.g. Claude Code --session-id)
|
||||
History []HistoryEntry
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AgentSessionID string `json:"agent_session_id"`
|
||||
History []HistoryEntry `json:"history"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
mu sync.Mutex `json:"-"`
|
||||
busy bool `json:"-"`
|
||||
}
|
||||
|
||||
func (s *Session) TryLock() bool {
|
||||
@@ -60,21 +63,36 @@ func (s *Session) GetHistory(n int) []HistoryEntry {
|
||||
return out
|
||||
}
|
||||
|
||||
// SessionManager supports multiple named sessions per user with active-session tracking.
|
||||
type SessionManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session // sessionID → Session
|
||||
activeSession map[string]string // userKey → active sessionID
|
||||
userSessions map[string][]string // userKey → ordered list of sessionIDs
|
||||
counter int64
|
||||
// sessionSnapshot is the JSON-serializable state of the SessionManager.
|
||||
type sessionSnapshot struct {
|
||||
Sessions map[string]*Session `json:"sessions"`
|
||||
ActiveSession map[string]string `json:"active_session"`
|
||||
UserSessions map[string][]string `json:"user_sessions"`
|
||||
Counter int64 `json:"counter"`
|
||||
}
|
||||
|
||||
func NewSessionManager() *SessionManager {
|
||||
return &SessionManager{
|
||||
// SessionManager supports multiple named sessions per user with active-session tracking.
|
||||
// It can persist state to a JSON file and reload on startup.
|
||||
type SessionManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session
|
||||
activeSession map[string]string
|
||||
userSessions map[string][]string
|
||||
counter int64
|
||||
storePath string // empty = no persistence
|
||||
}
|
||||
|
||||
func NewSessionManager(storePath string) *SessionManager {
|
||||
sm := &SessionManager{
|
||||
sessions: make(map[string]*Session),
|
||||
activeSession: make(map[string]string),
|
||||
userSessions: make(map[string][]string),
|
||||
storePath: storePath,
|
||||
}
|
||||
if storePath != "" {
|
||||
sm.load()
|
||||
}
|
||||
return sm
|
||||
}
|
||||
|
||||
func (sm *SessionManager) nextID() string {
|
||||
@@ -82,7 +100,6 @@ func (sm *SessionManager) nextID() string {
|
||||
return fmt.Sprintf("s%d", sm.counter)
|
||||
}
|
||||
|
||||
// GetOrCreateActive returns the user's active session, creating a default one if none exists.
|
||||
func (sm *SessionManager) GetOrCreateActive(userKey string) *Session {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
@@ -95,11 +112,12 @@ func (sm *SessionManager) GetOrCreateActive(userKey string) *Session {
|
||||
return sm.createLocked(userKey, "default")
|
||||
}
|
||||
|
||||
// NewSession creates a new session for the user and makes it active.
|
||||
func (sm *SessionManager) NewSession(userKey, name string) *Session {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
return sm.createLocked(userKey, name)
|
||||
s := sm.createLocked(userKey, name)
|
||||
sm.saveLocked()
|
||||
return s
|
||||
}
|
||||
|
||||
func (sm *SessionManager) createLocked(userKey, name string) *Session {
|
||||
@@ -117,7 +135,6 @@ func (sm *SessionManager) createLocked(userKey, name string) *Session {
|
||||
return s
|
||||
}
|
||||
|
||||
// SwitchSession makes the session matching target (by ID or name) active for the user.
|
||||
func (sm *SessionManager) SwitchSession(userKey, target string) (*Session, error) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
@@ -126,13 +143,13 @@ func (sm *SessionManager) SwitchSession(userKey, target string) (*Session, error
|
||||
s := sm.sessions[sid]
|
||||
if s != nil && (s.ID == target || s.Name == target) {
|
||||
sm.activeSession[userKey] = s.ID
|
||||
sm.saveLocked()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("session %q not found", target)
|
||||
}
|
||||
|
||||
// ListSessions returns all sessions belonging to the user.
|
||||
func (sm *SessionManager) ListSessions(userKey string) []*Session {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
@@ -147,9 +164,70 @@ func (sm *SessionManager) ListSessions(userKey string) []*Session {
|
||||
return out
|
||||
}
|
||||
|
||||
// ActiveSessionID returns the ID of the user's currently active session (empty if none).
|
||||
func (sm *SessionManager) ActiveSessionID(userKey string) string {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.activeSession[userKey]
|
||||
}
|
||||
|
||||
// Save persists current state to disk. Safe to call from outside (e.g. after message processing).
|
||||
func (sm *SessionManager) Save() {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
sm.saveLocked()
|
||||
}
|
||||
|
||||
func (sm *SessionManager) saveLocked() {
|
||||
if sm.storePath == "" {
|
||||
return
|
||||
}
|
||||
snap := sessionSnapshot{
|
||||
Sessions: sm.sessions,
|
||||
ActiveSession: sm.activeSession,
|
||||
UserSessions: sm.userSessions,
|
||||
Counter: sm.counter,
|
||||
}
|
||||
data, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
slog.Error("session: failed to marshal", "error", err)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(sm.storePath), 0o755); err != nil {
|
||||
slog.Error("session: failed to create dir", "error", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(sm.storePath, data, 0o644); err != nil {
|
||||
slog.Error("session: failed to write", "path", sm.storePath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) load() {
|
||||
data, err := os.ReadFile(sm.storePath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
slog.Error("session: failed to read", "path", sm.storePath, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var snap sessionSnapshot
|
||||
if err := json.Unmarshal(data, &snap); err != nil {
|
||||
slog.Error("session: failed to unmarshal", "path", sm.storePath, "error", err)
|
||||
return
|
||||
}
|
||||
sm.sessions = snap.Sessions
|
||||
sm.activeSession = snap.ActiveSession
|
||||
sm.userSessions = snap.UserSessions
|
||||
sm.counter = snap.Counter
|
||||
|
||||
if sm.sessions == nil {
|
||||
sm.sessions = make(map[string]*Session)
|
||||
}
|
||||
if sm.activeSession == nil {
|
||||
sm.activeSession = make(map[string]string)
|
||||
}
|
||||
if sm.userSessions == nil {
|
||||
sm.userSessions = make(map[string][]string)
|
||||
}
|
||||
|
||||
slog.Info("session: loaded from disk", "path", sm.storePath, "sessions", len(sm.sessions))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
|
||||
@@ -53,6 +54,10 @@ func (p *Platform) Start(handler core.MessageHandler) error {
|
||||
eventHandler := dispatcher.NewEventDispatcher("", "").
|
||||
OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||
return p.onMessage(event)
|
||||
}).
|
||||
OnP2ChatAccessEventBotP2pChatEnteredV1(func(ctx context.Context, event *larkim.P2ChatAccessEventBotP2pChatEnteredV1) error {
|
||||
slog.Debug("feishu: user opened bot chat")
|
||||
return nil
|
||||
})
|
||||
|
||||
p.wsClient = larkws.NewClient(p.appID, p.appSecret,
|
||||
@@ -123,13 +128,13 @@ func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
|
||||
return fmt.Errorf("feishu: invalid reply context type %T", rctx)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"text": content})
|
||||
msgType, msgBody := buildReplyContent(content)
|
||||
|
||||
resp, err := p.client.Im.Message.Reply(ctx, larkim.NewReplyMessageReqBuilder().
|
||||
MessageId(rc.messageID).
|
||||
Body(larkim.NewReplyMessageReqBodyBuilder().
|
||||
MsgType(larkim.MsgTypeText).
|
||||
Content(string(body)).
|
||||
MsgType(msgType).
|
||||
Content(msgBody).
|
||||
Build()).
|
||||
Build())
|
||||
if err != nil {
|
||||
@@ -141,6 +146,80 @@ func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildReplyContent decides between plain text and interactive card based on content.
|
||||
func buildReplyContent(content string) (msgType string, body string) {
|
||||
if !containsMarkdown(content) {
|
||||
b, _ := json.Marshal(map[string]string{"text": content})
|
||||
return larkim.MsgTypeText, string(b)
|
||||
}
|
||||
return larkim.MsgTypeInteractive, buildCardJSON(adaptMarkdown(content))
|
||||
}
|
||||
|
||||
var markdownIndicators = []string{
|
||||
"```", "**", "~~", "\n- ", "\n* ", "\n1. ", "\n# ", "---",
|
||||
}
|
||||
|
||||
func containsMarkdown(s string) bool {
|
||||
for _, ind := range markdownIndicators {
|
||||
if strings.Contains(s, ind) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// adaptMarkdown converts standard markdown to Feishu card-compatible markdown.
|
||||
// Feishu card markdown elements do NOT support # headers or > blockquotes,
|
||||
// so we convert them to bold text and indented text respectively.
|
||||
func adaptMarkdown(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
inCodeBlock := false
|
||||
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
inCodeBlock = !inCodeBlock
|
||||
continue
|
||||
}
|
||||
if inCodeBlock {
|
||||
continue
|
||||
}
|
||||
|
||||
for level := 6; level >= 1; level-- {
|
||||
prefix := strings.Repeat("#", level) + " "
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
lines[i] = "**" + strings.TrimPrefix(line, prefix) + "**"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "> ") {
|
||||
lines[i] = " " + strings.TrimPrefix(line, "> ")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildCardJSON(content string) string {
|
||||
card := map[string]any{
|
||||
"config": map[string]any{
|
||||
"wide_screen_mode": true,
|
||||
},
|
||||
"elements": []any{
|
||||
map[string]any{
|
||||
"tag": "div",
|
||||
"text": map[string]any{
|
||||
"tag": "lark_md",
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(card)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (p *Platform) Stop() error {
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
|
||||
Reference in new issue
Block a user