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:
chenhg5andClaude committed 2026-02-28 12:53:43 +08:00
1 parent 615710a528
commit 92164b38fb
7 files changed
+438 -85

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
cc-connect
config.toml
.cc-connect/
*.exe
*.dll
*.so
+107 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
+82 -3
View File
@@ -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()