mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 00:15:05 +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>
375 lines
12 KiB
Go
375 lines
12 KiB
Go
package pancake
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- ReplyComment ---
|
|
|
|
func TestReplyComment_MatchesOfficialContract(t *testing.T) {
|
|
transport := &captureTransport{}
|
|
client := NewAPIClient("user-token", "page-token", "page-123")
|
|
client.httpClient = &http.Client{Transport: transport}
|
|
|
|
if err := client.ReplyComment(context.Background(), "conv-123", "msg-456", "thank you"); err != nil {
|
|
t.Fatalf("ReplyComment returned error: %v", err)
|
|
}
|
|
|
|
if transport.req == nil {
|
|
t.Fatal("expected outbound request to be captured")
|
|
}
|
|
wantPath := "/api/public_api/v1/pages/page-123/conversations/conv-123/messages"
|
|
if got := transport.req.URL.Path; got != wantPath {
|
|
t.Fatalf("request path = %q, want %q", got, wantPath)
|
|
}
|
|
if got := transport.req.URL.Query().Get("page_access_token"); got != "page-token" {
|
|
t.Fatalf("page_access_token = %q, want %q", got, "page-token")
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(transport.body, &payload); err != nil {
|
|
t.Fatalf("unmarshal payload: %v", err)
|
|
}
|
|
if got, want := payload["action"], "reply_comment"; got != want {
|
|
t.Fatalf("payload.action = %#v, want %#v", got, want)
|
|
}
|
|
if got, want := payload["message"], "thank you"; got != want {
|
|
t.Fatalf("payload.message = %#v, want %#v", got, want)
|
|
}
|
|
if got, want := payload["message_id"], "msg-456"; got != want {
|
|
t.Fatalf("payload.message_id = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestReplyComment_ReturnsError(t *testing.T) {
|
|
transport := &captureTransport{
|
|
resp: &http.Response{
|
|
StatusCode: http.StatusBadRequest,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"code":400,"message":"bad request"}`)),
|
|
},
|
|
}
|
|
client := NewAPIClient("user-token", "page-token", "page-123")
|
|
client.httpClient = &http.Client{Transport: transport}
|
|
|
|
if err := client.ReplyComment(context.Background(), "conv-123", "msg-456", "thank you"); err == nil {
|
|
t.Fatal("expected ReplyComment to return error on HTTP 400")
|
|
}
|
|
}
|
|
|
|
// --- PrivateReply ---
|
|
|
|
func TestPrivateReply_MatchesOfficialContract(t *testing.T) {
|
|
transport := &captureTransport{}
|
|
client := NewAPIClient("user-token", "page-token", "page-123")
|
|
client.httpClient = &http.Client{Transport: transport}
|
|
|
|
if err := client.PrivateReply(context.Background(), "conv-123", "thanks for commenting!"); err != nil {
|
|
t.Fatalf("PrivateReply returned error: %v", err)
|
|
}
|
|
|
|
if transport.req == nil {
|
|
t.Fatal("expected outbound request to be captured")
|
|
}
|
|
wantPath := "/api/public_api/v1/pages/page-123/conversations/conv-123/messages"
|
|
if got := transport.req.URL.Path; got != wantPath {
|
|
t.Fatalf("request path = %q, want %q", got, wantPath)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(transport.body, &payload); err != nil {
|
|
t.Fatalf("unmarshal payload: %v", err)
|
|
}
|
|
if got, want := payload["action"], "private_reply"; got != want {
|
|
t.Fatalf("payload.action = %#v, want %#v", got, want)
|
|
}
|
|
if got, want := payload["message"], "thanks for commenting!"; got != want {
|
|
t.Fatalf("payload.message = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestPrivateReply_ReturnsError(t *testing.T) {
|
|
transport := &captureTransport{
|
|
resp: &http.Response{
|
|
StatusCode: http.StatusBadRequest,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"code":400,"message":"conversation not found"}`)),
|
|
},
|
|
}
|
|
client := NewAPIClient("user-token", "page-token", "page-123")
|
|
client.httpClient = &http.Client{Transport: transport}
|
|
|
|
if err := client.PrivateReply(context.Background(), "conv-123", "hi"); err == nil {
|
|
t.Fatal("expected PrivateReply to return error on HTTP 400")
|
|
}
|
|
}
|
|
|
|
// --- GetPosts ---
|
|
|
|
func TestGetPosts_ParsesResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"success":true,"data":[{"id":"post-1","message":"Hello world","created_at":"2024-01-01"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewAPIClient("user-token", "page-token", "page-123")
|
|
client.pageV2BaseURL = srv.URL
|
|
client.httpClient = srv.Client()
|
|
|
|
posts, err := client.GetPosts(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("GetPosts returned error: %v", err)
|
|
}
|
|
if len(posts) != 1 {
|
|
t.Fatalf("expected 1 post, got %d", len(posts))
|
|
}
|
|
if posts[0].ID != "post-1" {
|
|
t.Errorf("posts[0].ID = %q, want %q", posts[0].ID, "post-1")
|
|
}
|
|
if posts[0].Message != "Hello world" {
|
|
t.Errorf("posts[0].Message = %q, want %q", posts[0].Message, "Hello world")
|
|
}
|
|
}
|
|
|
|
func TestGetPosts_EmptyResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(`{"success":true,"data":[]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewAPIClient("user-token", "page-token", "page-123")
|
|
client.pageV2BaseURL = srv.URL
|
|
client.httpClient = srv.Client()
|
|
|
|
posts, err := client.GetPosts(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("GetPosts returned error: %v", err)
|
|
}
|
|
if len(posts) != 0 {
|
|
t.Fatalf("expected 0 posts, got %d", len(posts))
|
|
}
|
|
}
|
|
|
|
func TestGetPosts_ErrorResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`internal error`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewAPIClient("user-token", "page-token", "page-123")
|
|
client.pageV2BaseURL = srv.URL
|
|
client.httpClient = srv.Client()
|
|
|
|
_, err := client.GetPosts(context.Background(), 10)
|
|
if err == nil {
|
|
t.Fatal("expected error on HTTP 500")
|
|
}
|
|
}
|
|
|
|
// --- Config Parsing ---
|
|
|
|
func TestConfigParsing_CommentReplyOptions(t *testing.T) {
|
|
raw := `{
|
|
"page_id": "123",
|
|
"features": {"comment_reply": true, "private_reply": true},
|
|
"comment_reply_options": {"filter": "keyword", "keywords": ["price", "buy"]},
|
|
"private_reply_message": "Thanks!",
|
|
"post_context_cache_ttl": "30m"
|
|
}`
|
|
|
|
var cfg pancakeInstanceConfig
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
if !cfg.Features.PrivateReply {
|
|
t.Error("Features.PrivateReply should be true")
|
|
}
|
|
if cfg.CommentReplyOptions.Filter != "keyword" {
|
|
t.Errorf("Filter = %q, want %q", cfg.CommentReplyOptions.Filter, "keyword")
|
|
}
|
|
if len(cfg.CommentReplyOptions.Keywords) != 2 ||
|
|
cfg.CommentReplyOptions.Keywords[0] != "price" ||
|
|
cfg.CommentReplyOptions.Keywords[1] != "buy" {
|
|
t.Errorf("Keywords = %v, want [price buy]", cfg.CommentReplyOptions.Keywords)
|
|
}
|
|
if cfg.PrivateReplyMessage != "Thanks!" {
|
|
t.Errorf("PrivateReplyMessage = %q, want %q", cfg.PrivateReplyMessage, "Thanks!")
|
|
}
|
|
if cfg.PostContextCacheTTL != "30m" {
|
|
t.Errorf("PostContextCacheTTL = %q, want %q", cfg.PostContextCacheTTL, "30m")
|
|
}
|
|
}
|
|
|
|
func TestPancakeConfig_AutoReactOptionsRoundtrip(t *testing.T) {
|
|
src := `{
|
|
"page_id": "123",
|
|
"platform": "facebook",
|
|
"features": {"auto_react": true},
|
|
"auto_react_options": {
|
|
"allow_post_ids": ["post-1", "post-2"],
|
|
"deny_post_ids": ["post-3"],
|
|
"allow_user_ids": ["user-a"],
|
|
"deny_user_ids": ["user-b", "user-c"]
|
|
}
|
|
}`
|
|
var cfg pancakeInstanceConfig
|
|
if err := json.Unmarshal([]byte(src), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if cfg.AutoReactOptions == nil {
|
|
t.Fatal("AutoReactOptions should be non-nil when JSON has auto_react_options")
|
|
}
|
|
if len(cfg.AutoReactOptions.AllowPostIDs) != 2 {
|
|
t.Errorf("AllowPostIDs len = %d, want 2", len(cfg.AutoReactOptions.AllowPostIDs))
|
|
}
|
|
if cfg.AutoReactOptions.DenyPostIDs[0] != "post-3" {
|
|
t.Errorf("DenyPostIDs[0] = %q, want post-3", cfg.AutoReactOptions.DenyPostIDs[0])
|
|
}
|
|
if len(cfg.AutoReactOptions.DenyUserIDs) != 2 {
|
|
t.Errorf("DenyUserIDs len = %d, want 2", len(cfg.AutoReactOptions.DenyUserIDs))
|
|
}
|
|
if cfg.AutoReactOptions.AllowUserIDs[0] != "user-a" {
|
|
t.Errorf("AllowUserIDs[0] = %q, want user-a", cfg.AutoReactOptions.AllowUserIDs[0])
|
|
}
|
|
}
|
|
|
|
func TestPancakeConfig_AutoReactOptionsOmitempty(t *testing.T) {
|
|
var cfg pancakeInstanceConfig
|
|
cfg.PageID = "123"
|
|
b, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if strings.Contains(string(b), "auto_react_options") {
|
|
t.Errorf("empty AutoReactOptions should be omitted, got: %s", b)
|
|
}
|
|
}
|
|
|
|
func TestPancakeConfig_AutoReactOptionsDefaultsEmpty(t *testing.T) {
|
|
src := `{"page_id": "123"}`
|
|
var cfg pancakeInstanceConfig
|
|
if err := json.Unmarshal([]byte(src), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if cfg.AutoReactOptions != nil {
|
|
t.Errorf("AutoReactOptions should be nil when absent, got %+v", cfg.AutoReactOptions)
|
|
}
|
|
}
|
|
|
|
func TestConfigParsing_Defaults(t *testing.T) {
|
|
raw := `{"page_id":"123","features":{"inbox_reply":true}}`
|
|
|
|
var cfg pancakeInstanceConfig
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
if cfg.Features.PrivateReply {
|
|
t.Error("Features.PrivateReply should default to false")
|
|
}
|
|
if cfg.CommentReplyOptions.Filter != "" {
|
|
t.Errorf("CommentReplyOptions.Filter should default to empty, got %q", cfg.CommentReplyOptions.Filter)
|
|
}
|
|
if cfg.PrivateReplyMessage != "" {
|
|
t.Errorf("PrivateReplyMessage should default to empty, got %q", cfg.PrivateReplyMessage)
|
|
}
|
|
}
|
|
|
|
// --- ReactComment (Pancake user API) ---
|
|
|
|
func TestReactComment_Success(t *testing.T) {
|
|
done := make(chan string, 1)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/likes") {
|
|
if got := r.URL.Query().Get("access_token"); got != "user-key" {
|
|
t.Errorf("access_token query = %q, want user-key", got)
|
|
}
|
|
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
|
|
t.Errorf("Content-Type = %q, want multipart/form-data", ct)
|
|
}
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
t.Errorf("ParseMultipartForm: %v", err)
|
|
}
|
|
if got := r.FormValue("action"); got != "like_toggle" {
|
|
t.Errorf("action = %q, want like_toggle", got)
|
|
}
|
|
if got := r.FormValue("user_likes"); got != "false" {
|
|
t.Errorf("user_likes = %q, want false", got)
|
|
}
|
|
select {
|
|
case done <- r.URL.Path:
|
|
default:
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"success":true}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewAPIClient("user-key", "page-token", "page123")
|
|
client.userBaseURL = srv.URL
|
|
client.httpClient = srv.Client()
|
|
|
|
err := client.ReactComment(context.Background(), "conv-1_msg-1", "conv-1_msg-1")
|
|
if err != nil {
|
|
t.Fatalf("ReactComment unexpected error: %v", err)
|
|
}
|
|
select {
|
|
case path := <-done:
|
|
want := "/pages/page123/conversations/conv-1_msg-1/messages/conv-1_msg-1/likes"
|
|
if path != want {
|
|
t.Errorf("path = %q, want %q", path, want)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("POST /likes was not called within 2s")
|
|
}
|
|
}
|
|
|
|
func TestReactComment_ErrorIncludesBody(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(`{"success":false,"message":"invalid access_token"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewAPIClient("bad-key", "page-token", "page123")
|
|
client.userBaseURL = srv.URL
|
|
client.httpClient = srv.Client()
|
|
|
|
err := client.ReactComment(context.Background(), "conv-1", "msg-1")
|
|
if err == nil {
|
|
t.Fatal("expected error for 401 response")
|
|
}
|
|
if !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "invalid access_token") {
|
|
t.Errorf("expected HTTP status + body in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReactComment_RejectsInvalidIDs(t *testing.T) {
|
|
client := NewAPIClient("key", "token", "page123")
|
|
cases := []struct{ conv, msg string }{
|
|
{"", "msg-1"},
|
|
{"conv-1", ""},
|
|
{"conv/1", "msg-1"},
|
|
{"conv-1", "msg?evil=1"},
|
|
{"conv#frag", "msg-1"},
|
|
}
|
|
for _, c := range cases {
|
|
if err := client.ReactComment(context.Background(), c.conv, c.msg); err == nil {
|
|
t.Errorf("expected error for conv=%q msg=%q, got nil", c.conv, c.msg)
|
|
}
|
|
}
|
|
}
|