diff --git a/.gitignore b/.gitignore index 17256ec..492032c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ cc-connect config.toml +.cc-connect/ *.exe *.dll *.so diff --git a/agent/claudecode/claudecode.go b/agent/claudecode/claudecode.go index ad5ed14..07e8abd 100644 --- a/agent/claudecode/claudecode.go +++ b/agent/claudecode/claudecode.go @@ -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. diff --git a/core/engine.go b/core/engine.go index 2582f04..a8df28e 100644 --- a/core/engine.go +++ b/core/engine.go @@ -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 ` 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 ") + _ = p.Reply(e.ctx, msg.ReplyCtx, "Usage: /switch ") 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 โ€” 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 โ€” 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 { diff --git a/core/interfaces.go b/core/interfaces.go index ab535a9..49ecef4 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -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 } diff --git a/core/message.go b/core/message.go index d3c1654..31c9c38 100644 --- a/core/message.go +++ b/core/message.go @@ -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 } diff --git a/core/session.go b/core/session.go index d06e8e8..51a63af 100644 --- a/core/session.go +++ b/core/session.go @@ -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)) +} diff --git a/platform/feishu/feishu.go b/platform/feishu/feishu.go index a3ad897..7fe1313 100644 --- a/platform/feishu/feishu.go +++ b/platform/feishu/feishu.go @@ -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()