From 4d6273173054a5c620d9241b04c25d117c805187 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 20 May 2026 12:35:12 +0800 Subject: [PATCH] =?UTF-8?q?[V10.322]=20=E4=BF=AE=E6=AD=A3=20Telegram=20?= =?UTF-8?q?=E6=B1=BA=E7=AD=96=E5=AF=A9=E6=A0=B8=E6=8E=A8=E6=92=AD=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + routes/bot_api_routes.py | 28 ++--- services/learning_pipeline.py | 2 +- services/telegram_templates.py | 12 +- tests/test_bot_api_telegram_delivery.py | 133 ++++++++++++++++++++ tests/test_learning_pipeline.py | 30 +++++ tests/test_telegram_triaged_alert_format.py | 22 +++- 9 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 tests/test_bot_api_telegram_delivery.py diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 59272ba..ee9b9a9 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.322 修正 Telegram 決策/審核推播舊入口:`price_decision_notify()` 改用 `send_telegram_with_result()` 統一套用 HTML sanitizer 與多 chat 結果彙整,並補齊 `price_decision(report_url=...)` 相容;RAG awaiting review 推播改用正確 `chat_ids=[...]` 呼叫,避免 Stage 4 人工審核按鈕因參數名錯誤完全送不出去。 - V10.321 修正 Telegram HTML 發送格式:所有 `sendMessage` / `sendPhoto` caption 在 HTML parse mode 送出前會把 `
` / `
` / `
` 統一轉成換行,避免 Telegram Bot API 回 `Unsupported start tag "br"` 造成告警或報告送出失敗。 - V10.320 補市場情報 candidate queue review AI summary Telegram dispatch report input:新增 read-only report input builder、獨立 report route extension、UI 按鈕與 deployment readiness smoke target,在 archive summary 後整理 report input sections、report contract、message evidence 與 dispatch audit traceability;API/UI 不讀 approval/Telegram token、不呼叫 LLM、不派送 Telegram、不開 DB、不寫檔、不產報表、不更新 review_state、不掛 scheduler。 - V10.319 補市場情報 candidate queue review AI summary Telegram dispatch archive summary:新增 read-only archive summary builder、POST endpoint、UI 按鈕與 deployment readiness smoke target,在 Telegram dispatch archive 後整理 message identity、dispatch audit、artifact manifest 與後續 report input sections;API/UI 不讀 approval/Telegram token、不呼叫 LLM、不派送 Telegram、不開 DB、不寫檔、不更新 review_state、不掛 scheduler。 diff --git a/config.py b/config.py index 24c8f79..cfb84a3 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.321" +SYSTEM_VERSION = "V10.322" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 106d797..c15df39 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -611,3 +611,4 @@ POSTGRES_HOST=momo-db | 2026-05-20 | PChome 覆蓋率分子可能被非活躍或無 MOMO 現價 SKU 膨脹 | V10.317 起 `fetch_competitor_coverage()` 的 `valid_matches` 改為 active MOMO latest price 與有效 PChome `identity_v2` 價格交集,確保 daily/growth/PPT/AI 看到的比價資料品質不被舊快取列高估 | | 2026-05-20 | EA HITL 告警可能把非 SKU 診斷誤排成待審 SKU,或在缺少 DB/Hermes 實證時打擾人工 | V10.318 起 `ea_escalation` 僅對含 SKU/價格比較的 actions 使用競價卡片;非 SKU 診斷改為「待確認事項」。價格類低信心事件若無 DB/Hermes 實證,測試鎖定只 suppress、不寫 human_review、不發 Telegram | | 2026-05-20 | Telegram HTML parse mode 不支援 `
`,可能導致告警或報告送出 400 | V10.321 起 Telegram template 發送前會把 `
` / `
` / `
` 轉為換行;保留其他 HTML 標籤,非 HTML parse mode 不改寫 | +| 2026-05-20 | 部分舊 Telegram 入口繞過中央 sanitizer,且 RAG awaiting review 使用錯誤 `chat_id=` 參數會讓人工審核推播失敗 | V10.322 起 Bot API price decision 走 `send_telegram_with_result()`;`price_decision()` 補 `report_url` 相容並 escape 動態欄位;RAG awaiting review 改用 `chat_ids=[...]` 呼叫 `_send_telegram_raw()` | diff --git a/routes/bot_api_routes.py b/routes/bot_api_routes.py index 19da946..e40e1c0 100644 --- a/routes/bot_api_routes.py +++ b/routes/bot_api_routes.py @@ -7,7 +7,6 @@ Bot API 路由模組 """ import os -import requests from datetime import datetime, timezone, timedelta from functools import wraps from flask import Blueprint, request, jsonify @@ -755,7 +754,7 @@ def price_decision_notify(): if not token: return jsonify({'success': False, 'error': 'TELEGRAM_BOT_TOKEN not configured'}), 500 - from services.telegram_templates import price_decision + from services.telegram_templates import price_decision, send_telegram_with_result message, keyboard = price_decision( product_name=product_name, product_sku=product_sku, @@ -779,21 +778,16 @@ def price_decision_notify(): 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": "HTML", - "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}") + admin_chat_ids = [row[0] for row in rows] + if admin_chat_ids: + result = send_telegram_with_result( + message, + chat_ids=admin_chat_ids, + reply_markup=keyboard, + parse_mode="HTML", + ) + sent_count = int(result.get("sent", 0)) + errors = list(result.get("errors", [])) sys_log.info(f"[BotAPI] price_decision_notify sent={sent_count}/{len(rows)} insight_id={insight_id}") return jsonify({ diff --git a/services/learning_pipeline.py b/services/learning_pipeline.py index 5ad86b3..c52cb90 100644 --- a/services/learning_pipeline.py +++ b/services/learning_pipeline.py @@ -904,7 +904,7 @@ def push_awaiting_reviews_to_telegram(batch: int = AWAITING_REVIEW_PUSH_BATCH, f"審核:通過 → 寫入 ai_insights 供 RAG 檢索;拒絕 → 永不晉升" ) try: - _send_telegram_raw(msg, chat_id=chat_id, reply_markup=promotion_review_keyboard(ep_id)) + _send_telegram_raw(msg, chat_ids=[chat_id], reply_markup=promotion_review_keyboard(ep_id)) pushed += 1 except Exception as exc: logger.warning('[AwaitingReviewPush] episode_id=%s push failed: %s', ep_id, exc) diff --git a/services/telegram_templates.py b/services/telegram_templates.py index a4484a9..2aaa4c2 100644 --- a/services/telegram_templates.py +++ b/services/telegram_templates.py @@ -326,23 +326,29 @@ def report_section(icon: str, title: str, lines: List[str]) -> str: def price_decision(product_name: str, product_sku: str, current_price: float, suggested_price: float, - reason: str, insight_id: Optional[int] = None) -> tuple: + reason: str, insight_id: Optional[int] = None, + report_url: Optional[str] = None) -> tuple: """降 / 提價決策通知(含 Inline Keyboard)""" diff = current_price - suggested_price action_text = f"降價 NT${diff:,.0f}" if diff > 0 else \ f"提價 NT${-diff:,.0f}" if diff < 0 else "維持現價" direction = "📉" if diff > 0 else "📈" if diff < 0 else "➡️" + safe_name = escape(str(product_name or "")) + safe_sku = escape(str(product_sku or "")) + safe_reason = escape(_sanitize_telegram_html(str(reason or ""), "HTML")) message = ( f"💰 AI 定價決策建議\n" f"━━━━━━━━━━━━━━━━━━━━\n" - f"🏷️ {product_name} {product_sku}\n\n" + f"🏷️ {safe_name} {safe_sku}\n\n" f"現價:NT${current_price:,.0f}\n" f"建議:NT${suggested_price:,.0f} {direction} {action_text}\n\n" - f"💡 依據:{reason}\n" + f"💡 依據:{safe_reason}\n" ) if insight_id: message += f"🔗 洞察 ID:{insight_id}\n" + if report_url: + message += f"📎 查看分析報表\n" message += f"━━━━━━━━━━━━━━━━━━━━" # ADR-012: callback_data 採短 prefix(momo:pa/pr:{insight_id})— 64-byte 安全、與 L2 handler 一致 diff --git a/tests/test_bot_api_telegram_delivery.py b/tests/test_bot_api_telegram_delivery.py new file mode 100644 index 0000000..38b8378 --- /dev/null +++ b/tests/test_bot_api_telegram_delivery.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Bot API Telegram delivery contract tests.""" + +from flask import Flask + + +def test_price_decision_notify_uses_central_telegram_sender(monkeypatch): + from routes import bot_api_routes + + class FakeResult: + def fetchall(self): + return [("101",), ("202",)] + + class FakeConnection: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, _statement): + return FakeResult() + + class FakeEngine: + def connect(self): + return FakeConnection() + + class FakeDatabaseManager: + engine = FakeEngine() + + sent = {} + + def fake_send_telegram_with_result(text, chat_ids=None, reply_markup=None, parse_mode=None): + sent["text"] = text + sent["chat_ids"] = chat_ids + sent["reply_markup"] = reply_markup + sent["parse_mode"] = parse_mode + return {"ok": True, "sent": len(chat_ids or []), "failed": 0, "errors": []} + + monkeypatch.setattr(bot_api_routes, "BOT_API_TOKEN", "test-api-token") + monkeypatch.setattr(bot_api_routes, "DatabaseManager", lambda: FakeDatabaseManager()) + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-telegram-token") + monkeypatch.setattr( + "services.telegram_templates.send_telegram_with_result", + fake_send_telegram_with_result, + ) + + app = Flask(__name__) + app.register_blueprint(bot_api_routes.bot_api_bp) + client = app.test_client() + + response = client.post( + "/bot/api/price-decision/notify", + headers={"X-API-Token": "test-api-token"}, + json={ + "product_sku": "SKU<001>", + "product_name": "精華 ", + "insight_id": 42, + "report_url": "https://mo.wooo.work/report?a=1&b=", + }, + ) + data = response.get_json() + + assert response.status_code == 200 + assert data["success"] is True + assert data["sent"] == 2 + assert sent["chat_ids"] == ["101", "202"] + assert sent["parse_mode"] == "HTML" + assert sent["reply_markup"]["inline_keyboard"][0][0]["callback_data"] == "momo:pa:42" + assert "" not in sent["text"] + assert "第一行\n第二行<script>alert(1)</script>" in sent["text"] + + +def test_price_decision_notify_does_not_fallback_when_no_admins(monkeypatch): + from routes import bot_api_routes + + class FakeResult: + def fetchall(self): + return [] + + class FakeConnection: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, _statement): + return FakeResult() + + class FakeEngine: + def connect(self): + return FakeConnection() + + class FakeDatabaseManager: + engine = FakeEngine() + + def fail_send(*_args, **_kwargs): + raise AssertionError("empty admin list must not fall back to default chat") + + monkeypatch.setattr(bot_api_routes, "BOT_API_TOKEN", "test-api-token") + monkeypatch.setattr(bot_api_routes, "DatabaseManager", lambda: FakeDatabaseManager()) + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-telegram-token") + monkeypatch.setattr("services.telegram_templates.send_telegram_with_result", fail_send) + + app = Flask(__name__) + app.register_blueprint(bot_api_routes.bot_api_bp) + client = app.test_client() + + response = client.post( + "/bot/api/price-decision/notify", + headers={"X-API-Token": "test-api-token"}, + json={ + "product_sku": "SKU001", + "product_name": "精華", + "current_price": 1200, + "suggested_price": 990, + "reason": "測試", + "insight_id": 42, + }, + ) + data = response.get_json() + + assert response.status_code == 200 + assert data["success"] is True + assert data["sent"] == 0 + assert data["total_admins"] == 0 + assert data["errors"] == [] diff --git a/tests/test_learning_pipeline.py b/tests/test_learning_pipeline.py index 66a5cfa..29f19d7 100644 --- a/tests/test_learning_pipeline.py +++ b/tests/test_learning_pipeline.py @@ -235,6 +235,36 @@ class TestExpireStaleReviews: assert count == 0 +class TestAwaitingReviewPush: + def test_push_uses_chat_ids_keyword_for_telegram(self, monkeypatch): + from services.learning_pipeline import push_awaiting_reviews_to_telegram + + fake_session = MagicMock() + fake_session.execute.return_value.fetchall.return_value = [ + (7, "這是一段待審核學習內容", 0.91, 0.84) + ] + monkeypatch.setattr('database.manager.get_session', lambda: fake_session) + + sent = {} + + def fake_send(msg, chat_ids=None, reply_markup=None, parse_mode="HTML"): + sent["msg"] = msg + sent["chat_ids"] = chat_ids + sent["reply_markup"] = reply_markup + sent["parse_mode"] = parse_mode + return True + + monkeypatch.setattr("services.telegram_templates._send_telegram_raw", fake_send) + + count = push_awaiting_reviews_to_telegram(batch=1, chat_id="12345") + + assert count == 1 + assert sent["chat_ids"] == ["12345"] + assert "episode #7" in sent["msg"] + assert sent["reply_markup"]["inline_keyboard"][0][0]["callback_data"] == "pg_ok:7" + fake_session.close.assert_called_once() + + # ───────────────────────────────────────────────────────────────────────────── # hash_human_approver # ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_telegram_triaged_alert_format.py b/tests/test_telegram_triaged_alert_format.py index c7c2ad6..3fc56a9 100644 --- a/tests/test_telegram_triaged_alert_format.py +++ b/tests/test_telegram_triaged_alert_format.py @@ -1,5 +1,5 @@ from services import telegram_templates -from services.telegram_templates import _sanitize_telegram_html, triaged_alert +from services.telegram_templates import _sanitize_telegram_html, price_decision, triaged_alert def test_telegram_html_sanitizer_converts_br_tags_to_newlines(): @@ -38,6 +38,26 @@ def test_send_telegram_with_result_sanitizes_html_payload(monkeypatch): assert all(item["json"]["parse_mode"] == "HTML" for item in sent_payloads) +def test_price_decision_accepts_report_url_and_escapes_dynamic_fields(): + message, keyboard = price_decision( + product_name="精華 ", + insight_id=42, + report_url="https://mo.wooo.work/report?a=1&b=", + ) + + assert "