diff --git a/.claude/settings.json b/.claude/settings.json index e06b033..d0659fa 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/claude-code-settings.json", "hooks": { "SessionStart": [ { @@ -8,6 +9,125 @@ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/session-start.mjs\"" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/prompt-submit.mjs\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|Read|Glob|Grep", + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/pre-tool-use.mjs\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/post-tool-use.mjs\"" + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/post-tool-failure.mjs\"" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/pre-compact.mjs\"" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/subagent-start.mjs\"" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/subagent-stop.mjs\"" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/notification.mjs\"" + } + ] + } + ], + "TaskCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/task-completed.mjs\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/stop.mjs\"" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(npm root -g)/@agentmemory/agentmemory/plugin/scripts/session-end.mjs\"" + } + ] } ] } diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..37f7754 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL:-http://localhost:3111}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET:-}", + "AGENTMEMORY_TOOLS": "${AGENTMEMORY_TOOLS:-all}" + } + } + } +} diff --git a/3q-hatchery-line-oa b/3q-hatchery-line-oa new file mode 160000 index 0000000..7f9b363 --- /dev/null +++ b/3q-hatchery-line-oa @@ -0,0 +1 @@ +Subproject commit 7f9b36368721cb82fa6d5b3ac20e715518ac402a diff --git a/agentmemory-server/Dockerfile b/agentmemory-server/Dockerfile new file mode 100644 index 0000000..0443240 --- /dev/null +++ b/agentmemory-server/Dockerfile @@ -0,0 +1,35 @@ +# agentmemory 後端容器(部署到 Railway) +# 把本機 npm 套件 @agentmemory/agentmemory 包成可上線的 REST 服務。 +# +# 重要事實(已在 v0.9.22 驗證): +# - REST 在 :3111;bundled iii-config.yaml 預設 host=127.0.0.1(僅 loopback)。 +# 要對外提供,下面用 sed 把 host 改成 0.0.0.0。 +# - 記憶資料落在工作目錄下的 ./data/(state_store.db + stream_store)。 +# => WORKDIR=/app,volume 掛在 /app/data 才能持久化。 +# - REST 預設「無驗證」(原設計假設 localhost)。對外暴露務必加保護, +# 見同目錄 README.md 的「安全」段(Railway 私網 / proxy + token)。 +FROM node:22-slim + +ENV AGENTMEMORY_HOME=/app/.agentmemory \ + NODE_ENV=production + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g @agentmemory/agentmemory@0.9.22 + +# 讓 REST 監聽所有介面(預設只綁 127.0.0.1,容器外連不到) +RUN CFG="$(npm root -g)/@agentmemory/agentmemory/dist/iii-config.yaml" \ + && sed -i 's/host: 127.0.0.1/host: 0.0.0.0/g' "$CFG" + +# 記憶資料目錄(Railway volume 掛載點) +RUN mkdir -p /app/data +VOLUME ["/app/data"] + +EXPOSE 3111 + +# Railway 會注入 $PORT;agentmemory 用 --port 覆寫 REST 埠。 +# 預設指令即啟動 worker + iii-engine。 +CMD ["sh", "-c", "agentmemory --port ${PORT:-3111}"] diff --git a/agentmemory-server/README.md b/agentmemory-server/README.md new file mode 100644 index 0000000..4bfd9e2 --- /dev/null +++ b/agentmemory-server/README.md @@ -0,0 +1,129 @@ +# agentmemory 後端上線 + 記憶同步 runbook + +把本機的 agentmemory(`@agentmemory/agentmemory` v0.9.22,REST 在 `:3111`)包成 +一個 Railway 上的長駐服務,讓四個 repo 的 `.mcp.json` 透過 `AGENTMEMORY_URL` +指向它,並把你**本機累積的記憶資料**同步上去。 + +> 格式同 `social_distributor/BROWSER_STEPS.md`:🟢 = 純複製貼上、無決策; +> 🔴 = 你本人必須親手做(登入、付款、貼密鑰、做判斷)。完成每步把產出貼回。 + +--- + +## ⚠️ 先讀:兩個必須處理的限制 + +1. **預設無驗證(安全)。** agentmemory REST 原設計假設 localhost,對外暴露等於 + 任何人都能讀寫你的記憶。**Dockerfile 已把監聽改成 `0.0.0.0`,但沒有加驗證。** + 上線前必須二擇一(見 §4): + - (A) 只用 Railway **私有網路**(不開 public domain),讓需要的服務走內網;或 + - (B) 在前面加一層帶 token 的反向代理 / 只允許特定來源。 + 雲端 Claude session 與你本機要連得到 → 若需公開,務必用 (B)。 +2. **Railway 需先儲值 $5。**(你的 `social_distributor/BROWSER_STEPS.md` 也記過這點。) + 不想付費的話,agentmemory 也可改用 Render,但 Render 免費層**無持久磁碟**, + 記憶會在每次重啟後消失 → 不建議。 + +--- + +## 1. 🔴 建 Railway 專案並儲值 + +- URL:https://railway.com/new +- 用 GitHub 登入(同 `milk790-code` 帳號) +- Billing 儲值 $5:https://railway.com/account/billing +- 完成後貼回「Railway 好了」 + +## 2. 🟢 從 repo 部署服務 + +- 在專案內 **+ New → Deploy from GitHub repo → `popmonster-vip`** +- **Settings → Source**: + - Root Directory = `agentmemory-server` + - Builder 會自動讀 `railway.json` → Dockerfile +- **Settings → Variables**(先設,再 deploy): + ``` + PORT=3111 + ``` + (Railway 通常自動注入 PORT;保險起見手動設。) + +## 3. 🟢 掛持久化 Volume(記憶 DB 一定要保存) + +- 服務 **Settings → Volumes → + New Volume** +- Mount Path = `/app/data` +- 大小 = 1 GB(之後可調大) +- 這對應容器內記憶資料的 `./data/`(`state_store.db` + `stream_store`) + +## 4. 🔴 安全:選一條路(見上方 §先讀-1) + +- **(A) 私網**:agentmemory service Settings → Networking → **不要** Generate Domain。 + 其他需要的 Railway 服務用內部 hostname 連 `http://agentmemory.railway.internal:3111`。 + ⚠️ 你本機 / 雲端 session 連不到 → 只適合「服務對服務」。 +- **(B) 公開 + token proxy**(已預先鋪好設定):agentmemory service 維持私網, + 另開一個 Caddy proxy service 對外。設定檔在 `proxy/`: + - 在同 Railway 專案 **+ New → Deploy from GitHub repo** → 一樣選 `popmonster-vip` + - Settings → Source → Root Directory = `agentmemory-server/proxy` + (它會自動讀 `proxy/railway.json` → `proxy/Dockerfile`) + - Settings → Variables: + ``` + AGENTMEMORY_INTERNAL_URL=http://agentmemory.railway.internal:3111 + AGENTMEMORY_SECRET=<自己產一串長字串,例:python3 -c "import secrets;print(secrets.token_urlsafe(40))"> + ``` + - Networking → **Generate Domain**(這個才對外) + - 之後對外用 `https://` 當 `AGENTMEMORY_URL`,請求帶 + `Authorization: Bearer `。 + 四個 repo 的 `.mcp.json` 已預留 `AGENTMEMORY_SECRET` 環境變數,會自動帶上。 + +> 建議路徑:先 (A) 確認 agentmemory 自己起得來;確認 OK 後再加 (B) proxy 對外。 + +## 5. 同步本機記憶資料 → 線上 volume + +**重要**:要同步的記憶在**你本機**那台跑 agentmemory 的機器上,不在雲端容器裡。 +雲端 session 是全新空白實例,無法代你匯出。請在**你本機**執行: + +```bash +# 本機:確認 agentmemory 在跑、看記憶數量 +agentmemory status + +# 找出本機記憶資料目錄(state_store.db + stream_store 所在的 ./data/) +# 通常在你最常啟動 agentmemory 的工作目錄下,或用 export 工具: +``` + +兩種同步法: + +- **法一(推薦):直接搬 data 目錄。** + 把本機的 `data/state_store.db` 與 `data/stream_store/` 整包, + 在 Railway volume(`/app/data`)還原。Railway 可用 + `railway run` / `railway volume` CLI 或臨時 shell 上傳。 +- **法二:用 MCP `memory_export` 匯出 JSON 備份。** + 注意:agentmemory **沒有對應的 import 指令**(CLI 只有 `import-jsonl`, + 那是匯入 Claude Code 轉錄,不是還原匯出 JSON)。所以 JSON 僅作**備份/保險**, + 正式同步仍走法一(搬 data 目錄)。 + +> 🔴 **退路**:若搬 data 目錄遇到版本/格式問題,退而求其次=線上實例重新開始累積, +> 本機 JSON 僅留存。是否接受此退路請告知。 + +## 6. 🟢 把四個 repo 指向線上實例 + +部署成功、拿到可連 URL 後(例如 `https://agentmemory-xxx.up.railway.app`), +把四個 repo 的 `.mcp.json` 裡: + +``` +"AGENTMEMORY_URL": "${AGENTMEMORY_URL:-http://localhost:3111}" +``` + +的預設值改成線上 URL(保留可被環境變數覆寫的形式)。我會在分支上一起改。 + +--- + +## 驗證清單 + +- [ ] Railway service 部署成功(build log 無錯、容器有起來) +- [ ] iii-engine 在容器內正常啟動(看 deploy log;native 引擎在 slim 容器是否需額外 + 依賴**尚待驗證** — 若起不來,改用 `AGENTMEMORY_USE_DOCKER=1` 路徑或加缺漏套件) +- [ ] `curl https:///` 或正確健康路徑有回應(**確切 health path 待驗證**) +- [ ] 用 MCP `memory_recall` 對線上 URL 查得到剛同步進去的舊記憶 +- [ ] 四個 repo 連線正常(`agentmemory status` 指向線上 URL 顯示 memory count > 0) + +## 尚待驗證的未知數(別當成已完成) + +1. **native iii-engine 在 `node:22-slim` 容器能否啟動** —— 可能缺系統依賴或需 Docker-in-Docker。 + 若失敗,試 `AGENTMEMORY_USE_DOCKER=1`(但 Railway 容器內跑 Docker 較麻煩)。 +2. **確切健康檢查路徑** —— 文件未明示;部署後實測。 +3. **data 目錄跨機/跨版本還原相容性** —— 同步前先在本機備份。 +4. **對外驗證機制** —— agentmemory 無內建 auth,§4 的保護方案需落實。 diff --git a/agentmemory-server/proxy/Caddyfile b/agentmemory-server/proxy/Caddyfile new file mode 100644 index 0000000..4ed0e5e --- /dev/null +++ b/agentmemory-server/proxy/Caddyfile @@ -0,0 +1,32 @@ +# Caddy 反向代理 + Bearer token 驗證 +# +# 部署為 Railway 上獨立 service(在 agentmemory service 前面),讓 agentmemory +# 維持私網、由本 proxy 對外提供帶 token 的存取。 +# +# 必填環境變數(在 Railway proxy service 設): +# AGENTMEMORY_INTERNAL_URL 例:http://agentmemory.railway.internal:3111 +# AGENTMEMORY_SECRET 對外 Bearer token(自己產一串長字串) +# +# 使用:所有對 proxy 公網域名的請求需帶 `Authorization: Bearer $AGENTMEMORY_SECRET`, +# proxy 才會轉發;否則回 401。 + +{ + # 不寫 admin API 對外 + admin off + auto_https off +} + +:{$PORT:8080} { + @noauth { + not header Authorization "Bearer {$AGENTMEMORY_SECRET}" + } + respond @noauth "Unauthorized" 401 { + close + } + + reverse_proxy {$AGENTMEMORY_INTERNAL_URL} { + header_up Host {http.reverse_proxy.upstream.host} + # 不把外部 Authorization 透傳給後端(避免被 log) + header_up -Authorization + } +} diff --git a/agentmemory-server/proxy/Dockerfile b/agentmemory-server/proxy/Dockerfile new file mode 100644 index 0000000..df63b77 --- /dev/null +++ b/agentmemory-server/proxy/Dockerfile @@ -0,0 +1,10 @@ +# agentmemory auth proxy(Caddy + Bearer token) +# Build/run 為 Railway 一個獨立 service(與 agentmemory service 同專案、Private Network)。 +FROM caddy:2-alpine + +COPY Caddyfile /etc/caddy/Caddyfile + +# Railway 注入 $PORT;Caddy 讀 {$PORT:8080} +EXPOSE 8080 + +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/agentmemory-server/proxy/railway.json b/agentmemory-server/proxy/railway.json new file mode 100644 index 0000000..fe89d7a --- /dev/null +++ b/agentmemory-server/proxy/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" + }, + "deploy": { + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} diff --git a/agentmemory-server/railway.json b/agentmemory-server/railway.json new file mode 100644 index 0000000..fe89d7a --- /dev/null +++ b/agentmemory-server/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" + }, + "deploy": { + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} diff --git a/css/main.css b/css/main.css index f6cbbaa..ba19a6a 100644 --- a/css/main.css +++ b/css/main.css @@ -421,6 +421,24 @@ a:hover{color:var(--gold-lt)} .section-title{font-size:20px} } +/* === MOBILE-FRIENDLY MOTION PACK(2026-06-07 夜班,全 transform/opacity GPU 動畫) === */ +@media(hover:none){ + .card:active{transform:translateY(-2px) scale(.985);border-color:var(--gold)} + .btn:active,.card-btn:active,.filter-btn:active{transform:scale(.96)} + .related-card:active,.blog-card:active,.brand-card:active{transform:scale(.985)} +} +.btn,.card-btn,.filter-btn,.card{-webkit-tap-highlight-color:transparent} +@keyframes imgIn{from{opacity:0;transform:scale(1.02)}to{opacity:1;transform:scale(1)}} +.card-img img,.featured-img img{animation:imgIn .55s ease both} +@keyframes sheen{0%{background-position:-120% 0}55%{background-position:220% 0}100%{background-position:220% 0}} +.btn-gold{position:relative;overflow:hidden} +.btn-gold::after{content:'';position:absolute;inset:0;background:linear-gradient(115deg,transparent 38%,rgba(255,245,220,.45) 50%,transparent 62%);background-size:240% 100%;animation:sheen 4.6s ease-in-out infinite;pointer-events:none} +.grid .fade-up:nth-child(2){transition-delay:.06s} +.grid .fade-up:nth-child(3){transition-delay:.12s} +.grid .fade-up:nth-child(4){transition-delay:.18s} +@keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}} +.featured-badge,.card-img .badge{animation:floaty 3.6s ease-in-out infinite} + @media(prefers-reduced-motion:reduce){ *,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important} .fade-up{opacity:1;transform:none} diff --git a/img/a001-main.jpg b/img/a001-main.jpg index b0e0ec2..354b554 100644 Binary files a/img/a001-main.jpg and b/img/a001-main.jpg differ diff --git a/img/a003-main.jpg b/img/a003-main.jpg index 417e503..df46b5b 100644 Binary files a/img/a003-main.jpg and b/img/a003-main.jpg differ diff --git a/img/a004-main.jpg b/img/a004-main.jpg index 3908e47..7885dba 100644 Binary files a/img/a004-main.jpg and b/img/a004-main.jpg differ diff --git a/img/a005-main.jpg b/img/a005-main.jpg index 700925b..2efa1ab 100644 Binary files a/img/a005-main.jpg and b/img/a005-main.jpg differ diff --git a/img/a007-main.jpg b/img/a007-main.jpg index 3c1c366..6e0d8b7 100644 Binary files a/img/a007-main.jpg and b/img/a007-main.jpg differ diff --git a/img/a008-main.jpg b/img/a008-main.jpg index b3967d0..bd9355b 100644 Binary files a/img/a008-main.jpg and b/img/a008-main.jpg differ diff --git a/index.html b/index.html index febf325..69a8a9d 100644 --- a/index.html +++ b/index.html @@ -348,4 +348,5 @@

