package discord import ( "fmt" "strings" "unicode/utf8" "github.com/bwmarrin/discordgo" "github.com/chenhg5/cc-connect/core" ) const ( maxDiscordEmbedTitleLen = 256 maxDiscordProgressDescLen = 3500 maxDiscordProgressLineLen = 220 maxDiscordProgressFooterLen = 512 ) func buildDiscordPreviewMessage(content string) *discordgo.MessageSend { if payload, ok := core.ParseProgressCardPayload(content); ok { return &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{buildDiscordProgressEmbed(payload)}, } } return &discordgo.MessageSend{ Content: trimDiscordRunes(content, maxDiscordLen), } } func buildDiscordPreviewEdit(channelID, messageID, content string) *discordgo.MessageEdit { edit := discordgo.NewMessageEdit(channelID, messageID) if payload, ok := core.ParseProgressCardPayload(content); ok { edit.SetContent("") edit.SetEmbeds([]*discordgo.MessageEmbed{buildDiscordProgressEmbed(payload)}) return edit } edit.SetContent(trimDiscordRunes(content, maxDiscordLen)) edit.SetEmbeds([]*discordgo.MessageEmbed{}) return edit } func buildDiscordProgressEmbed(payload *core.ProgressCardPayload) *discordgo.MessageEmbed { if payload == nil { return &discordgo.MessageEmbed{Description: " "} } agent := discordProgressAgentLabel(payload.Agent) title, color, footer := discordProgressStateMeta(payload.State, payload.Lang, agent) embed := &discordgo.MessageEmbed{ Title: trimDiscordRunes(title, maxDiscordEmbedTitleLen), Description: buildDiscordProgressDescription(payload), Color: color, } if footer = strings.TrimSpace(footer); footer != "" { embed.Footer = &discordgo.MessageEmbedFooter{ Text: trimDiscordRunes(footer, maxDiscordProgressFooterLen), } } return embed } func buildDiscordProgressDescription(payload *core.ProgressCardPayload) string { if payload == nil { return " " } items := discordProgressItems(payload) lines := make([]string, 0, len(items)+1) if payload.Truncated { lines = append(lines, "ℹ️ "+discordProgressLatestOnlyText(payload.Lang)) } for _, item := range items { if line := buildDiscordProgressLine(item, payload.Lang); line != "" { lines = append(lines, line) } } desc := strings.Join(lines, "\n") desc = trimDiscordRunes(desc, maxDiscordProgressDescLen) if strings.TrimSpace(desc) == "" { return " " } return desc } func discordProgressItems(payload *core.ProgressCardPayload) []core.ProgressCardEntry { if payload == nil { return nil } if len(payload.Items) > 0 { return payload.Items } out := make([]core.ProgressCardEntry, 0, len(payload.Entries)) for _, entry := range payload.Entries { entry = strings.TrimSpace(entry) if entry == "" { continue } out = append(out, core.ProgressCardEntry{ Kind: core.ProgressEntryInfo, Text: entry, }) } return out } func buildDiscordProgressLine(item core.ProgressCardEntry, lang string) string { text := compactDiscordProgressText(item.Text) switch item.Kind { case core.ProgressEntryThinking: return "💭 " + trimDiscordRunes(text, maxDiscordProgressLineLen-2) case core.ProgressEntryToolUse: return "🔧 " + trimDiscordRunes(buildDiscordToolUseSummary(item, lang), maxDiscordProgressLineLen-2) case core.ProgressEntryToolResult: return "🧾 " + trimDiscordRunes(buildDiscordToolResultSummary(item, lang), maxDiscordProgressLineLen-2) case core.ProgressEntryError: return "❌ " + trimDiscordRunes(text, maxDiscordProgressLineLen-2) default: return "• " + trimDiscordRunes(text, maxDiscordProgressLineLen-2) } } func buildDiscordToolUseSummary(item core.ProgressCardEntry, lang string) string { toolName := strings.TrimSpace(item.Tool) if toolName == "" { toolName = discordProgressToolLabel(lang) } text := compactDiscordProgressText(item.Text) if text == "" { return toolName } return toolName + " — " + text } func buildDiscordToolResultSummary(item core.ProgressCardEntry, lang string) string { toolName := strings.TrimSpace(item.Tool) text := compactDiscordProgressText(item.Text) meta := make([]string, 0, 3) if status := discordProgressStatusText(item, lang); status != "" { meta = append(meta, status) } if item.ExitCode != nil { meta = append(meta, fmt.Sprintf("exit %d", *item.ExitCode)) } if text != "" { duplicate := false for _, part := range meta { if strings.EqualFold(part, text) { duplicate = true break } } if !duplicate { meta = append(meta, text) } } if len(meta) == 0 { meta = append(meta, discordProgressNoOutputText(lang)) } if toolName == "" { return strings.Join(meta, " · ") } return toolName + " — " + strings.Join(meta, " · ") } func discordProgressStateMeta(state core.ProgressCardState, lang string, agent string) (title string, color int, footer string) { switch state { case core.ProgressCardStateCompleted: return fmt.Sprintf("%s · %s", agent, discordProgressCompletedText(lang)), 0x57F287, discordProgressCompletedFooter(lang) case core.ProgressCardStateFailed: return fmt.Sprintf("%s · %s", agent, discordProgressFailedText(lang)), 0xED4245, discordProgressFailedFooter(lang) default: return fmt.Sprintf("%s · %s", agent, discordProgressRunningText(lang)), 0x5865F2, "" } } func discordProgressAgentLabel(agent string) string { agent = strings.TrimSpace(agent) if agent == "" { return "Agent" } return agent } func discordProgressRunningText(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "处理中" case "zh-tw": return "處理中" case "ja": return "処理中" case "es": return "Procesando" default: return "Processing" } } func discordProgressCompletedText(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "已完成" case "zh-tw": return "已完成" case "ja": return "完了" case "es": return "Completado" default: return "Completed" } } func discordProgressFailedText(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "失败" case "zh-tw": return "失敗" case "ja": return "失敗" case "es": return "Fallido" default: return "Failed" } } func discordProgressCompletedFooter(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "本进度卡已停止更新,完整答复见下一条消息。" case "zh-tw": return "本進度卡已停止更新,完整答覆見下一條訊息。" case "ja": return "この進捗カードの更新は終了しました。完全な回答は次のメッセージにあります。" case "es": return "Esta tarjeta de progreso ya no se actualiza. La respuesta completa está en el siguiente mensaje." default: return "This progress card is no longer updating. Full response is in the next message." } } func discordProgressFailedFooter(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "本进度卡已停止更新(失败),请查看下一条消息中的错误详情。" case "zh-tw": return "本進度卡已停止更新(失敗),請查看下一條訊息中的錯誤詳情。" case "ja": return "この進捗カードは失敗で停止しました。詳細は次のメッセージを確認してください。" case "es": return "Esta tarjeta de progreso se detuvo por error. Consulta el siguiente mensaje para ver los detalles." default: return "This progress card has stopped (failed). See the next message for details." } } func discordProgressLatestOnlyText(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "仅显示最近更新。" case "zh-tw": return "僅顯示最近更新。" case "ja": return "最新の更新のみ表示しています。" case "es": return "Mostrando solo las actualizaciones más recientes." default: return "Showing latest updates only." } } func discordProgressNoOutputText(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "无输出" case "zh-tw": return "無輸出" case "ja": return "出力なし" case "es": return "Sin salida" default: return "No output" } } func discordProgressToolLabel(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "工具" case "zh-tw": return "工具" case "ja": return "ツール" case "es": return "Herramienta" default: return "Tool" } } func discordProgressOKText(lang string) string { switch normalizeDiscordProgressLang(lang) { case "zh": return "成功" case "zh-tw": return "成功" case "ja": return "成功" case "es": return "ok" default: return "ok" } } func discordProgressStatusText(item core.ProgressCardEntry, lang string) string { status := strings.ToLower(strings.TrimSpace(item.Status)) switch status { case "completed", "complete", "success", "succeeded", "ok": if status == "completed" || status == "complete" { return strings.ToLower(discordProgressCompletedText(lang)) } return discordProgressOKText(lang) case "failed", "failure", "error": return strings.ToLower(discordProgressFailedText(lang)) } if item.Success != nil { if *item.Success { return discordProgressOKText(lang) } return strings.ToLower(discordProgressFailedText(lang)) } return strings.TrimSpace(item.Status) } func normalizeDiscordProgressLang(lang string) string { l := strings.ToLower(strings.TrimSpace(lang)) switch { case l == "zh-tw" || l == "zh-hk": return "zh-tw" case strings.HasPrefix(l, "zh"): return "zh" case strings.HasPrefix(l, "ja"): return "ja" case strings.HasPrefix(l, "es"): return "es" default: return "en" } } func compactDiscordProgressText(s string) string { s = strings.ReplaceAll(strings.TrimSpace(s), "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") s = strings.ReplaceAll(s, "\n", " ") return strings.Join(strings.Fields(s), " ") } func trimDiscordRunes(s string, maxRunes int) string { if maxRunes <= 0 { return "" } s = strings.TrimSpace(s) if s == "" { return "" } if utf8.RuneCountInString(s) <= maxRunes { return s } rs := []rune(s) if maxRunes == 1 { return string(rs[:1]) } return string(rs[:maxRunes-1]) + "…" }