mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 04:16:06 +00:00
* feat(pancake): private-reply funnel — 2 modes, scope filter, DB dedup, metrics
Refactor the pancake channel's one-time DM feature from the 5-day-old minimal
`first_inbox` toggle into a complete comment → DM funnel. Breaking rename
(first_inbox → private_reply) matches Pancake + Facebook Graph terminology.
Channel logic
- Two modes: `after_reply` (default — public reply then DM) and `standalone`
(DM-only; bypasses keyword filter, publishes synthetic outbound via
`ch.Bus().PublishOutbound` so the LLM pipeline is skipped for template DMs).
- Post-level scope filter (`private_reply_options.allow_post_ids` /
`deny_post_ids`, deny beats allow).
- Template vars `{{commenter_name}}` / `{{post_title}}` with pre-sanitized
literal-replace (strips `{{`/`}}` from values — no nested substitution).
- Locale-aware default text via `i18n.T(locale, MsgPancakePrivateReplyDefault)`
with en/vi/zh catalogs and English fallback.
DB dedup (replaces in-memory sync.Map)
- New `store.PancakePrivateReplyStore` (PG + SQLite impls), tenant-scoped via
`store.TenantIDFromContext`, fail-closed on missing tenant.
- Atomic `TryClaim` / `Unclaim` (INSERT ... ON CONFLICT DO UPDATE WHERE stale)
eliminates the concurrent-comment TOCTOU that would have fired duplicate
DMs — claim first, release on API failure so the next comment can retry.
- Configurable TTL (`private_reply_ttl_days`, default 7).
- Dual-DB: migration 000056 on PG; schema v25 on SQLite.
Bumps RequiredSchemaVersion 55 → 56.
Wiring
- `pancake.FactoryWithStores(ppReplyStore)` follows the existing
`FactoryWithStores*` pattern (telegram, discord, whatsapp) and replaces
`pancake.Factory` in `cmd/gateway.go`.
- `Channel.Send` injects `store.WithTenantID(ctx, ch.TenantID())` before
dispatch so the outbound path sees a non-nil tenant (regression guard:
without this the new store fails-closed and drops DMs in production — unit
fakes masked this by bypassing the tenant check).
- Routing metadata whitelist extended: `private_reply_mode`,
`private_reply_only`, `post_id`, `display_name` survive inbound → outbound.
Observability
- New `internal/metrics` package with `pancake_private_reply_total{
page_id, result, reason}` counter and `/metrics` endpoint mounted on the
gateway.
- Endpoint gated behind Bearer auth when `gateway.token` is configured; open
(with warning) when unset for local dev scraping. Operators should set a
token or bind `GOCLAW_HOST=127.0.0.1` to avoid leaking page identifiers.
UI
- 6 additive Pancake fields in `channel-schemas.ts` with `showWhen` gating on
`features.private_reply` (itself gated on platform = fb/ig).
- 10 schema tests; full web build passes.
- i18n entries in en/vi/zh `channels.json` + 2 new Go keys
(`MsgPancakePrivateReplyWindowExpired`, `MsgPancakePrivateReplyDefault`).
Tests
- 18 unit tests in pancake package (render/filter/mode switch/scope/dedup/
metrics/tenant-ctx regression); 6 store-level tests; 5 E2E tests against
live PG covering happy path, dedup-across-restart, TTL expiry via SQL,
scope filter, FB 7-day policy error → no claim retained.
- All green on both PG and SQLite build tags with race detector.
* refactor(pancake): simplify private-reply to stateless DM
Roll back the dedup funnel from PR 951 — no table, no in-memory state,
no metrics, no modes, no scope filter. Keep only feature flag + message
template.
Rationale: each platform owning its own dedup table is an anti-pattern
that inflates multi-tenant data footprint. Use the platform itself
(Facebook Graph /comment/private_replies is per-comment idempotent)
combined with the existing webhook comment_id dedup in
comment_handler.go. Zero GoClaw state required.
Removed:
- DB table pancake_private_reply_sent (PG migration 000056, SQLite v25)
- Store interface + PG + SQLite implementations + tests
- internal/metrics package + /metrics endpoint + prometheus dep
- Two modes (after_reply/standalone) + standalone fast-path
- Scope filter (allow/deny post IDs) + PrivateReplyOptions struct
- TTL config (private_reply_ttl_days)
- Locale-aware i18n default (MsgPancakePrivateReplyDefault,
MsgPancakePrivateReplyWindowExpired)
- FactoryWithStores wiring + tenant ctx injection in Send()
- SetHTTPClientForTest dead code
Kept:
- features.private_reply flag
- private_reply_message template with {{commenter_name}} and
{{post_title}} vars (literal-replace, injection-safe)
- After-reply flow: comment -> public reply -> DM
- Hard-coded English fallback when message is empty
Schema: RequiredSchemaVersion 56 -> 55; SQLite SchemaVersion 25 -> 24;
schema.sql DDL block removed.
Net delta: -2661 lines across 44 files. Build (PG + sqliteonly) +
vet + race tests clean.
* test(agent): bump none-mode prompt size budget to 3100
vault_read wiring (#948) added ~95 chars to read_file tool summary,
pushing none-mode prompt from <3000 to 3075 chars. Bump budget to
3100 (~775 tokens) to match the intentional addition.
* test(integration): ensure data_migrations table exists in reset helper
The reset helper runs before RunPendingHooks, but RunPendingHooks is
what normally creates data_migrations. On a fresh CI database the
DELETE fails with 'relation does not exist'. Create the table
defensively so reset works regardless of execution order.
* chore(sqlite): remove accidental trailing blank line in schema.sql
---------
Co-authored-by: viettranx <viettranx@gmail.com>
147 lines
3.5 KiB
Go
147 lines
3.5 KiB
Go
package pancake
|
|
|
|
import (
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
htmlBreakTagRe = regexp.MustCompile(`(?i)<br\b[^>]*\/?>`)
|
|
htmlCloseTagRe = regexp.MustCompile(`(?i)</(?:div|p|li|ul|ol|h[1-6])>`)
|
|
htmlTagRe = regexp.MustCompile(`(?i)<[^>]+>`)
|
|
)
|
|
|
|
// isDup checks and records a dedup key. Returns true if the key was already seen.
|
|
func (ch *Channel) isDup(key string) bool {
|
|
_, loaded := ch.dedup.LoadOrStore(key, time.Now())
|
|
return loaded
|
|
}
|
|
|
|
// splitMessage splits text into chunks no longer than maxLen Unicode code points.
|
|
// Operates on runes (not bytes) to avoid splitting multi-byte characters (CJK, emoji, Vietnamese).
|
|
func splitMessage(text string, maxLen int) []string {
|
|
runes := []rune(text)
|
|
if maxLen <= 0 || len(runes) <= maxLen {
|
|
return []string{text}
|
|
}
|
|
var parts []string
|
|
for len(runes) > maxLen {
|
|
parts = append(parts, string(runes[:maxLen]))
|
|
runes = runes[maxLen:]
|
|
}
|
|
if len(runes) > 0 {
|
|
parts = append(parts, string(runes))
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func (ch *Channel) forgetOutboundEcho(conversationID, content string) {
|
|
if conversationID == "" {
|
|
return
|
|
}
|
|
normalized := normalizeEchoContent(content)
|
|
if normalized == "" {
|
|
return
|
|
}
|
|
ch.recentOutbound.Delete(conversationID + "\x00" + normalized)
|
|
}
|
|
|
|
func (ch *Channel) rememberOutboundEcho(conversationID, content string) {
|
|
if conversationID == "" {
|
|
return
|
|
}
|
|
normalized := normalizeEchoContent(content)
|
|
if normalized == "" {
|
|
return
|
|
}
|
|
ch.recentOutbound.Store(conversationID+"\x00"+normalized, time.Now())
|
|
}
|
|
|
|
func (ch *Channel) isRecentOutboundEcho(conversationID, content string) bool {
|
|
if conversationID == "" {
|
|
return false
|
|
}
|
|
normalized := normalizeEchoContent(content)
|
|
if normalized == "" {
|
|
return false
|
|
}
|
|
key := conversationID + "\x00" + normalized
|
|
v, ok := ch.recentOutbound.Load(key)
|
|
if !ok {
|
|
return false
|
|
}
|
|
ts, ok := v.(time.Time)
|
|
if !ok {
|
|
ch.recentOutbound.Delete(key)
|
|
return false
|
|
}
|
|
if time.Since(ts) > outboundEchoTTL {
|
|
ch.recentOutbound.Delete(key)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func normalizeEchoContent(content string) string {
|
|
content = strings.TrimSpace(content)
|
|
if content == "" {
|
|
return ""
|
|
}
|
|
|
|
content = html.UnescapeString(content)
|
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
|
content = strings.ReplaceAll(content, "\r", "\n")
|
|
content = htmlBreakTagRe.ReplaceAllString(content, "\n")
|
|
content = htmlCloseTagRe.ReplaceAllString(content, "\n")
|
|
content = htmlTagRe.ReplaceAllString(content, "")
|
|
|
|
lines := strings.Split(content, "\n")
|
|
normalized := make([]string, 0, len(lines))
|
|
pendingBlank := false
|
|
for _, line := range lines {
|
|
line = strings.Join(strings.Fields(line), " ")
|
|
if line == "" {
|
|
if len(normalized) == 0 || pendingBlank {
|
|
continue
|
|
}
|
|
pendingBlank = true
|
|
continue
|
|
}
|
|
if pendingBlank {
|
|
normalized = append(normalized, "")
|
|
pendingBlank = false
|
|
}
|
|
normalized = append(normalized, line)
|
|
}
|
|
|
|
return strings.TrimSpace(strings.Join(normalized, "\n"))
|
|
}
|
|
|
|
// runDedupCleaner evicts dedup entries older than dedupTTL every dedupCleanEvery.
|
|
func (ch *Channel) runDedupCleaner() {
|
|
ticker := time.NewTicker(dedupCleanEvery)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ch.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
now := time.Now()
|
|
ch.dedup.Range(func(k, v any) bool {
|
|
if t, ok := v.(time.Time); ok && now.Sub(t) > dedupTTL {
|
|
ch.dedup.Delete(k)
|
|
}
|
|
return true
|
|
})
|
|
ch.recentOutbound.Range(func(k, v any) bool {
|
|
if t, ok := v.(time.Time); ok && now.Sub(t) > outboundEchoTTL {
|
|
ch.recentOutbound.Delete(k)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
}
|