全系列產品

+ \ No newline at end of file diff --git a/referral.html b/referral.html new file mode 100644 index 0000000..da19f84 --- /dev/null +++ b/referral.html @@ -0,0 +1,306 @@ + + + + + +我的推薦碼 | 泡泡怪獸 POP MONSTER + + + + + + +
+
+

載入中…

+
+ + + +
+ + + + diff --git a/social_distributor/BROWSER_STEPS.md b/social_distributor/BROWSER_STEPS.md index 590ea33..193836c 100644 --- a/social_distributor/BROWSER_STEPS.md +++ b/social_distributor/BROWSER_STEPS.md @@ -405,11 +405,120 @@ TIKTOK_REDIRECT_URI=https://api.popmonster.vip/auth/tiktok/callback --- +## Phase T — Threads OAuth(3Q LINE OA 用) + +> **背景**:Threads API 只能掛在「消費者 (Consumer)」類型的 Meta App 上,不能掛在商業 (Business) 類型的 App 上。 +> 現有的 `956987317313843`(popmonster-distributor-v2)和 `1845315733092457`(3q-hatchery-)都是 Business 類型,無法使用 Threads API。 +> 需要新建一個 Consumer App,專門給 3Q LINE OA 的 Threads 自動換 token 用。 + +### T1 🔴 建立 Consumer 類型 Meta App + +直連 URL:`https://developers.facebook.com/apps/create/` + +操作步驟: +1. 進入頁面後 **App Type** 選 **「消費者 (Consumer)」** ← 這一步是關鍵,不能選「企業商家 (Business)」 +2. App 名稱: + +``` +3q-threads-consumer +``` + +3. Contact email 填你的信箱(自動帶入) +4. 點 **建立應用程式** → 系統可能要求輸入 Meta 密碼 🔴 +5. 建立成功後,**把 App ID 貼回給我** + +### T2 🟢 加 Threads API 產品(T1 完成後我來提示你) + +- 在新 App 的 Dashboard 左側:**Add Product** → 找到 **Threads API** → 點 **Set Up** +- (如果 Add Product 清單裡看不到 Threads API,代表 App Type 選錯了,請刪除重建) + +### T3 🟢 設 OAuth Redirect URI + +位置:Threads API → Settings(設定頁) + +**Valid OAuth Redirect URI** 填入: + +``` +https://milk790-code.github.io/3q-hatchery-line-oa/assets/threads-auth.html +``` + +點 **Save**。 + +### T4 🔴 取得 App Secret + +- App Dashboard → **Settings → Basic** +- 在 **App Secret** 欄位按 **Show** → 系統要求輸入 Meta 密碼 🔴 +- 複製後**把 App ID + App Secret 一起貼回給我** + +### T5 🟢(我來做)更新 3Q LINE OA 相關設定 + +拿到 T1 的 App ID + T4 的 App Secret 之後,我自動完成: + +1. `3q-hatchery-line-oa/assets/threads-auth.html` 的 `APP_ID` 更新 +2. `.github/workflows/threads-token-setup.yml` 的 `THREADS_APP_ID` 更新 +3. 呼叫 GitHub API 把 `THREADS_APP_SECRET` 寫入 GitHub Secret(取代舊的) +4. 推送到 main 分支並 dispatch workflow 完成首次 token 換取 + +--- + ## 走到這裡你會擁有 ✅ 一個生產 backend `https://api.popmonster.vip` ✅ 前端 magic-link 登入頁 `https://app.popmonster.vip` ✅ 三個平台 OAuth ID/Secret 可以連社群帳號 ✅ R2 bucket 可以收 5 支影片 +✅ (Phase T 完成後)Threads 自動換 token → 3Q LINE OA Threads 帳號接入 → 接著走 `RUNBOOK_SEED_SPRINT.md` Phase 5–8(建 AccountGroup、上傳影片、seed、worker) + +--- + +## Phase Th — Threads OAuth 接入(低垂果實) + +> Threads adapter 已完工(`social_distributor/backend/app/platforms/threads.py`)。 +> OAuth 路由 `/auth/threads/start` 也已就緒。 +> 只差以下三步把你的 Threads 帳號接進來。 + +### Th.1 🔴 建立 Threads 開發者應用 +- URL:https://developers.facebook.com/apps/ +- 右上角「建立應用程式」→ 類型選 **Other** → Use Case 選 **Threads API** +- App 名稱:`PopMonsterDistributor`(或任意名) +- 完成後進入 App → 找到 `App ID` 和 `App Secret`(需點「顯示」) +- 回來貼:`App ID = ___` 和 `App Secret = ___` + +### Th.2 🟢 設定 Redirect URI +- 同一個 App 頁面 → Products → Threads → Basic Display 或 Threads API Settings +- Valid OAuth Redirect URIs 加入(視你的部署網址): +``` +https://api-production-6de7.up.railway.app/auth/threads/callback +``` +- 同時加入 Deauthorize Callback URL(可隨便填,如 `https://api-production-6de7.up.railway.app/auth/threads/deauth`) + +### Th.3 🟢 把 Threads App ID/Secret 寫進 Railway Variables +- URL:https://railway.com(找你的 social-distributor project → Variables tab) +- 新增三個 Variables: +``` +THREADS_APP_ID=(你的 App ID) +THREADS_APP_SECRET=(你的 App Secret) +THREADS_REDIRECT_URI=https://api-production-6de7.up.railway.app/auth/threads/callback +``` +- 儲存後等 Railway 重新部署完成 + +### Th.4 🔴 走 OAuth 連接你的 Threads 帳號 +- 在 Social Distributor dashboard 的 Accounts tab,點「Connect Threads」(或直接打開): +``` +https://api-production-6de7.up.railway.app/auth/threads/start?user_id=1 +``` +- 會跳轉到 Threads 登入頁 → 授權 → 自動跳回顯示「已連接 ✓」 +- 連接成功後,Accounts tab 會出現你的 Threads 帳號 + +### Th.5 🟢 排程 7 天文案到 Threads(13:00 時段) +- 在 Social Distributor,Distribute tab 選天使 7 天文案的素材 +- 人設群組選你的 Threads 群組 +- 排程時間設 13:00(IG 09:00 已用,Threads 用 13:00 空檔) +- 一次排 7 天(每天 13:00)點「Distribute」 +- 或透過 brain.py 直接生成排程 JSON: +```bash +cd C:\POP\智能發布中樞 +python brain.py plan --days 7 --start 2026-06-08 --platforms threads +``` diff --git a/social_distributor/MULTITENANT_EVAL.md b/social_distributor/MULTITENANT_EVAL.md new file mode 100644 index 0000000..f22b636 --- /dev/null +++ b/social_distributor/MULTITENANT_EVAL.md @@ -0,0 +1,112 @@ +# 多租戶架構評估 · Social Distributor + +> 目的:支撐「內部引擎 vs 服務商產品」的定位拍板。 +> 讓學誼用工程量數字做決策,而不是空談架構。 + +--- + +## 當前狀態(單租戶) + +| 項目 | 現況 | +|---|---| +| 用戶識別 | `user_id=1` 寫死在多處;`AUTO_SEED_USER` 自動建第一個 User | +| Token 隔離 | `SocialAccount.user_id` FK 存在,但 API 層有些路由未嚴格過濾 | +| 計費 | 無 | +| 帳號數 | 1 個 User,59 個 SocialAccount(估計) | +| 部署 | Railway 單容器(api + worker) | + +--- + +## 改動點清單 + +### 1. 資料模型(Models) + +| 表 | 需要加的欄位 | 工時估計 | +|---|---|---| +| `users` | 無(已有 User model) | — | +| `social_accounts` | 已有 `user_id` FK,**但要驗全部 query 都帶 WHERE user_id=** | 0.5 天審計 | +| `account_groups` | 已有 `user_id` FK | 驗 query 一致性 | +| `posts` | 已有 `user_id` FK | 同上 | +| `post_targets` | 透過 `posts.user_id` 間接隔離,需補直接 FK 或 JOIN 檢查 | 半天 | +| 新增 `tenants` 表 | `id, name, plan, created_at, stripe_customer_id` | 0.5 天 | +| `users` | 加 `tenant_id FK` | migration 1 行 | +| 所有資源表 | 長期目標:把 `user_id` 升格成 `tenant_id`,支援同租戶多用戶 | 3 天(大重構) | + +### 2. Token 隔離 + +現況:`SocialAccount.access_token_enc` 用 Fernet 加密,key 全局共用(`TOKEN_ENCRYPTION_KEY`)。 + +多租戶後: +- **最小改動**:維持全局加密 key,依靠 DB `user_id` 行級隔離。工時:0。 +- **強隔離**:每租戶獨立 Fernet key,存在 `tenants.token_key_enc`(再用主 key 包一層)。工時:2 天 + 遷移腳本。 + +推薦:先走最小改動,強隔離等 SOC 2 壓力來了再做。 + +### 3. API 層(鑑權 + 隔離) + +已有: +- Flask session cookie 鑑權(C3 magic-link login) +- `attach_user_id_middleware` 注入 `g.current_user_id` + +需改: +- 所有 `query(X).filter_by(user_id=g.current_user_id)` 改成同時過濾 `tenant_id`(如果引入租戶概念)。 +- API Key 鑑權(指令1 已加)要加「key 屬於哪個租戶」的映射表。 +- 工時估計:**3 天**(全面 grep + 修 + 測試) + +### 4. 計費掛在哪 + +最小路徑: +1. `tenants.plan` = `free | starter | pro` +2. `api/__init__.py` 的 `before_request` 讀 plan,超出配額回 402 +3. 計費事件 webhook → Stripe → 更新 `tenants.plan` + +工時:**2 天**(Stripe webhook handler + plan 欄位 + 配額中間件) + +### 5. 現有 59 帳號遷移 + +```sql +-- 假設現有所有 social_accounts 都歸屬 user_id=1 的學誼 +-- 遷移步驟: +-- 1. 建 tenants 表,INSERT 一筆(學誼的租戶) +-- 2. ALTER TABLE users ADD COLUMN tenant_id INT NOT NULL DEFAULT 1 +-- 3. 所有 59 個 social_accounts 的 user_id=1 本就是學誼,不需動 +-- 零停機:Column default + backfill 在 Postgres 可在線完成 +``` + +總遷移工時:**半天**(因為只有 1 個 user,風險極低)。 + +--- + +## 工程量彙整 + +| 範疇 | 最小改動(2租戶可用) | 完整多租戶 | +|---|---|---| +| 資料模型 | 2 天 | 5 天 | +| API 層隔離 | 3 天 | 3 天 | +| 計費 | 2 天 | 4 天(含 Stripe 整合) | +| Token 強隔離 | 0(走 DB 行級) | 2 天 | +| 遷移 59 帳號 | 0.5 天 | 0.5 天 | +| **合計** | **7.5 工作天** | **14.5 工作天** | + +--- + +## 決策建議 + +| 定位 | 建議 | +|---|---| +| **內部引擎**(只服務自己三品牌) | 不改,現有 user_id 隔離已夠;投資報酬率極低 | +| **服務商雛型**(招募 2–5 個付費測試客) | 走「最小改動」7.5 天,先收費再做強隔離 | +| **正式 SaaS**(10+ 付費客) | 完整多租戶 14.5 天 + Stripe + SOC 2 審計 | + +**現實建議**:先以 `user_id` 做邏輯隔離(實質上已有),加上指令1的 API Key, +手動為每個測試客建一個 User row + 發一個獨立 API Key。 +等客戶數到 5 個再做真正的 `tenants` 表重構。 +這樣可以在 0 天額外工程的前提下先開始收費驗證市場。 + +--- + +## 風險注意 + +- `PostTarget` 沒有直接 `user_id`,靠 JOIN `posts`,如果未來有 bulk query 要過濾要特別注意。 +- Celery tasks 目前不過濾 user_id(全局掃 schedules),多租戶後需要加租戶範圍或做 task 隔離。 +- Rate limit(Redis key 格式 `rl:{platform}:{account_id}`)本就帳號級隔離,不需改。 diff --git a/social_distributor/backend/.env.example b/social_distributor/backend/.env.example index 7093b8a..d947e8e 100644 --- a/social_distributor/backend/.env.example +++ b/social_distributor/backend/.env.example @@ -2,6 +2,8 @@ FLASK_ENV=development SECRET_KEY=replace-with-32+byte-random-string TOKEN_ENCRYPTION_KEY=replace-with-fernet-key +# API key guard for /api/* routes (generate: openssl rand -hex 24) +API_KEY= # Database / cache DATABASE_URL=postgresql+psycopg2://distributor:distributor@db:5432/distributor @@ -24,6 +26,11 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:5000/auth/youtube/callback +# OAuth - Threads (Meta developer app — separate from Meta/IG app) +THREADS_APP_ID= +THREADS_APP_SECRET= +THREADS_REDIRECT_URI=http://localhost:5000/auth/threads/callback + # Compliance APIs PERSPECTIVE_API_KEY= AWS_ACCESS_KEY_ID= @@ -50,7 +57,7 @@ RETRY_BACKOFF_BASE_SECONDS=30 # Caption variant engine (Claude). Without a key, the system falls back to a # deterministic template-based variant. ANTHROPIC_API_KEY= -ANTHROPIC_VARIANT_MODEL=claude-haiku-4-5-20251001 +ANTHROPIC_VARIANT_MODEL=claude-opus-4-6 # Per-platform rate limit overrides (calls:window_seconds). Defaults are # conservative and based on each platform's documented developer limits. diff --git a/social_distributor/backend/app/__init__.py b/social_distributor/backend/app/__init__.py index 3351acf..98189b2 100644 --- a/social_distributor/backend/app/__init__.py +++ b/social_distributor/backend/app/__init__.py @@ -1,6 +1,7 @@ """Flask app factory.""" from __future__ import annotations +import hmac import logging import os @@ -30,6 +31,23 @@ def create_app() -> Flask: db.init_app(app) migrate.init_app(app, db) + + # Instruction 1: API key guard for all /api/* routes. + # Set API_KEY in Railway Variables (openssl rand -hex 24). + # Routes under /auth/* and /healthz* are exempt. + _api_key = os.environ.get("API_KEY", "").strip() + if _api_key: + @app.before_request + def _api_key_guard(): + from flask import request as _req + if not _req.path.startswith("/api/"): + return + client = _req.headers.get("X-API-Key", "") + if not hmac.compare_digest( + client.encode("utf-8"), _api_key.encode("utf-8") + ): + return jsonify({"error": "unauthorized"}), 401 + raw_origins = os.environ.get("CORS_ALLOWED_ORIGINS", "*").strip() cors_origins = ( "*" if raw_origins in ("", "*") diff --git a/social_distributor/backend/app/auth/routes.py b/social_distributor/backend/app/auth/routes.py index 3a4a3fe..8127022 100644 --- a/social_distributor/backend/app/auth/routes.py +++ b/social_distributor/backend/app/auth/routes.py @@ -47,6 +47,7 @@ PROVIDERS = { "meta": {"platforms": [Platform.FACEBOOK, Platform.INSTAGRAM]}, + "threads": {"platforms": [Platform.META_THREADS]}, "tiktok": {"platforms": [Platform.TIKTOK]}, "youtube": {"platforms": [Platform.YOUTUBE]}, "shopee": {"platforms": [Platform.SHOPEE]}, @@ -315,6 +316,30 @@ def upsert(platform: Platform, ext_id: str, handle: str, {}, ) ) + elif provider == "threads": + # Resolve the Threads user profile (id + username) using /me. + from ..platforms.threads import THREADS_BASE + me = request_json( + "GET", + f"{THREADS_BASE}/me", + params={ + "fields": "id,username", + "access_token": bundle.access_token, + }, + ) + threads_user_id = me["id"] + username = me.get("username", threads_user_id) + created.append( + upsert( + Platform.META_THREADS, + threads_user_id, + username, + bundle.access_token, + bundle.refresh_token, + bundle.expires_at, + bundle.extra, + ) + ) elif provider == "shopee": # shop_id is passed back by Shopee in the redirect alongside the code. # The callback receives ?code=&shop_id=&state=. diff --git a/social_distributor/backend/app/config.py b/social_distributor/backend/app/config.py index 4c8e7ba..713926f 100644 --- a/social_distributor/backend/app/config.py +++ b/social_distributor/backend/app/config.py @@ -76,6 +76,11 @@ class Config: def platform(self, name: str) -> PlatformCredentials: mapping = { "meta": ("META_APP_ID", "META_APP_SECRET", "META_REDIRECT_URI"), + "threads": ( + "THREADS_APP_ID", + "THREADS_APP_SECRET", + "THREADS_REDIRECT_URI", + ), "tiktok": ( "TIKTOK_CLIENT_KEY", "TIKTOK_CLIENT_SECRET", diff --git a/social_distributor/backend/app/models.py b/social_distributor/backend/app/models.py index d47279b..aa181ed 100644 --- a/social_distributor/backend/app/models.py +++ b/social_distributor/backend/app/models.py @@ -417,6 +417,38 @@ class OwnershipTransfer(db.Model): raw: Mapped[dict] = mapped_column(JSON, default=dict) +class TokenExpiryAlert(db.Model): + """Upcoming token expiration or FB liveness failure for a SocialAccount. + + kind: + - ``expiring_soon`` — token_expires_at is within 7 days + - ``needs_reauth`` — FB page token failed the /me liveness probe + """ + + __tablename__ = "token_expiry_alerts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + account_id: Mapped[int] = mapped_column( + ForeignKey("social_accounts.id"), nullable=False + ) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + detail: Mapped[dict] = mapped_column(JSON, default=dict) + detected_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + resolved_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + notified_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + account: Mapped["SocialAccount"] = relationship() + + class RebroadcastCandidate(db.Model): """A historical post discovered on a connected account, available to promote into a new ``Post`` for re-distribution to a persona group. diff --git a/social_distributor/backend/app/platforms/registry.py b/social_distributor/backend/app/platforms/registry.py index 728d961..e2f7177 100644 --- a/social_distributor/backend/app/platforms/registry.py +++ b/social_distributor/backend/app/platforms/registry.py @@ -6,12 +6,14 @@ from .facebook import FacebookPublisher, MetaOAuth from .instagram import InstagramPublisher from .shopee import ShopeeOAuth, ShopeePublisher +from .threads import ThreadsOAuth, ThreadsPublisher from .tiktok import TikTokOAuth, TikTokPublisher from .youtube import YouTubeOAuth, YouTubePublisher _PUBLISHERS: dict[Platform, Publisher] = { Platform.FACEBOOK: FacebookPublisher(), Platform.INSTAGRAM: InstagramPublisher(), + Platform.META_THREADS: ThreadsPublisher(), Platform.TIKTOK: TikTokPublisher(), Platform.YOUTUBE: YouTubePublisher(), Platform.SHOPEE: ShopeePublisher(), @@ -19,6 +21,7 @@ _OAUTH: dict[str, OAuthProvider] = { "meta": MetaOAuth(), + "threads": ThreadsOAuth(), "tiktok": TikTokOAuth(), "youtube": YouTubeOAuth(), "shopee": ShopeeOAuth(), diff --git a/social_distributor/backend/app/platforms/threads.py b/social_distributor/backend/app/platforms/threads.py new file mode 100644 index 0000000..a72c7c9 --- /dev/null +++ b/social_distributor/backend/app/platforms/threads.py @@ -0,0 +1,204 @@ +"""Threads platform adapter. + +OAuth uses graph.threads.net (not graph.facebook.com). +Publishing is a two-step process: + 1. Create a media container + 2. Publish the container + +Docs: https://developers.facebook.com/docs/threads/getting-started +""" +from __future__ import annotations + +import os +import time +from datetime import datetime, timedelta, timezone +from typing import Any + +import requests + +from .base import ( + OAuthProvider, + PlatformError, + PublishRequest, + PublishResult, + Publisher, + TokenBundle, +) + +_THREADS_API = "https://graph.threads.net/v1.0" +_AUTH_BASE = "https://www.threads.net/oauth" + +APP_ID = os.getenv("THREADS_APP_ID", "") +APP_SECRET = os.getenv("THREADS_APP_SECRET", "") +REDIRECT = os.getenv("THREADS_REDIRECT_URI", "") + + +def _raise(resp: requests.Response, context: str) -> None: + try: + err = resp.json().get("error", {}) + msg = err.get("message", resp.text) + code = str(err.get("code", resp.status_code)) + except Exception: + msg = resp.text or "unknown error" + code = str(resp.status_code) + retryable = resp.status_code >= 500 + raise PlatformError( + f"Threads {context}: {msg}", + retryable=retryable, + status_code=resp.status_code, + platform_code=code, + ) + + +class ThreadsOAuth(OAuthProvider): + name = "threads" + + def authorization_url(self, state: str) -> str: + scope = "threads_basic,threads_content_publish" + return ( + f"{_AUTH_BASE}/authorize" + f"?client_id={APP_ID}" + f"&redirect_uri={REDIRECT}" + f"&scope={scope}" + f"&response_type=code" + f"&state={state}" + ) + + def exchange_code(self, code: str) -> TokenBundle: + # Step 1: short-lived token + r = requests.post( + f"{_AUTH_BASE}/access_token", + data={ + "client_id": APP_ID, + "client_secret": APP_SECRET, + "grant_type": "authorization_code", + "redirect_uri": REDIRECT, + "code": code, + }, + timeout=30, + ) + if not r.ok: + _raise(r, "code exchange") + short = r.json()["access_token"] + + # Step 2: long-lived token (valid 60 days) + r2 = requests.get( + "https://graph.threads.net/access_token", + params={ + "grant_type": "th_exchange_token", + "client_id": APP_ID, + "client_secret": APP_SECRET, + "access_token": short, + }, + timeout=30, + ) + if not r2.ok: + # Fall back to short-lived if exchange fails + expires = datetime.now(timezone.utc) + timedelta(hours=1) + return TokenBundle(access_token=short, expires_at=expires) + + data = r2.json() + token = data.get("access_token", short) + expires_in = data.get("expires_in", 5_184_000) # 60 days + expires = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + return TokenBundle(access_token=token, expires_at=expires) + + def refresh(self, refresh_token: str) -> TokenBundle: + """Threads uses token refresh via th_refresh_token.""" + r = requests.get( + "https://graph.threads.net/refresh_access_token", + params={ + "grant_type": "th_refresh_token", + "access_token": refresh_token, + }, + timeout=30, + ) + if not r.ok: + _raise(r, "token refresh") + data = r.json() + token = data["access_token"] + expires_in = data.get("expires_in", 5_184_000) + expires = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + return TokenBundle(access_token=token, expires_at=expires) + + +class ThreadsPublisher(Publisher): + name = "threads" + _POLL_MAX = 30 + _POLL_INTERVAL = 2 + + def publish( + self, + token: TokenBundle, + external_account_id: str, + request: PublishRequest, + ) -> PublishResult: + at = token.access_token + + # Step 1: create container + payload: dict[str, Any] = { + "access_token": at, + "text": request.caption, + } + if request.media_url and request.media_kind == "video": + payload.update({"media_type": "VIDEO", "video_url": request.media_url}) + elif request.media_url and request.media_kind == "image": + payload.update({"media_type": "IMAGE", "image_url": request.media_url}) + else: + payload["media_type"] = "TEXT" + + r = requests.post( + f"{_THREADS_API}/{external_account_id}/threads", + data=payload, + timeout=60, + ) + if not r.ok: + _raise(r, "create container") + container_id = r.json()["id"] + + # Step 2: poll until container is ready + for _ in range(self._POLL_MAX): + time.sleep(self._POLL_INTERVAL) + status_r = requests.get( + f"{_THREADS_API}/{container_id}", + params={"fields": "status,error_message", "access_token": at}, + timeout=15, + ) + if status_r.ok: + st = status_r.json().get("status", "") + if st == "FINISHED": + break + if st == "ERROR": + err_msg = status_r.json().get("error_message", "unknown") + raise PlatformError(f"Threads container error: {err_msg}", retryable=False) + + # Step 3: publish + pub_r = requests.post( + f"{_THREADS_API}/{external_account_id}/threads_publish", + data={"creation_id": container_id, "access_token": at}, + timeout=30, + ) + if not pub_r.ok: + _raise(pub_r, "publish") + post_id = pub_r.json()["id"] + + permalink = None + info_r = requests.get( + f"{_THREADS_API}/{post_id}", + params={"fields": "permalink", "access_token": at}, + timeout=15, + ) + if info_r.ok: + permalink = info_r.json().get("permalink") + + return PublishResult( + external_post_id=post_id, + permalink=permalink, + raw=pub_r.json(), + ) + + def validate(self, request: PublishRequest) -> list[str]: + issues = [] + if len(request.caption) > 500: + issues.append("Threads caption exceeds 500 characters") + return issues diff --git a/social_distributor/backend/app/scheduler/celery_app.py b/social_distributor/backend/app/scheduler/celery_app.py index d0722dc..e1a6051 100644 --- a/social_distributor/backend/app/scheduler/celery_app.py +++ b/social_distributor/backend/app/scheduler/celery_app.py @@ -66,6 +66,13 @@ def make_celery(flask_app=None) -> Celery: "task": "app.scheduler.tasks.weekly_insights_digest", "schedule": crontab(minute=0, hour=9, day_of_week="mon"), }, + # Instruction 2: daily token expiry scan + FB liveness probe. + # Runs 08:00 UTC every day — early enough to catch 7-day warnings + # before the work day starts in Asia/Taipei (UTC+8 = 16:00). + "sweep-expiring-tokens": { + "task": "app.scheduler.tasks.sweep_expiring_tokens", + "schedule": crontab(minute=0, hour=8), + }, }, ) diff --git a/social_distributor/backend/app/scheduler/tasks.py b/social_distributor/backend/app/scheduler/tasks.py index a27293c..67306c6 100644 --- a/social_distributor/backend/app/scheduler/tasks.py +++ b/social_distributor/backend/app/scheduler/tasks.py @@ -12,7 +12,10 @@ from ..compliance import ComplianceEngine from ..compliance.engine import publisher_request_from from ..extensions import db -from ..models import JobStatus, MediaAsset, PostMetric, PostTarget, SocialAccount, User +from ..models import ( + JobStatus, MediaAsset, Platform, PostMetric, PostTarget, SocialAccount, + TokenExpiryAlert, User, +) from ..platforms import get_oauth_provider, get_publisher from ..platforms.base import PlatformError, TokenBundle from ..utils.events import publish_event @@ -524,3 +527,117 @@ def ingest_insights() -> None: fetched += 1 db.session.commit() log.info("insights ingestion: %d snapshots stored", fetched) + + +@celery_app.task +def sweep_expiring_tokens() -> dict: + """Daily scan: alert on tokens expiring within 7 days + probe FB liveness. + + Writes TokenExpiryAlert rows (upsert by account+kind so re-runs are safe). + Returns a JSON-serialisable report for auditing. + """ + now = datetime.now(timezone.utc) + threshold_7d = now + timedelta(days=7) + + # --- Tokens with known expiry within 7 days --- + expiring = ( + db.session.query(SocialAccount) + .filter(SocialAccount.revoked_at.is_(None)) + .filter(SocialAccount.token_expires_at.isnot(None)) + .filter(SocialAccount.token_expires_at > now) + .filter(SocialAccount.token_expires_at <= threshold_7d) + .all() + ) + + # --- FB page tokens have no expiry — probe liveness via /me?fields=id --- + fb_accounts = ( + db.session.query(SocialAccount) + .filter(SocialAccount.revoked_at.is_(None)) + .filter(SocialAccount.platform == Platform.FACEBOOK) + .filter(SocialAccount.token_expires_at.is_(None)) + .all() + ) + + report: dict = {"expiring_soon": [], "needs_reauth": []} + + for acct in expiring: + _upsert_token_alert( + acct, "expiring_soon", + expires_at=acct.token_expires_at, + detail={"days_left": (acct.token_expires_at - now).days}, + ) + report["expiring_soon"].append({ + "account_id": acct.id, + "handle": acct.handle, + "platform": acct.platform.value, + "expires_at": acct.token_expires_at.isoformat(), + }) + log.warning( + "token expiring soon: account_id=%s handle=%s platform=%s expires=%s", + acct.id, acct.handle, acct.platform.value, + acct.token_expires_at.isoformat(), + ) + + for acct in fb_accounts: + alive = _probe_fb_liveness(acct) + if not alive: + _upsert_token_alert(acct, "needs_reauth", expires_at=None, + detail={"reason": "/me probe failed"}) + report["needs_reauth"].append({ + "account_id": acct.id, + "handle": acct.handle, + "platform": "facebook", + }) + log.warning( + "FB page token liveness failed: account_id=%s handle=%s", + acct.id, acct.handle, + ) + + db.session.commit() + log.info( + "sweep_expiring_tokens: %d expiring, %d needs_reauth", + len(report["expiring_soon"]), len(report["needs_reauth"]), + ) + return report + + +def _upsert_token_alert( + account: SocialAccount, + kind: str, + *, + expires_at, + detail: dict, +) -> None: + existing = ( + db.session.query(TokenExpiryAlert) + .filter_by(account_id=account.id, kind=kind) + .filter(TokenExpiryAlert.resolved_at.is_(None)) + .one_or_none() + ) + if existing: + existing.expires_at = expires_at + existing.detail = detail + else: + db.session.add(TokenExpiryAlert( + account_id=account.id, + kind=kind, + expires_at=expires_at, + detail=detail, + )) + + +def _probe_fb_liveness(account: SocialAccount) -> bool: + """Return True if the FB token can authenticate /me, False otherwise.""" + try: + import requests as _requests + c = cipher() + token = c.decrypt(account.access_token_enc) + r = _requests.get( + "https://graph.facebook.com/me", + params={"fields": "id", "access_token": token}, + timeout=10, + ) + return r.ok and "id" in r.json() + except Exception as exc: + log.debug("FB liveness probe failed for account %s: %s", account.id, exc) + return False diff --git a/social_distributor/backend/app/utils/variants.py b/social_distributor/backend/app/utils/variants.py index 56941f2..bbcbc9d 100644 --- a/social_distributor/backend/app/utils/variants.py +++ b/social_distributor/backend/app/utils/variants.py @@ -6,10 +6,11 @@ many accounts is what platforms' integrity heuristics flag as spam, so a matrix workflow needs natural variation. -If ``ANTHROPIC_API_KEY`` is set we use Claude (with prompt caching on the -style profile, since it's reused across many calls). Otherwise we fall back -to a deterministic template that swaps hashtag pools and tone markers — not -as good but never blocks distribution. +If ``ANTHROPIC_API_KEY`` is set we use Claude with: + - Prompt caching on the style profile + - Few-shot examples from the group's top-performing posts (by engagement_rate) + - Brand-safety net: mandatory compliance check before returning the variant +Otherwise we fall back to a deterministic template. """ from __future__ import annotations @@ -18,11 +19,33 @@ import logging import os import random -from dataclasses import dataclass +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass log = logging.getLogger(__name__) -DEFAULT_MODEL = os.environ.get("ANTHROPIC_VARIANT_MODEL", "claude-haiku-4-5-20251001") +DEFAULT_MODEL = os.environ.get("ANTHROPIC_VARIANT_MODEL", "claude-opus-4-6") + +# Absolute blocklist — any variant containing these terms is rejected outright. +# Per product brief: 維護型/保護1個月/不是鍍膜/無禁詞/數字可驗證/do_not_say絕不出現 +BRAND_BLOCKLIST: list[str] = [ + r"鍍膜", # product is NOT a coating + r"保護一個月", # unverifiable duration claim + r"保護1個月", + r"日本原裝", # false provenance unless product actually is + r"100%", # absolute claims are unverifiable unless product spec confirms + r"免費", # never offer free goods without explicit promo context + r"follow\s*back", + r"buy followers?", + r"crypto\s+giveaway", +] + +# Numeric-fact check: caption should not introduce numbers the source didn't have. +_NUMBER_RE = re.compile(r"\b\d+(?:\.\d+)?\s*(?:%|倍|公里|km|ml|g)\b") @dataclass @@ -32,6 +55,8 @@ class VariantRequest: platform: str # facebook | instagram | tiktok | youtube style_profile: dict seed: str # stable identifier — same seed → same output + few_shot_examples: list[dict] = field(default_factory=list) + # Each example: {"caption": str, "engagement_rate": float} @dataclass @@ -41,17 +66,47 @@ class VariantResult: used_engine: str # "claude" | "template" +# --------------------------------------------------------------------------- +# Brand safety net — applied to every generated variant regardless of engine. +# --------------------------------------------------------------------------- + +def _brand_safety_check(caption: str, source_caption: str) -> list[str]: + """Return list of violation descriptions; empty list means clean.""" + issues: list[str] = [] + + for pattern in BRAND_BLOCKLIST: + if re.search(pattern, caption, re.IGNORECASE): + issues.append(f"blocklist hit: {pattern}") + + # Numbers in variant that weren't in source are suspicious claims. + source_nums = set(_NUMBER_RE.findall(source_caption)) + variant_nums = set(_NUMBER_RE.findall(caption)) + new_nums = variant_nums - source_nums + if new_nums: + issues.append(f"新增未驗證數字: {', '.join(new_nums)}") + + return issues + + def generate_variant(req: VariantRequest) -> VariantResult: if os.environ.get("ANTHROPIC_API_KEY"): try: - return _claude_variant(req) + result = _claude_variant(req) + violations = _brand_safety_check(result.caption, req.source_caption) + if violations: + log.warning( + "Claude variant failed brand safety (%s); falling back to template", + violations, + ) + else: + return result except Exception as exc: log.warning("Claude variant failed (%s); falling back to template", exc) return _template_variant(req) # --------------------------------------------------------------------------- -# Claude implementation with prompt caching of the style profile. +# Claude implementation with prompt caching + few-shot examples. # --------------------------------------------------------------------------- _PLATFORM_HINTS = { @@ -59,14 +114,34 @@ def generate_variant(req: VariantRequest) -> VariantResult: "instagram": "Engaging hook in first line. Up to 30 hashtags but quality > quantity. 2,200 char hard limit.", "tiktok": "Punchy first line. 2,200 char limit. Hashtags should be specific, not #fyp.", "youtube": "Title under 100 chars (return separately). Description can include timestamps and links.", + "threads": "Concise. 500 char hard limit. Conversational tone. Minimal hashtags.", } +def _build_few_shot_block(examples: list[dict]) -> str: + if not examples: + return "" + lines = ["以下是這個人設過去互動率最高的貼文範例(供你學習文風,勿複製內容):"] + for i, ex in enumerate(examples[:3], 1): + rate = ex.get("engagement_rate", 0) + caption = ex.get("caption", "")[:300] + lines.append(f"\n範例{i}(互動率 {rate:.2%}):\n{caption}") + return "\n".join(lines) + + def _claude_variant(req: VariantRequest) -> VariantResult: from anthropic import Anthropic client = Anthropic() style_block = json.dumps(req.style_profile, ensure_ascii=False, indent=2) + few_shot_text = _build_few_shot_block(req.few_shot_examples) + + do_not_say = req.style_profile.get("do_not_say", []) + blocklist_note = ( + f"\n\n絕對禁止出現的詞彙(do_not_say): {', '.join(do_not_say)}" + if do_not_say + else "" + ) system = [ { @@ -75,14 +150,16 @@ def _claude_variant(req: VariantRequest) -> VariantResult: "You rewrite social media captions so each persona speaks in " "its own voice while preserving the source content's meaning. " "You never invent facts, never add fake stats, never claim " - "endorsements. You return strict JSON: " + "endorsements, never introduce numbers that weren't in the source. " + "You return strict JSON: " '{"title": "...", "caption": "..."} with no surrounding prose.' + + blocklist_note ), }, { "type": "text", - "text": f"Persona style profile:\n{style_block}", - # Cache the style profile across calls for the same group. + "text": f"Persona style profile:\n{style_block}\n\n{few_shot_text}", + # Cache the style profile + few-shot block across calls for the same group. "cache_control": {"type": "ephemeral"}, }, ] @@ -93,6 +170,7 @@ def _claude_variant(req: VariantRequest) -> VariantResult: f"Source title: {req.source_title}\n" f"Source caption:\n{req.source_caption}\n\n" f"Rewrite for this platform in the persona's voice. " + f"Keep emoji_density={req.style_profile.get('emoji_density', 'low')}. " f'Return JSON: {{"title": "...", "caption": "..."}}' ) @@ -163,3 +241,37 @@ def _template_variant(req: VariantRequest) -> VariantResult: title=req.source_title, used_engine="template", ) + + +# --------------------------------------------------------------------------- +# Helper: fetch top-performing posts for a group (call from Flask context). +# --------------------------------------------------------------------------- + +def fetch_few_shot_examples(group_id: int, limit: int = 3) -> list[dict]: + """Query the DB for the top-engagement posts from this persona group. + + Must be called within a Flask app context. Returns [] if no data yet. + """ + try: + from ..extensions import db + from ..models import AccountGroup, PostMetric, PostTarget, Post + + rows = ( + db.session.query( + Post.caption, + db.func.avg(PostMetric.likes + PostMetric.comments + PostMetric.shares) + .label("eng"), + ) + .join(PostTarget, PostTarget.post_id == Post.id) + .join(PostMetric, PostMetric.target_id == PostTarget.id) + .filter(PostTarget.group_id == group_id) + .filter(PostMetric.likes.isnot(None)) + .group_by(Post.id) + .order_by(db.text("eng DESC")) + .limit(limit) + .all() + ) + return [{"caption": r[0], "engagement_rate": float(r[1] or 0) / 1000} for r in rows] + except Exception as exc: + log.debug("fetch_few_shot_examples failed: %s", exc) + return [] diff --git a/social_distributor/frontend/index.html b/social_distributor/frontend/index.html index 388dc00..7ddc50c 100644 --- a/social_distributor/frontend/index.html +++ b/social_distributor/frontend/index.html @@ -35,6 +35,7 @@

Social Distributor

+