This commit is contained in:
@@ -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 送出前會把 `<br>` / `<br/>` / `<BR />` 統一轉成換行,避免 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。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 不支援 `<br>`,可能導致告警或報告送出 400 | V10.321 起 Telegram template 發送前會把 `<br>` / `<br/>` / `<BR />` 轉為換行;保留其他 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()` |
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"💰 <b>AI 定價決策建議</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"🏷️ <b>{product_name}</b> <code>{product_sku}</code>\n\n"
|
||||
f"🏷️ <b>{safe_name}</b> <code>{safe_sku}</code>\n\n"
|
||||
f"現價:<b>NT${current_price:,.0f}</b>\n"
|
||||
f"建議:<b>NT${suggested_price:,.0f}</b> {direction} {action_text}\n\n"
|
||||
f"💡 <b>依據:</b>{reason}\n"
|
||||
f"💡 <b>依據:</b>{safe_reason}\n"
|
||||
)
|
||||
if insight_id:
|
||||
message += f"🔗 洞察 ID:<code>{insight_id}</code>\n"
|
||||
if report_url:
|
||||
message += f"📎 <a href=\"{escape(str(report_url), quote=True)}\">查看分析報表</a>\n"
|
||||
message += f"━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# ADR-012: callback_data 採短 prefix(momo:pa/pr:{insight_id})— 64-byte 安全、與 L2 handler 一致
|
||||
|
||||
133
tests/test_bot_api_telegram_delivery.py
Normal file
133
tests/test_bot_api_telegram_delivery.py
Normal file
@@ -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": "精華 <script>",
|
||||
"current_price": 1200,
|
||||
"suggested_price": 990,
|
||||
"reason": "第一行<br>第二行<script>alert(1)</script>",
|
||||
"insight_id": 42,
|
||||
"report_url": "https://mo.wooo.work/report?a=1&b=<x>",
|
||||
},
|
||||
)
|
||||
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 "<br" not in sent["text"].lower()
|
||||
assert "<script>" 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"] == []
|
||||
@@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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="精華 <script>",
|
||||
product_sku="SKU<001>",
|
||||
current_price=1200,
|
||||
suggested_price=990,
|
||||
reason="第一行<br>第二行<script>alert(1)</script>",
|
||||
insight_id=42,
|
||||
report_url="https://mo.wooo.work/report?a=1&b=<x>",
|
||||
)
|
||||
|
||||
assert "<script>" not in message
|
||||
assert "<br" not in message.lower()
|
||||
assert "第一行\n第二行<script>alert(1)</script>" in message
|
||||
assert "精華 <script>" in message
|
||||
assert "SKU<001>" in message
|
||||
assert 'href="https://mo.wooo.work/report?a=1&b=<x>"' in message
|
||||
assert keyboard["inline_keyboard"][0][0]["callback_data"] == "momo:pa:42"
|
||||
|
||||
|
||||
def test_ea_escalation_uses_structured_incident_brief():
|
||||
msg, keyboard = triaged_alert(
|
||||
base_event={
|
||||
|
||||
Reference in New Issue
Block a user