diff --git a/internal/policy/payload_text.go b/internal/policy/payload_text.go index 25140f15..5b95060c 100644 --- a/internal/policy/payload_text.go +++ b/internal/policy/payload_text.go @@ -1,22 +1,15 @@ package policy -import "github.com/envector/rune-go/internal/domain" +import ( + "fmt" + "strings" + + "github.com/envector/rune-go/internal/domain" +) // Payload text renderer — Python canonical: agents/common/schemas/templates.py (364 LoC). // D15: line-by-line port. Verified via byte-for-byte golden fixture test. // -// payload.text is the embedding fallback target (when reusable_insight empty) -// and the recall display text. Any divergence from Python changes embedding -// vector space → breaks recall. -// -// Porting scope: -// ✅ PAYLOAD_TEMPLATE (L14~) — multi-line format string -// ✅ _format_* helpers (L52-131) — 7 helpers (alternatives, trade_offs, -// assumptions, risks, evidence, links, tags) -// ✅ render_payload_text (L138-222) — main -// 🟡 render_compact_payload (L225-238) — Post-MVP -// 🟡 render_display_text (L288-363) — Post-MVP (EN/KO/JA locale) -// // Subtle behaviors (easy to miss in porting): // 1. phase_line / group_summary post-insertion (L204-216) — inserted AFTER // template.format(), not in the template string itself @@ -25,9 +18,247 @@ import "github.com/envector/rune-go/internal/domain" // 3. _format_alternatives "chosen" marker bug (L59): chosen=="" makes all // alternatives marked "(chosen)" — Python current behavior, keep bit-identical -// RenderPayloadText — Python: render_payload_text(record) at L138-222. -// TODO: line-by-line port with golden fixture test. +const payloadTemplate = ` +# Decision Record: %s +ID: %s +Status: %s | Sensitivity: %s | Domain: %s +When/Where: %s | %s + +## Decision +%s + +## Problem +%s + +## Alternatives Considered +%s + +## Why (Rationale) +%s +Certainty: %s + +## Trade-offs +%s + +## Assumptions +%s + +## Risks & Mitigations +%s + +## Evidence (Quotes) +%s + +## Links +%s + +## Tags +%s +` + +// FIXME: if chosen == "", every alternatievs matches since "".lower() == alt.lower() always true +func formatAlternatives(alternatives []string, chosen string) string { + if len(alternatives) == 0 { + return "- (none documented)" + } + var lines []string + chosenLower := strings.ToLower(chosen) + for _, alt := range alternatives { + altLower := strings.ToLower(alt) + if altLower == chosenLower || strings.Contains(altLower, chosenLower) { + lines = append(lines, fmt.Sprintf("- %s (chosen)", alt)) + } else { + lines = append(lines, fmt.Sprintf("- %s", alt)) + } + } + return strings.Join(lines, "\n") +} + +func formatTradeOffs(tradeOffs []string) string { + if len(tradeOffs) == 0 { + return "- (none documented)" + } + var lines []string + for _, t := range tradeOffs { + lines = append(lines, fmt.Sprintf("- %s", t)) + } + return strings.Join(lines, "\n") +} + +func formatAssumptions(assumptions []domain.Assumption) string { + if len(assumptions) == 0 { + return "- (none documented)" + } + var lines []string + for _, a := range assumptions { + lines = append(lines, fmt.Sprintf("- %s (confidence: %.1f)", a.Assumption, a.Confidence)) + } + return strings.Join(lines, "\n") +} + +func formatRisks(risks []domain.Risk) string { + if len(risks) == 0 { + return "- (none documented)" + } + var lines []string + for _, r := range risks { + mitigation := "TBD" + if r.Mitigation != nil && *r.Mitigation != "" { + mitigation = *r.Mitigation + } + lines = append(lines, fmt.Sprintf("- Risk: %s\n Mitigation: %s", r.Risk, mitigation)) + } + return strings.Join(lines, "\n") +} + +func formatEvidence(evidence []domain.Evidence) string { + if len(evidence) == 0 { + return "(no evidence recorded)" + } + var lines []string + for i, e := range evidence { + sourceType := string(e.Source.Type) + sourceURL := "(no url)" + if e.Source.URL != nil && *e.Source.URL != "" { + sourceURL = *e.Source.URL + } + + lines = append(lines, fmt.Sprintf("%d) Claim: %s", i+1, e.Claim)) + lines = append(lines, fmt.Sprintf(" Quote: \"%s\"", e.Quote)) + lines = append(lines, fmt.Sprintf(" Source: %s %s", sourceType, sourceURL)) + if e.Source.Pointer != nil && *e.Source.Pointer != "" { + lines = append(lines, fmt.Sprintf(" Pointer: %s", *e.Source.Pointer)) + } + lines = append(lines, "") + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func formatLinks(links []map[string]any) string { + if len(links) == 0 { + return "- (none)" + } + var lines []string + for _, link := range links { + rel := "link" + if r, ok := link["rel"].(string); ok { + rel = r + } + url := "" + if u, ok := link["url"].(string); ok { + url = u + } + lines = append(lines, fmt.Sprintf("- %s: %s", rel, url)) + } + return strings.Join(lines, "\n") +} + +func formatTags(tags []string) string { + if len(tags) == 0 { + return "(none)" + } + return strings.Join(tags, ", ") +} + func RenderPayloadText(r *domain.DecisionRecord) string { - // TODO: port per D15 canonical reference - return "" + domainStr := string(r.Domain) + sensitivity := string(r.Sensitivity) + status := string(r.Status) + certainty := string(r.Why.Certainty) + + alternatives := formatAlternatives(r.Context.Alternatives, r.Context.Chosen) + tradeOffs := formatTradeOffs(r.Context.TradeOffs) + assumptions := formatAssumptions(r.Context.Assumptions) + risks := formatRisks(r.Context.Risks) + evidenceBlock := formatEvidence(r.Evidence) + links := formatLinks(r.Links) + tags := formatTags(r.Tags) + + rationale := r.Why.RationaleSummary + if rationale == "" { + rationale = "(no rationale documented)" + } + if len(r.Why.MissingInfo) > 0 { + var missingLines []string + for _, m := range r.Why.MissingInfo { + missingLines = append(missingLines, fmt.Sprintf("- %s", m)) + } + rationale += "\n\nMissing Information:\n" + strings.Join(missingLines, "\n") + } + + when := r.Decision.When + if when == "" { + when = "(unknown)" + } + where := r.Decision.Where + if where == "" { + where = "(unknown)" + } + problem := r.Context.Problem + if problem == "" { + problem = "(not documented)" + } + + text := fmt.Sprintf(payloadTemplate, + r.Title, + r.ID, + status, sensitivity, domainStr, + when, where, + r.Decision.What, + problem, + alternatives, + rationale, + certainty, + tradeOffs, + assumptions, + risks, + evidenceBlock, + links, + tags, + ) + + // Post insertion + if r.GroupID != nil && *r.GroupID != "" { + seq := 0 + if r.PhaseSeq != nil { + seq = *r.PhaseSeq + } + seq++ + + totalStr := "?" + if r.PhaseTotal != nil { + totalStr = fmt.Sprintf("%d", *r.PhaseTotal) + } + gtype := "phase_chain" + if r.GroupType != nil && *r.GroupType != "" { + gtype = *r.GroupType + } + phaseLine := fmt.Sprintf("Part: %d of %s | Type: %s | Group: %s", seq, totalStr, gtype, *r.GroupID) + + lines := strings.Split(text, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "ID: ") { + insertPos := i + 1 + // Insert phase line after ID line + newLines := make([]string, 0, len(lines)+2) + newLines = append(newLines, lines[:insertPos]...) + newLines = append(newLines, phaseLine) + // Insert group summary if present + if r.GroupSummary != nil && *r.GroupSummary != "" { + newLines = append(newLines, fmt.Sprintf("Group Summary: %s", *r.GroupSummary)) + } + newLines = append(newLines, lines[insertPos:]...) + lines = newLines + break + } + } + text = strings.Join(lines, "\n") + } + + // Clean up blank lines + for strings.Contains(text, "\n\n\n") { + text = strings.ReplaceAll(text, "\n\n\n", "\n\n") + } + + return strings.TrimSpace(text) } diff --git a/internal/policy/pii.go b/internal/policy/pii.go index 0027b504..b267c9ef 100644 --- a/internal/policy/pii.go +++ b/internal/policy/pii.go @@ -1,28 +1,55 @@ package policy +import ( + "fmt" + "regexp" + "strings" +) + // PII redaction — Python: agents/scribe/record_builder.py:L89-95 SENSITIVE_PATTERNS // + L406-418 _redact_sensitive. // // Called from BuildPhases entry (L228) in BOTH legacy and agent-delegated modes // (rune-mcp is responsible for PII redaction per D13 Option A). -// -// 5 patterns (exact regex to port from Python): -// 1. Email: [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+ -// 2. Phone: \d{3}[-.]?\d{3}[-.]?\d{4} -// 3. API key: [sk|pk|api|key|token|secret|password]_[a-zA-Z0-9_-]{15,} -// 4. Long hex: [A-Za-z0-9]{32,} -// 5. Credit card: [0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4} -// -// Replacement labels per Python (exact strings): see record_builder.py:L89-95. + +type sensitivePattern struct { + regex *regexp.Regexp + replacement string +} + +// sensitivePatterns — 5 patterns, exact regex from Python L89-95. +// Order matters: more specific patterns (e.g., API key with prefix) must come +// before the generic long-alphanumeric pattern. +var sensitivePatterns = []sensitivePattern{ + {regexp.MustCompile(`(?i)\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`), "[EMAIL]"}, + {regexp.MustCompile(`\b\d{3}[-.]?\d{3}[-.]?\d{4}\b`), "[PHONE]"}, + {regexp.MustCompile(`(?i)\b(?:sk|pk|api|key|token|secret|password)[_\-][a-zA-Z0-9_\-]{15,}\b`), "[API_KEY]"}, + {regexp.MustCompile(`\b[A-Za-z0-9]{32,}\b`), "[API_KEY]"}, + {regexp.MustCompile(`\b[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b`), "[CARD]"}, +} // RedactSensitive — Python: record_builder.py:L406-418 _redact_sensitive. -// Also applies MAX_INPUT_CHARS truncate (L227) after redaction. -// -// TODO: port 5 regex + replacement labels + length cap. -func RedactSensitive(text string) string { - // TODO: bit-identical to _redact_sensitive - if len(text) > MaxInputChars { - text = text[:MaxInputChars] +// Returns the redacted text and an optional notes string (e.g. "Redacted 2 [EMAIL]; Redacted 1 [PHONE]"). +// Also applies MAX_INPUT_CHARS truncation after redaction. +func RedactSensitive(text string) (string, string) { + redacted := text + var notes []string + + for _, sp := range sensitivePatterns { + matches := sp.regex.FindAllString(redacted, -1) + if len(matches) > 0 { + redacted = sp.regex.ReplaceAllString(redacted, sp.replacement) + notes = append(notes, fmt.Sprintf("Redacted %d %s", len(matches), sp.replacement)) + } + } + + if len(redacted) > MaxInputChars { + redacted = redacted[:MaxInputChars] + } + + noteStr := "" + if len(notes) > 0 { + noteStr = strings.Join(notes, "; ") } - return text + return redacted, noteStr } diff --git a/internal/policy/record_builder.go b/internal/policy/record_builder.go index 274b3381..5cdf200d 100644 --- a/internal/policy/record_builder.go +++ b/internal/policy/record_builder.go @@ -1,6 +1,9 @@ package policy import ( + "fmt" + "regexp" + "strings" "time" "github.com/envector/rune-go/internal/domain" @@ -14,19 +17,28 @@ import ( // MAX_INPUT_CHARS — Python L227. Truncate cleanText before extraction. const MaxInputChars = 12_000 -// SensitivePatterns — 5 regex for PII redaction (Python L89-95). -// Full patterns live in pii.go (separate file for clarity). - // QuotePatterns — 4 regex (Python L72-77): double "", single '', Japanese 「」, // French «». Min 10 chars. -var QuotePatterns = []string{ - // TODO: port from record_builder.py:L72-77 +var QuotePatterns = []*regexp.Regexp{ + regexp.MustCompile(`"([^"]{10,})"`), + regexp.MustCompile(`'([^']{10,})'`), + regexp.MustCompile(`「([^」]{10,})」`), + regexp.MustCompile(`«([^»]{10,})»`), } // RationalePatterns — 5 regex (Python L80-86): because / reason / rationale / // since / due to. -var RationalePatterns = []string{ - // TODO: port from record_builder.py:L80-86 +var RationalePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)because\s+(.{10,}?)(?:\.|$)`), + regexp.MustCompile(`(?i)reason(?:ing)?(?:\s+is)?[:\s]+(.{10,}?)(?:\.|$)`), + regexp.MustCompile(`(?i)rationale[:\s]+(.{10,}?)(?:\.|$)`), + regexp.MustCompile(`(?i)since\s+(.{10,}?)(?:\.|$)`), + regexp.MustCompile(`(?i)due to\s+(.{10,}?)(?:\.|$)`), +} + +var acceptancePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\b(?:approved|accepted|confirmed|finalized|agreed|decided)\b`), + regexp.MustCompile(`(?i)\b(?:final decision|it's decided|we're going with)\b`), } // BuildPhases — Python: record_builder.py build_phases(rawEvent, detection, pre_extraction). @@ -42,8 +54,6 @@ var RationalePatterns = []string{ // 5. Set reusable_insight = pre_extraction.group_summary (if present) // // Returns 1-7 records (single / phase_chain / bundle per ExtractionResult.GroupType). -// -// TODO: implement. Python reference is 703 LoC; port line-by-line with golden fixture test. func BuildPhases( rawEvent *domain.RawEvent, detection *domain.Detection, @@ -53,8 +63,399 @@ func BuildPhases( if preExtraction == nil { return nil, domain.ErrExtractionMissing } - // TODO: bit-identical to record_builder.py:L196-404 - // Methods to port: _build_single_record_from_extraction (L264-317), - // _build_multi_record_from_extraction (L319-404). - return nil, nil + + // PII redaction + cleanText, redactionNotes := RedactSensitive(rawEvent.Text) + + // Dispatch + if !preExtraction.IsMultiPhase() { + fields := preExtraction.Single + if fields == nil { + return nil, &domain.RuneError{ + Code: domain.CodeInvalidInput, + Message: "extraction has no single fields and no phases", + } + } + rec := buildSingleRecord(fields, rawEvent, cleanText, detection, preExtraction, redactionNotes, now) + return []domain.DecisionRecord{rec}, nil + } + + records := buildMultiRecord(preExtraction, rawEvent, cleanText, detection, redactionNotes, now) + return records, nil +} + +func buildSingleRecord( + fields *domain.ExtractedFields, + rawEvent *domain.RawEvent, + cleanText string, + detection *domain.Detection, + extraction *domain.ExtractionResult, + redactionNotes string, + now time.Time, +) domain.DecisionRecord { + title := fields.Title + if title == "" { + title = extractTitle(cleanText) + } + + evidence := extractEvidence(rawEvent, cleanText) + certainty, missingInfo := determineCertainty(evidence, fields.Rationale) + status := statusFromHint(fields.StatusHint, evidence, cleanText) + d := domain.ParseDomain(detection.Domain) + recordID := domain.GenerateRecordID(now, d, title) + + confidence := detection.Confidence + if extraction.Confidence != nil { + confidence = *extraction.Confidence + } + + var reviewNotes *string + if redactionNotes != "" { + reviewNotes = &redactionNotes + } + + alts := fields.Alternatives + if len(alts) > 5 { + alts = alts[:5] + } + tos := fields.TradeOffs + if len(tos) > 5 { + tos = tos[:5] + } + + record := domain.DecisionRecord{ + SchemaVersion: "2.1", // XXX: should we reset this? + ID: recordID, + Type: "decision_record", + Domain: d, + Sensitivity: domain.SensitivityInternal, + Status: status, + Timestamp: now.UTC(), + Title: title, + Decision: domain.DecisionDetail{ + What: truncStr(cleanText, 500), + Who: userWho(rawEvent), + Where: whereStr(rawEvent), + }, + Context: domain.Context{ + Problem: fields.Problem, + Alternatives: alts, + TradeOffs: tos, + }, + Why: domain.Why{ + RationaleSummary: fields.Rationale, + Certainty: certainty, + MissingInfo: missingInfo, + }, + Evidence: evidence, + Tags: fields.Tags, + OriginalText: &rawEvent.Text, + Quality: domain.Quality{ + ScribeConfidence: confidence, + ReviewState: domain.ReviewStateUnreviewed, + ReviewNotes: reviewNotes, + }, + Payload: domain.Payload{Format: "markdown", Text: ""}, + } + + domain.EnsureEvidenceCertaintyConsistency(&record) + record.Payload.Text = RenderPayloadText(&record) + + if extraction.GroupSummary != "" { + record.ReusableInsight = extraction.GroupSummary + } + + return record +} + +func buildMultiRecord( + extraction *domain.ExtractionResult, + rawEvent *domain.RawEvent, + cleanText string, + detection *domain.Detection, + redactionNotes string, + now time.Time, +) []domain.DecisionRecord { + phases := extraction.Phases + d := domain.ParseDomain(detection.Domain) + groupTitle := extraction.GroupTitle + if groupTitle == "" { + groupTitle = extractTitle(cleanText) + } + groupID := domain.GenerateGroupID(now, d, groupTitle) + groupType := extraction.GroupType + if groupType == "" { + groupType = "phase_chain" + } + phaseTotal := len(phases) + + confidence := detection.Confidence + if extraction.Confidence != nil { + confidence = *extraction.Confidence + } + + var reviewNotes *string + if redactionNotes != "" { + reviewNotes = &redactionNotes + } + + var records []domain.DecisionRecord + for seq, phase := range phases { + phaseTitle := phase.PhaseTitle + if phaseTitle == "" { + phaseTitle = fmt.Sprintf("Phase %d", seq+1) + } + suffix := fmt.Sprintf("_p%d", seq) + if groupType == "bundle" { + suffix = fmt.Sprintf("_b%d", seq) + } + recordID := domain.GenerateRecordID(now, d, phaseTitle) + suffix + + decision := phase.PhaseDecision + if len(decision) > 500 { + decision = decision[:500] + } + + evidence := extractEvidence(rawEvent, cleanText) + certainty, missingInfo := determineCertainty(evidence, phase.PhaseRationale) + status := statusFromHint(extraction.StatusHint, evidence, cleanText) + + alts := phase.Alternatives + if len(alts) > 5 { + alts = alts[:5] + } + tos := phase.TradeOffs + if len(tos) > 5 { + tos = tos[:5] + } + + // Fallback phase.Tags to extractions.Tags + tags := phase.Tags + if len(tags) == 0 { + tags = extraction.Tags + } + + var groupSummary *string + if extraction.GroupSummary != "" { + groupSummary = &extraction.GroupSummary + } + + record := domain.DecisionRecord{ + SchemaVersion: "2.1", // XXX: should we reset this? + ID: recordID, + Type: "decision_record", + Domain: d, + Sensitivity: domain.SensitivityInternal, + Status: status, + Timestamp: now.UTC(), + Title: phaseTitle, + Decision: domain.DecisionDetail{ + What: decision, + Who: userWho(rawEvent), + Where: whereStr(rawEvent), + }, + Context: domain.Context{ + Problem: phase.PhaseProblem, + Alternatives: alts, + TradeOffs: tos, + }, + Why: domain.Why{ + RationaleSummary: phase.PhaseRationale, + Certainty: certainty, + MissingInfo: missingInfo, + }, + Evidence: evidence, + Tags: tags, + OriginalText: &rawEvent.Text, + GroupID: &groupID, + GroupType: &groupType, + PhaseSeq: &seq, + PhaseTotal: &phaseTotal, + GroupSummary: groupSummary, + Quality: domain.Quality{ + ScribeConfidence: confidence, + ReviewState: domain.ReviewStateUnreviewed, + ReviewNotes: reviewNotes, + }, + Payload: domain.Payload{Format: "markdown", Text: ""}, + } + + domain.EnsureEvidenceCertaintyConsistency(&record) + record.Payload.Text = RenderPayloadText(&record) + + if extraction.GroupSummary != "" { + record.ReusableInsight = extraction.GroupSummary + } + + records = append(records, record) + } + + return records +} + +//--- Helper ---// + +// Extract first sentence up to domain.MaxTitleLen chars +func extractTitle(text string) string { + firstSentence := text + if idx := strings.Index(text, "."); idx > 0 { + firstSentence = text[:idx] + } + if len(firstSentence) > domain.MaxTitleLen { + firstSentence = firstSentence[:domain.MaxTitleLen] + } + firstSentence = strings.TrimSpace(firstSentence) + if len(firstSentence) > 10 { + return firstSentence + } + return "General decision" +} + +func extractEvidence(rawEvent *domain.RawEvent, text string) []domain.Evidence { + var evidence []domain.Evidence + sourceRef := makeSourceRef(rawEvent) + + for _, rx := range QuotePatterns { + matches := rx.FindAllStringSubmatch(text, -1) + for _, m := range matches { + if len(m) > 1 && len(m[1]) >= 10 { + quote := m[1] + if len(quote) > 200 { + quote = quote[:200] + } + evidence = append(evidence, domain.Evidence{ + Claim: "Quoted statement from discussion", + Quote: quote, + Source: sourceRef, + }) + } + } + } + + // Fallback paraphrase + if len(evidence) == 0 && len(text) >= 20 { + quote := text + if len(quote) > 150 { + quote = quote[:150] + "..." + } + evidence = append(evidence, domain.Evidence{ + Claim: "Decision statement (paraphrased)", + Quote: quote, + Source: sourceRef, + }) + } + + if len(evidence) > 3 { + evidence = evidence[:3] + } + return evidence +} + +func determineCertainty(evidence []domain.Evidence, rationale string) (domain.Certainty, []string) { + var missingInfo []string + + if len(evidence) == 0 { + missingInfo = append(missingInfo, "No evidence found") + return domain.CertaintyUnknown, missingInfo + } + + hasDirectQuotes := false + for _, e := range evidence { + if !strings.Contains(strings.ToLower(e.Claim), "paraphrase") { + hasDirectQuotes = true + break + } + } + + if !hasDirectQuotes { + missingInfo = append(missingInfo, "No direct quotes - evidence is paraphrased") + return domain.CertaintyPartiallySupported, missingInfo + } + + if rationale == "" { + missingInfo = append(missingInfo, "Explicit rationale not found") + return domain.CertaintyPartiallySupported, missingInfo + } + + return domain.CertaintySupported, missingInfo +} + +func statusFromHint(hint string, evidence []domain.Evidence, text string) domain.Status { + hintLower := strings.TrimSpace(strings.ToLower(hint)) + switch hintLower { + case "accepted": + return domain.StatusAccepted + case "rejected": + return domain.StatusProposed // Rejected: proposed, not superseded + case "proposed": + return domain.StatusProposed + } + + return determineStatus(evidence, text) +} + +func determineStatus(evidence []domain.Evidence, text string) domain.Status { + if len(evidence) == 0 { + return domain.StatusProposed + } + textLower := strings.ToLower(text) + for _, rx := range acceptancePatterns { + if rx.MatchString(textLower) { + return domain.StatusAccepted + } + } + return domain.StatusProposed +} + +// RawEvent -> SourceRef +func makeSourceRef(rawEvent *domain.RawEvent) domain.SourceRef { + var pointer *string + if rawEvent.Channel != "" { + p := "channel:" + rawEvent.Channel + pointer = &p + } + return domain.SourceRef{ + Type: parseSourceType(rawEvent.Source), + Pointer: pointer, + } +} + +func parseSourceType(source string) domain.SourceType { + s := strings.ToLower(source) + switch { + case strings.Contains(s, "slack"): + return domain.SourceTypeSlack + case strings.Contains(s, "github"): + return domain.SourceTypeGitHub + case strings.Contains(s, "notion"): + return domain.SourceTypeNotion + case strings.Contains(s, "meeting"): + return domain.SourceTypeMeeting + case strings.Contains(s, "email"): + return domain.SourceTypeEmail + case strings.Contains(s, "doc"): + return domain.SourceTypeDoc + } + return domain.SourceTypeOther +} + +func userWho(rawEvent *domain.RawEvent) []string { + if rawEvent.User != "" { + return []string{fmt.Sprintf("user:%s", rawEvent.User)} + } + return nil +} + +func whereStr(rawEvent *domain.RawEvent) string { + if rawEvent.Channel != "" { + return fmt.Sprintf("%s:%s", rawEvent.Source, rawEvent.Channel) + } + return rawEvent.Source +} + +func truncStr(s string, maxLen int) string { + if len(s) > maxLen { + return s[:maxLen] + } + return s }