* feat: add 'sessions' subcommand with TUI viewer (WIP)
* feat: add 'sessions' subcommand with TUI viewer for browsing session history
Three modes: interactive TUI browser (default), non-interactive list,
and non-interactive show. Reads session JSON files directly from
~/.cc-connect/sessions/ without requiring the daemon to be running.
- TUI: bubbles/table overview + bubbles/viewport detail with lipgloss styling
- `sessions list`: pipe-friendly tabwriter output
- `sessions show <id> [-n N]`: view specific session by composite ID or index
* feat: persist user/group names for sessions viewer
- Add UserMeta (UserName/ChatName) to SessionManager with persistence
- Capture metadata in engine.handleMessage() on every incoming message
- Fix feishu userName bug: was using sender.SenderType ("user"), now
calls resolveUserName() for actual display names
- Populate ChatName with Chat.Title for Telegram group chats
- Display User and Group/Chat columns in sessions list/show/TUI
- Return copy from GetUserMeta() for thread safety
- Use rune-based truncation to avoid corrupting multi-byte UTF-8
* feat: add user/chat name resolution for all platforms
- Slack: resolve UserName via GetUserInfo API, ChatName via channel name
- Discord: resolve ChatName via Channel API with sync.Map cache
- LINE: resolve UserName via GetProfile API with sync.Map cache
- DingTalk: populate ChatName from ConversationTitle (SDK provides it)
- QQ (OneBot): resolve ChatName via get_group_info API with cache
- WeChat Work (HTTP): resolve UserName via user/get API with cache
Platforms with API limitations (left as raw ID):
- QQ Bot: official API does not provide user nicknames
- WeChat Work (WS): no REST API access from WebSocket mode
* fix: consistent fallback to raw ID when name resolution fails
- Slack: resolveChannelNameForMsg returns channelID (not "") on error
- Discord: resolveChannelName returns channelID (not "") on error,
skip caching empty names
- QQ: resolveGroupName returns groupID string (not "") on error
- WeChat Work WS: add comment documenting why raw userID is used
* feat: add chat/group name resolution for feishu and LINE
- Feishu: resolve chat name via Im.Chat.Get API with sync.Map cache,
populate ChatName for all message types
- LINE: resolve group name via GetGroupSummary API with sync.Map cache,
populate ChatName for group messages
* fix: populate UserName/ChatName in all remaining message paths
- Feishu: add UserName + ChatName to onCardAction handler (perm/askq/cmd)
- QQ Bot: add GroupOpenID as ChatName fallback in group messages
- WeChat Work WS: add ChatID as ChatName for group chats
200 lines
6.9 KiB
Go
200 lines
6.9 KiB
Go
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MergeEnv returns base env with entries from extra overriding same-key entries.
|
|
// This prevents duplicate keys (e.g. two PATH entries) which cause the override
|
|
// to be silently ignored on Linux (getenv returns the first match).
|
|
func MergeEnv(base, extra []string) []string {
|
|
keys := make(map[string]bool, len(extra))
|
|
for _, e := range extra {
|
|
if k, _, ok := strings.Cut(e, "="); ok {
|
|
keys[k] = true
|
|
}
|
|
}
|
|
merged := make([]string, 0, len(base)+len(extra))
|
|
for _, e := range base {
|
|
if k, _, ok := strings.Cut(e, "="); ok && keys[k] {
|
|
continue
|
|
}
|
|
merged = append(merged, e)
|
|
}
|
|
return append(merged, extra...)
|
|
}
|
|
|
|
// CheckAllowFrom logs a security warning at startup when allow_from is not
|
|
// configured (defaults to permit-all). Platforms should call this during init.
|
|
func CheckAllowFrom(platform, allowFrom string) {
|
|
if strings.TrimSpace(allowFrom) == "" {
|
|
slog.Warn("allow_from is not set — all users are permitted. "+
|
|
"Set allow_from in config to restrict access.",
|
|
"platform", platform)
|
|
}
|
|
}
|
|
|
|
// RedactToken replaces a secret token in text with [REDACTED] to prevent
|
|
// token leakage in logs or error messages.
|
|
func RedactToken(text, token string) string {
|
|
if token == "" || text == "" {
|
|
return text
|
|
}
|
|
return strings.ReplaceAll(text, token, "[REDACTED]")
|
|
}
|
|
|
|
// AllowList checks whether a user ID is permitted based on a comma-separated
|
|
// allow_from string. Returns true if allowFrom is empty or "*" (allow all),
|
|
// or if the userID is in the list. Comparison is case-insensitive.
|
|
func AllowList(allowFrom, userID string) bool {
|
|
allowFrom = strings.TrimSpace(allowFrom)
|
|
if allowFrom == "" || allowFrom == "*" {
|
|
return true
|
|
}
|
|
for _, id := range strings.Split(allowFrom, ",") {
|
|
if strings.EqualFold(strings.TrimSpace(id), userID) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ImageAttachment represents an image sent by the user.
|
|
type ImageAttachment struct {
|
|
MimeType string // e.g. "image/png", "image/jpeg"
|
|
Data []byte // raw image bytes
|
|
FileName string // original filename (optional)
|
|
}
|
|
|
|
// FileAttachment represents a file (PDF, doc, spreadsheet, etc.) sent by the user.
|
|
type FileAttachment struct {
|
|
MimeType string // e.g. "application/pdf", "text/plain"
|
|
Data []byte // raw file bytes
|
|
FileName string // original filename
|
|
}
|
|
|
|
// SaveFilesToDisk saves file attachments to workDir/.cc-connect/attachments/
|
|
// and returns the list of absolute file paths. Agents can reference these paths
|
|
// in their prompts so the CLI can read them with built-in tools.
|
|
func SaveFilesToDisk(workDir string, files []FileAttachment) []string {
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
attachDir := filepath.Join(workDir, ".cc-connect", "attachments")
|
|
os.MkdirAll(attachDir, 0o755)
|
|
|
|
var paths []string
|
|
for i, f := range files {
|
|
fname := f.FileName
|
|
if fname == "" {
|
|
fname = fmt.Sprintf("file_%d_%d", time.Now().UnixMilli(), i)
|
|
}
|
|
fpath := filepath.Join(attachDir, fname)
|
|
if err := os.WriteFile(fpath, f.Data, 0o644); err != nil {
|
|
slog.Error("SaveFilesToDisk: write failed", "error", err)
|
|
continue
|
|
}
|
|
paths = append(paths, fpath)
|
|
slog.Debug("SaveFilesToDisk: file saved", "path", fpath, "name", f.FileName, "mime", f.MimeType, "size", len(f.Data))
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// AppendFileRefs appends file path references to a prompt string.
|
|
func AppendFileRefs(prompt string, filePaths []string) string {
|
|
if len(filePaths) == 0 {
|
|
return prompt
|
|
}
|
|
if prompt == "" {
|
|
prompt = "Please analyze the attached file(s)."
|
|
}
|
|
return prompt + "\n\n(Files saved locally, please read them: " + strings.Join(filePaths, ", ") + ")"
|
|
}
|
|
|
|
// AudioAttachment represents a voice/audio message sent by the user.
|
|
type AudioAttachment struct {
|
|
MimeType string // e.g. "audio/amr", "audio/ogg", "audio/mp4"
|
|
Data []byte // raw audio bytes
|
|
Format string // short format hint: "amr", "ogg", "m4a", "mp3", "wav", etc.
|
|
Duration int // duration in seconds (if known)
|
|
}
|
|
|
|
// Message represents a unified incoming message from any platform.
|
|
type Message struct {
|
|
SessionKey string // unique key for user context, e.g. "feishu:{chatID}:{userID}"
|
|
Platform string
|
|
MessageID string // platform message ID for tracing
|
|
UserID string
|
|
UserName string
|
|
ChatName string // human-readable chat/group name (optional)
|
|
Content string
|
|
Images []ImageAttachment // attached images (if any)
|
|
Files []FileAttachment // attached files (if any)
|
|
Audio *AudioAttachment // voice message (if any)
|
|
ReplyCtx any // platform-specific context needed for replying
|
|
FromVoice bool // true if message originated from voice transcription
|
|
}
|
|
|
|
// EventType distinguishes different kinds of agent output.
|
|
type EventType string
|
|
|
|
const (
|
|
EventText EventType = "text" // intermediate or final text
|
|
EventToolUse EventType = "tool_use" // tool invocation info
|
|
EventToolResult EventType = "tool_result" // tool execution result
|
|
EventResult EventType = "result" // final aggregated result
|
|
EventError EventType = "error" // error occurred
|
|
EventPermissionRequest EventType = "permission_request" // agent requests permission via stdio protocol
|
|
EventThinking EventType = "thinking" // thinking/processing status
|
|
)
|
|
|
|
// UserQuestion represents a structured question from AskUserQuestion.
|
|
type UserQuestion struct {
|
|
Question string `json:"question"`
|
|
Header string `json:"header"`
|
|
Options []UserQuestionOption `json:"options"`
|
|
MultiSelect bool `json:"multiSelect"`
|
|
}
|
|
|
|
// UserQuestionOption is one choice in a UserQuestion.
|
|
type UserQuestionOption struct {
|
|
Label string `json:"label"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// Event represents a single piece of agent output streamed back to the engine.
|
|
type Event struct {
|
|
Type EventType
|
|
Content string
|
|
ToolName string // populated for EventToolUse, EventPermissionRequest
|
|
ToolInput string // human-readable summary of tool input
|
|
ToolInputRaw map[string]any // raw tool input (for EventPermissionRequest, used in allow response)
|
|
ToolResult string // populated for EventToolResult
|
|
SessionID string // agent-managed session ID for conversation continuity
|
|
RequestID string // unique request ID for EventPermissionRequest
|
|
Questions []UserQuestion // populated when ToolName == "AskUserQuestion"
|
|
Done bool
|
|
Error error
|
|
}
|
|
|
|
// HistoryEntry is one turn in a conversation.
|
|
type HistoryEntry struct {
|
|
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
|
|
}
|