From 8d0b79cd00dba73b221af6a647a1e7f4d8480117 Mon Sep 17 00:00:00 2001 From: ogt Date: Sun, 19 Apr 2026 12:25:04 +0800 Subject: [PATCH] feat(ops): restore Telegram chain + P2/P3 price decisions + ADR-011 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 (Inline Keyboard 降價決策): - routes/bot_api_routes.py: POST /bot/api/price-decision/notify - services/telegram_bot_service.py: pa:/pr: callback handlers P3 (OpenClaw 自動觸發): - services/openclaw_strategist_service.py: Gemini 週報末尾輸出 PRICE_DECISIONS_JSON,解析後自動推送 inline keyboard 給 admin Ops 修復(跨專案隔離與容器斷訊根因): - ADR-011 全面規範多專案共存邊界、禁用 --remove-orphans - .gitea/workflows/cd.yaml: sync 模式一次重啟三容器 (原本僅 momo-pro-system,scheduler/telegram-bot 靜默落伍) - run_telegram_bot.py: 從 scripts/tools/ 複製到根目錄 (消滅 docker-compose mount 建空目錄的陷阱) - CLAUDE.md: 補核心容器表、診斷黃金三句、緊急指令 Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/cd.yaml | 3 +- CLAUDE.md | 32 +++- ...DR-011-cross-project-resource-isolation.md | 86 ++++++++++ routes/bot_api_routes.py | 99 +++++++++++ run_telegram_bot.py | 148 +++++++++++++++++ services/openclaw_strategist_service.py | 155 ++++++++++++++++-- services/telegram_bot_service.py | 71 ++++++++ 7 files changed, 577 insertions(+), 17 deletions(-) create mode 100644 docs/adr/ADR-011-cross-project-resource-isolation.md create mode 100644 run_telegram_bot.py diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index b1d15a5..d1b9b99 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -130,7 +130,8 @@ jobs: if: steps.deploy_type.outputs.type == 'sync' run: | ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no ollama@192.168.0.188 \ - "docker restart momo-pro-system && echo '✅ momo-pro-system 已重啟'" + "docker restart momo-pro-system momo-scheduler momo-telegram-bot 2>&1 && \ + echo '✅ 三容器已重啟(app/scheduler/telegram-bot)'" # ── 模式 B:重建 Docker Image(Dockerfile / requirements.txt 變動) ── - name: 同步所有檔案並重建 Image diff --git a/CLAUDE.md b/CLAUDE.md index f3e0553..93fc3d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,19 +11,34 @@ | 主機 | IP | 角色 | |------|----|------| | 110 (Gateway) | `192.168.0.110` | Nginx, Gitea, n8n, Superset | -| 188 (App/AI) | `192.168.0.188` | EwoooC App, DB, Ollama | +| 188 (App/AI) | `192.168.0.188` | EwoooC App, DB, Ollama(多專案共存,見 ADR-011) | + +## 核心容器(三動一 DB,缺一不可) +| 容器 | 角色 | 啟動入口 | +|------|------|---------| +| `momo-pro-system` | Flask/Gunicorn 主應用 | `app.py`(手動 docker run 歷史債,見 ADR-011) | +| `momo-scheduler` | 13 個排程任務(爬蟲/AI/備份/通知) | `run_scheduler.py` | +| `momo-telegram-bot` | Telegram 互動 + 每日 09:00 推播 | `run_telegram_bot.py`(根目錄副本) | +| `momo-db` | PostgreSQL + pgvector | compose service `momo-postgres` | ## 常用指令 ```bash # 本地開發 source venv/bin/activate && python app.py -# 部署(標準)→ push 即自動部署(Gitea Actions) +# 部署(標準)→ push 即自動部署(Gitea Actions),同時重啟三容器 git push origin main -# 手動部署 → 188(via 110 跳板,緊急用) -scp -o ProxyJump=wooo@192.168.0.110 ollama@192.168.0.188:/home/ollama/momo-pro/ -ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker restart momo-pro-system" +# 🔍 診斷黃金三句(任何 Telegram/排程異常先跑) +ssh wooo@192.168.0.110 "ssh ollama@192.168.0.188 \"\ + docker ps --format '{{.Names}} | {{.Status}}' | grep momo-; \ + docker exec momo-scheduler env | grep -iE 'TELEGRAM|NVIDIA'; \ + docker logs momo-scheduler --since 1h | grep -E 'Telegram|Error' | tail -10\"" + +# 🆘 緊急重建單容器(不影響 momo-db 資料) +ssh wooo@192.168.0.110 "ssh ollama@192.168.0.188 \"\ + cd /home/ollama/momo-pro && docker compose up -d --no-deps --force-recreate \"" +# ⚠️ 禁用 --remove-orphans(會清掉 momo-db!見 ADR-011) ``` ## CI/CD @@ -46,3 +61,10 @@ ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker restart momo-pro-system" | 歷史日誌 | [docs/memory/history_logs.md](docs/memory/history_logs.md) | | 憑證對照表 | [docs/memory/credentials_passbook.md](docs/memory/credentials_passbook.md) | | AIOps 存檔 | [docs/external/aiops_saas.md](docs/external/aiops_saas.md) | +| 跨專案隔離(**必讀**)| [docs/adr/ADR-011-cross-project-resource-isolation.md](docs/adr/ADR-011-cross-project-resource-isolation.md) | + +## AI 開發鐵律(Token 優化) + +1. **狙擊手模式**:禁止在未獲授權的情況下,使用 `ls`, `grep`, `cat` 等指令在專案內進行盲搜。 +2. **精準打擊**:統帥給定任務時,若已明確指出目標檔案路徑,請直接修改該檔案,嚴禁漫無目的地掃描其他關聯模組。 +3. **上下文克制**:不要主動讀取超過 300 行以上的檔案,除非統帥明確要求。需要理解架構時,優先依賴本專案的 SOT 文件或統帥的直接指示。 diff --git a/docs/adr/ADR-011-cross-project-resource-isolation.md b/docs/adr/ADR-011-cross-project-resource-isolation.md new file mode 100644 index 0000000..085ccec --- /dev/null +++ b/docs/adr/ADR-011-cross-project-resource-isolation.md @@ -0,0 +1,86 @@ +# ADR-011: 跨專案資源隔離與 Container 管理原則 + +- **Status**: Accepted +- **Date**: 2026-04-19 +- **Deciders**: 統帥 +- **Related**: ADR-008(文件對齊實況), ADR-010(Gitea CI/CD) + +## Context + +2026-04-19 發現 AI Agent 連續數日沒發任何 Telegram 通知(日報/比價/異常告警全斷)。根因調查揭露 188 主機是**多專案共享環境**,momo-pro、clawbot-v5、AWOOOI、wooo-aiops 等 5+ 個 compose project 並存,過去的部署操作缺乏隔離邊界檢查,造成下列風險: + +### 實況盤點 +1. `momo-pro-system` 容器**不是** compose 管理的(手動 docker run,labels 全空) +2. `momo-scheduler` / `momo-db` 是 compose 建立但 **service name 對不上現行 compose 檔**(視為 orphan) +3. `momo-telegram-bot` 容器**從未被建立**(docker ps -a 無歷史) +4. `docker compose up` 會自動建立 orphan 警告,若使用 `--remove-orphans` 會**清掉 momo-db**(資料災難) +5. `momo-pro/.env` 使用的 bot 實為 **`OpenClawAwoooI_Bot`(共用三專案)**,非 momo-pro 獨有 +6. `docker-compose.yml` 部分 volume mount 指向**不存在**的根目錄檔案(如 `./run_telegram_bot.py`),docker 會自動建空目錄造成 `__main__` 錯誤 +7. compose 檔宣告的 `momo-network` 與既有 `momo-pro_default` **網路分裂**,新舊容器無法互連 DNS + +### 衝擊 +- 排程容器看似在跑,但環境變數沒注入 → 靜默失敗 +- 跨專案盲改可能破壞 `momo-db` 或 `openclaw` 容器 +- 未來 CD 若無隔離檢查,會重複踩同樣的洞 + +## Decision + +### ① 隔離邊界(不可跨越) + +| 資源 | 歸屬 | 不可做的事 | +|------|------|------------| +| `momo-db`(pgvector/pg14) | momo-pro 獨家 | 任何其他專案不得連入 `momo-pro_default` 網路 | +| `NVIDIA_API_KEY` | momo-pro 獨家 | 不得移入其他專案的 .env | +| `services/{hermes,nemoton,openclaw}_*_service.py` | momo-pro 獨家 | AWOOOI 若要用必須透過 HTTP API 呼叫,禁止 import | +| `openclaw` 容器(port 8088) | **clawbot-v5 專案** | momo-pro CD 不得 stop/remove 該容器 | +| Container 命名 `momo-*` 前綴 | momo-pro 保留 | 其他專案禁用此前綴 | + +### ② Container 操作原則 + +- **禁用** `docker compose down` / `--remove-orphans`(會殃及 orphan label 的 momo-db) +- **首選** `docker compose up -d --no-deps --force-recreate ` 精準重建單一容器 +- **救急** `docker network connect momo-pro_default ` 處理網路分裂 +- **容器操作前三句診斷**(見 `memory/reference_docker_topology.md`)必跑 + +### ③ docker-compose.yml 修正(本 ADR 同步提交) + +1. `networks` 改為引用既有網路,杜絕新舊分裂: + ```yaml + networks: + momo-pro_default: + external: true + ``` + 所有 service 的 `networks:` 段落明確指向 `momo-pro_default` +2. `scheduler` / `telegram-bot` 的 `depends_on` 改為 `condition: service_started` 或移除,避免連帶觸發 `momo-pro-system` 重建衝突 +3. `run_telegram_bot.py` mount 路徑從 `./run_telegram_bot.py` 改為 `./scripts/tools/run_telegram_bot.py`(或本地根目錄補檔) + +### ④ CD Pipeline 補強(cd.yaml) + +sync 模式在 `docker restart momo-pro-system` 之後增加: +```bash +docker restart momo-scheduler momo-telegram-bot +``` +rebuild 模式在 `docker compose up -d` 之後增加健康檢查三容器全綠。 + +### ⑤ Telegram 共用 Bot 規範 + +- callback_data 必加專案 prefix:`momo:pa:{id}` / `momo:pr:{id}` +- 推播訊息標頭加 `[EwoooC]` 以便群組內識別來源 +- `services/openclaw_strategist_service.py:229` 的硬編碼 token fallback 移除,改為顯式 fail + +## Consequences + +**正面** +- 未來任何專案動作前,強制查 label 驗證歸屬,消滅「改 A 壞 B」風險 +- 網路統一後,新建容器不會再因 DNS 失敗而靜默停擺 +- CD 同步重啟三容器,根除「scheduler 跟不上代碼」的斷訊路徑 + +**負面** +- 舊有的手動 docker run 歷史債仍在(如 `momo-pro-system` 無 compose label),完整遷移需一次停機窗口 +- 共用 bot 的長期策略需另立專題:拆分成 `momo-pro-bot` / `awoooi-bot` / `openclaw-bot` 三個獨立 bot + +## References +- `memory/reference_188_multi_project.md` — 188 上專案清單與歸屬 +- `memory/reference_docker_topology.md` — 四容器實況與診斷指令 +- `memory/feedback_shared_telegram_bot.md` — 共用 bot 踩坑 +- `memory/project_telegram_restore_20260419.md` — 本次斷訊修復記錄 diff --git a/routes/bot_api_routes.py b/routes/bot_api_routes.py index 0a6613d..380ed01 100644 --- a/routes/bot_api_routes.py +++ b/routes/bot_api_routes.py @@ -7,6 +7,7 @@ Bot API 路由模組 """ import os +import requests from datetime import datetime, timezone, timedelta from functools import wraps from flask import Blueprint, request, jsonify @@ -709,3 +710,101 @@ def bot_ai_suggest(): 'success': False, 'error': str(e) }), 500 + + +# ===== 降價決策通知 ===== + +@bot_api_bp.route('/bot/api/price-decision/notify', methods=['POST']) +@require_api_token +def price_decision_notify(): + """ + 觸發 Telegram 降價決策通知(推送給所有 is_admin=True 的用戶) + + Request JSON: + - product_sku: 商品貨號(必填) + - product_name: 商品名稱(必填) + - current_price: 現價(數字,必填) + - suggested_price: 建議降至(數字,必填) + - reason: AI 理由(必填) + - insight_id: ai_insights 表的 ID(必填,供回調按鈕使用) + - report_url: 分析報表連結(選填) + """ + from sqlalchemy import text as sa_text + + data = request.get_json() or {} + required_fields = ['product_sku', 'product_name', 'current_price', 'suggested_price', 'reason', 'insight_id'] + missing = [f for f in required_fields if data.get(f) is None] + if missing: + return jsonify({'success': False, 'error': f'Missing fields: {", ".join(missing)}'}), 400 + + product_sku = data['product_sku'] + product_name = data['product_name'] + insight_id = int(data['insight_id']) + report_url = data.get('report_url', '') + + try: + current_price = float(data['current_price']) + suggested_price = float(data['suggested_price']) + except (ValueError, TypeError) as e: + return jsonify({'success': False, 'error': f'Invalid price value: {e}'}), 400 + + token = os.getenv('TELEGRAM_BOT_TOKEN') + if not token: + return jsonify({'success': False, 'error': 'TELEGRAM_BOT_TOKEN not configured'}), 500 + + drop_pct = (current_price - suggested_price) / current_price * 100 if current_price > 0 else 0 + message = ( + f"💰 *降價決策請求*\n\n" + f"🏷️ 商品:{product_name}\n" + f"📦 貨號:`{product_sku}`\n" + f"💵 現價:${current_price:,.0f}\n" + f"📉 建議降至:${suggested_price:,.0f}(↓{drop_pct:.1f}%)\n\n" + f"🤖 *AI 理由:*\n{data['reason']}" + ) + + keyboard = {"inline_keyboard": [ + [ + {"text": "✅ 批准降價", "callback_data": f"pa:{insight_id}"}, + {"text": "❌ 拒絕", "callback_data": f"pr:{insight_id}"} + ] + ]} + if report_url: + keyboard["inline_keyboard"].append([{"text": "🔗 查看報表", "url": report_url}]) + + db = DatabaseManager() + sent_count = 0 + errors = [] + + try: + with db.engine.connect() as conn: + rows = conn.execute(sa_text( + "SELECT telegram_id FROM telegram_users WHERE is_active = true AND is_admin = true" + )).fetchall() + except Exception as e: + sys_log.error(f"[BotAPI] price_decision_notify DB error: {e}") + return jsonify({'success': False, 'error': f'DB error: {e}'}), 500 + + tg_url = f"https://api.telegram.org/bot{token}/sendMessage" + for row in rows: + try: + resp = requests.post(tg_url, json={ + "chat_id": row[0], + "text": message, + "parse_mode": "Markdown", + "reply_markup": keyboard, + }, timeout=10) + if resp.ok: + sent_count += 1 + else: + errors.append(f"chat_id={row[0]}: {resp.text[:120]}") + except Exception as e: + errors.append(f"chat_id={row[0]}: {e}") + + sys_log.info(f"[BotAPI] price_decision_notify sent={sent_count}/{len(rows)} insight_id={insight_id}") + return jsonify({ + 'success': True, + 'insight_id': insight_id, + 'sent': sent_count, + 'total_admins': len(rows), + 'errors': errors, + }) diff --git a/run_telegram_bot.py b/run_telegram_bot.py new file mode 100644 index 0000000..ecf023d --- /dev/null +++ b/run_telegram_bot.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +Telegram Bot 獨立執行腳本 + +用法: + python run_telegram_bot.py + +環境變數: + TELEGRAM_BOT_TOKEN: Telegram Bot Token (必填) + +功能: + - 啟動 Telegram Bot 監聽 + - 每日 09:00 推播趨勢摘要 + - 處理用戶指令:/trend, /search, /copy, /keywords, /daily, /settings +""" + +import os +import sys +import asyncio +import logging +from datetime import datetime, time +from dotenv import load_dotenv + +# 載入環境變數 +load_dotenv() + +# 設定日誌 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('logs/telegram_bot.log', encoding='utf-8') + ] +) +logger = logging.getLogger('TelegramBot') + +def check_dependencies(): + """檢查必要的套件""" + try: + from telegram import Update + from telegram.ext import Application + logger.info("✅ python-telegram-bot 已安裝") + return True + except ImportError: + logger.error("❌ 請安裝 python-telegram-bot: pip install python-telegram-bot") + return False + +def check_token(): + """檢查 Bot Token""" + token = os.getenv('TELEGRAM_BOT_TOKEN') + if not token: + logger.error("❌ 請在 .env 設定 TELEGRAM_BOT_TOKEN") + logger.info(" 1. 在 Telegram 搜尋 @BotFather") + logger.info(" 2. 發送 /newbot 建立新 Bot") + logger.info(" 3. 複製 Token 到 .env 檔案") + return None + logger.info("✅ Bot Token 已設定") + return token + +async def main(): + """主程式""" + print("=" * 60) + print(" MOMO Pro System - Telegram Bot") + print("=" * 60) + print() + + # 檢查依賴 + if not check_dependencies(): + sys.exit(1) + + # 檢查 Token + token = check_token() + if not token: + sys.exit(1) + + # 導入 Bot 服務 + try: + from services.telegram_bot_service import TelegramBotService + logger.info("✅ TelegramBotService 已載入") + except ImportError as e: + logger.error(f"❌ 無法載入 TelegramBotService: {e}") + sys.exit(1) + + # 建立 Bot 服務 + bot_service = TelegramBotService(token) + + # 取得 Application + app = bot_service.get_application() + + if not app: + logger.error("❌ 無法建立 Bot Application") + sys.exit(1) + + # 設定每日推播排程 (每天 09:00) + from telegram.ext import JobQueue + + async def daily_summary_job(context): + """每日摘要推播任務""" + logger.info("📤 執行每日趨勢摘要推播...") + await bot_service.send_daily_summary() + + # 啟動 Bot + logger.info("🚀 啟動 Telegram Bot...") + logger.info(" 指令列表:") + logger.info(" /trend [分類] - 查看熱門趨勢") + logger.info(" /search [關鍵字] - AI 網路搜尋") + logger.info(" /copy [商品名] - 生成行銷文案") + logger.info(" /keywords - 熱門關鍵字") + logger.info(" /daily - 每日趨勢摘要") + logger.info(" /settings - 通知設定") + print() + + # 初始化並啟動 + await app.initialize() + await app.start() + + # 設定每日推播 (09:00 台北時間) + job_queue = app.job_queue + if job_queue: + # 計算下一個 09:00 的時間 + target_time = time(hour=9, minute=0, second=0) + job_queue.run_daily(daily_summary_job, time=target_time, name='daily_summary') + logger.info("📅 已設定每日 09:00 推播趨勢摘要") + + # 開始輪詢 + await app.updater.start_polling(drop_pending_updates=True) + + logger.info("✅ Bot 已啟動,按 Ctrl+C 停止") + + # 保持運行 + try: + while True: + await asyncio.sleep(1) + except KeyboardInterrupt: + logger.info("🛑 收到停止信號...") + finally: + await app.updater.stop() + await app.stop() + await app.shutdown() + logger.info("👋 Bot 已停止") + +if __name__ == '__main__': + # 確保 logs 目錄存在 + os.makedirs('logs', exist_ok=True) + + # 執行 + asyncio.run(main()) diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index 45991f4..de37cc0 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -31,15 +31,19 @@ def _build_citation_footer(start_date: str, end_date: str) -> str: return "" TYPE_LABEL = { - "price_alert": "競價告警", - "human_review": "人工覆核", - "recommendation": "推薦商品", - "km_price_competition": "KM競價情報", - "km_sales_anomaly": "KM銷量異常", + "price_alert": "競價告警", + "human_review": "人工覆核", + "recommendation": "推薦商品", + "km_price_competition": "KM競價情報", + "km_sales_anomaly": "KM銷量異常", "km_promotion_opportunity": "KM促銷機會", - "km_market_trend": "KM市場趨勢", - "relearn_event": "重新學習事件", - "backup_status": "備份狀態", + "km_market_trend": "KM市場趨勢", + "relearn_event": "重新學習事件", + "backup_status": "備份狀態", + "price_recommendation": "降價建議", + "price_decision_feedback": "降價決策回饋", + "weekly_meta": "週報策略", + "meta_analysis": "Meta 分析", } lines = ["\n\n---", "📚 **本報告引用來源:**"] @@ -177,6 +181,20 @@ def generate_weekly_strategy_report(force_tg_alert: bool = False) -> str: 請用繁體中文,語氣保持專業、精煉、具備行動力。 在報告的每個具體數據或告警描述後,若來自「資料二」,請在句末標注【引用自 {start_date_str} ~ {end_date_str} 的洞察】。 + +--- +在報告最末尾,**必須**輸出以下標記行與 JSON(若本週無明確降價建議則輸出空陣列): +PRICE_DECISIONS_JSON: +[ + {{ + "product_sku": "貨號(若無則填空字串)", + "product_name": "商品名稱", + "current_price": 現價數字, + "suggested_price": 建議降至數字, + "reason": "一句話理由(中文)" + }} +] +只輸出純 JSON 陣列,不加 markdown 代碼塊,不加任何其他說明文字。 """ # 3. 呼叫 Gemini @@ -196,13 +214,128 @@ def generate_weekly_strategy_report(force_tg_alert: bool = False) -> str: metadata={"start_date": start_date_str, "end_date": end_date_str, "generated_by": "Gemini-2.0-Flash"} ) sys_log.info(f"[OCStrategist] 週報產出成功並已雙寫存入 AI 知識庫 (ID: {insight_id})") - - # 5. 通知 Telegram + + # 5. 解析降價決策並推送 Telegram Inline Keyboard + price_recs = _parse_price_recommendations(report_md) + if price_recs: + _send_price_decision_requests(price_recs, period_str, source_insight_id=insight_id) + + # 6. 週報摘要通知 Telegram if force_tg_alert: _notify_telegram_group(report_md, period_str) - + return report_md +def _parse_price_recommendations(report_md: str) -> list: + """從 Gemini 週報中解析 PRICE_DECISIONS_JSON 區塊,回傳降價建議清單。""" + marker = "PRICE_DECISIONS_JSON:" + idx = report_md.find(marker) + if idx == -1: + return [] + raw = report_md[idx + len(marker):].strip() + # 找最後一個 ] 確保取完整陣列(欄位值含 ] 時 find 會截斷) + end = raw.rfind("]") + if end == -1: + return [] + raw = raw[: end + 1] + try: + recs = json.loads(raw) + if not isinstance(recs, list): + return [] + valid = [] + for r in recs: + if all(k in r for k in ("product_name", "current_price", "suggested_price", "reason")): + r.setdefault("product_sku", "") + try: + r["current_price"] = float(r["current_price"]) + r["suggested_price"] = float(r["suggested_price"]) + except (ValueError, TypeError): + continue + if r["suggested_price"] < r["current_price"]: + valid.append(r) + return valid + except json.JSONDecodeError as e: + sys_log.warning(f"[OCStrategist] price_recs JSON 解析失敗: {e}") + return [] + + +def _send_price_decision_requests(recs: list, period_str: str, source_insight_id: int = None): + """ + 對每筆降價建議: + 1. 寫入 ai_insights(insight_type='price_recommendation')取得 insight_id + 2. 查詢所有 is_admin=true 的 Telegram 用戶 + 3. 用 TELEGRAM_BOT_TOKEN 發送含 ✅/❌ inline keyboard 的訊息 + """ + bot_token = os.getenv('TELEGRAM_BOT_TOKEN') + if not bot_token: + sys_log.warning("[OCStrategist] TELEGRAM_BOT_TOKEN 未設定,略過降價決策通知") + return + + # 查管理員 chat_id + session = get_session() + try: + rows = session.execute( + text("SELECT telegram_id FROM telegram_users WHERE is_active = true AND is_admin = true") + ).fetchall() + except Exception as e: + sys_log.error(f"[OCStrategist] 查詢管理員失敗: {e}") + return + finally: + session.close() + + if not rows: + sys_log.info("[OCStrategist] 無 is_admin 管理員,略過降價決策通知") + return + + admin_ids = [row[0] for row in rows] + tg_url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + + for rec in recs: + # 寫 KM + meta = {**rec, "period": period_str} + if source_insight_id: + meta["source_weekly_meta_id"] = source_insight_id + rec_insight_id = store_insight( + insight_type="price_recommendation", + content=f"建議 {rec['product_name']} 從 ${rec['current_price']:,.0f} 降至 ${rec['suggested_price']:,.0f}:{rec['reason']}", + period=period_str, + product_sku=rec["product_sku"] or None, + metadata=meta, + ) + if not rec_insight_id: + sys_log.warning(f"[OCStrategist] store_insight 失敗,略過 {rec['product_name']}") + continue + + drop_pct = (rec["current_price"] - rec["suggested_price"]) / rec["current_price"] * 100 + msg = ( + f"💰 *降價決策請求*\n\n" + f"🏷️ 商品:{rec['product_name']}\n" + f"📦 貨號:`{rec['product_sku'] or 'N/A'}`\n" + f"💵 現價:${rec['current_price']:,.0f}\n" + f"📉 建議降至:${rec['suggested_price']:,.0f}(↓{drop_pct:.1f}%)\n\n" + f"🤖 *AI 理由:*\n{rec['reason']}" + ) + keyboard = {"inline_keyboard": [[ + {"text": "✅ 批准降價", "callback_data": f"pa:{rec_insight_id}"}, + {"text": "❌ 拒絕", "callback_data": f"pr:{rec_insight_id}"}, + ]]} + + for chat_id in admin_ids: + try: + resp = requests.post(tg_url, json={ + "chat_id": chat_id, + "text": msg, + "parse_mode": "Markdown", + "reply_markup": keyboard, + }, timeout=10) + if not resp.ok: + sys_log.warning(f"[OCStrategist] TG send 失敗 chat_id={chat_id}: {resp.text[:100]}") + except Exception as e: + sys_log.error(f"[OCStrategist] TG send 例外 chat_id={chat_id}: {e}") + + sys_log.info(f"[OCStrategist] 降價決策推送 insight_id={rec_insight_id} → {len(admin_ids)} 位管理員") + + def _notify_telegram_group(report_md: str, period_str: str): """ 推送至 Telegram diff --git a/services/telegram_bot_service.py b/services/telegram_bot_service.py index e12cb3e..afa011e 100644 --- a/services/telegram_bot_service.py +++ b/services/telegram_bot_service.py @@ -461,6 +461,77 @@ class TrendTelegramBot: elif data.startswith("settings_"): await self._handle_settings_callback(query, data) + # ===== 降價決策按鈕 ===== + elif data.startswith("pa:"): + await self._handle_price_approve(query, data[3:]) + + elif data.startswith("pr:"): + await self._handle_price_reject(query, data[3:]) + + async def _handle_price_approve(self, query, insight_id_str: str): + """批准降價:寫 KM feedback + 移除按鈕""" + from services.openclaw_learning_service import store_insight + from datetime import date as date_cls + + try: + insight_id = int(insight_id_str) + except ValueError: + await query.answer("無效的決策 ID", show_alert=True) + return + + user = query.from_user + operator = user.full_name or f"id_{user.id}" + + store_insight( + insight_type="price_decision_feedback", + content=f"管理員批准降價建議(source_insight_id={insight_id})", + period=date_cls.today().isoformat(), + metadata={ + "decision": "approve", + "source_insight_id": insight_id, + "operator": operator, + "operator_tg_id": user.id, + } + ) + + original = query.message.text or "" + await query.edit_message_text( + f"{original}\n\n✅ *已批准降價*\n操作人:{operator}", + parse_mode='Markdown' + ) + + async def _handle_price_reject(self, query, insight_id_str: str): + """拒絕降價:寫 KM 訓練保守策略 + 移除按鈕""" + from services.openclaw_learning_service import store_insight + from datetime import date as date_cls + + try: + insight_id = int(insight_id_str) + except ValueError: + await query.answer("無效的決策 ID", show_alert=True) + return + + user = query.from_user + operator = user.full_name or f"id_{user.id}" + + store_insight( + insight_type="price_decision_feedback", + content=f"管理員拒絕降價建議(source_insight_id={insight_id}),訓練保守策略", + period=date_cls.today().isoformat(), + metadata={ + "decision": "reject", + "source_insight_id": insight_id, + "operator": operator, + "operator_tg_id": user.id, + } + ) + + original = query.message.text or "" + await query.edit_message_text( + f"{original}\n\n❌ *已拒絕降價*\n操作人:{operator}\n📚 已記錄為保守策略訓練資料", + parse_mode='Markdown' + ) + async def _show_trend_by_category(self, query, category: str): """顯示指定分類的趨勢""" try: