From dd3530639908bc2b9fd92f83851dd74dd225e426 Mon Sep 17 00:00:00 2001 From: OoO Date: Thu, 30 Apr 2026 23:17:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor(openclaw):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E9=81=B8=E5=96=AE=E9=8D=B5=E7=9B=A4=20builders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONSTITUTION.md | 2 +- app.py | 4 +- config.py | 2 +- .../code_modularization_inventory_20260430.md | 8 +- docs/memory/history_logs.md | 1 + routes/openclaw_bot_routes.py | 224 +--------------- services/openclaw_bot/menu_keyboards.py | 240 ++++++++++++++++++ tests/test_openclaw_bot_menu_keyboards.py | 63 +++++ 8 files changed, 325 insertions(+), 219 deletions(-) create mode 100644 services/openclaw_bot/menu_keyboards.py create mode 100644 tests/test_openclaw_bot_menu_keyboards.py diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 28f7e0b..63aedc4 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.29 (Gunicorn HUP hot reload 修正版) +> **當前版本**: V10.30 (OpenClaw 選單鍵盤模組化) > **最後更新**: 2026-04-30 --- diff --git a/app.py b/app.py index 8d4e593..bfd80d6 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-04-30 V10.29: Gunicorn HUP reload imports updated app code -SYSTEM_VERSION = "V10.29" +# 🚩 2026-04-30 V10.30: OpenClaw menu keyboard builders modularized +SYSTEM_VERSION = "V10.30" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index 23fcf92..eab2991 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.29" +SYSTEM_VERSION = "V10.30" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/code_modularization_inventory_20260430.md b/docs/memory/code_modularization_inventory_20260430.md index 00192dd..7cda6e2 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -4,8 +4,8 @@ ## 盤點結論 -- Python 總量:約 64,748 行。 -- 最大壓力區:`routes/` 約 21,020 行、`services/` 約 24,533 行。 +- Python 總量:約 65,113 行。 +- 最大壓力區:`routes/` 約 20,717 行、`services/` 約 24,908 行。 - `app.py` 已降到 1,206 行,功能定位應固定為 bootstrap / Blueprint registration / startup guard,不再承接新 route。 - 目前仍有 15 個 Python 檔案超過 800 行;這些不是禁止修 bug,而是禁止繼續塞新功能。 @@ -13,7 +13,7 @@ | 行數 | 檔案 | 分類 | 拆分方向 | |---:|---|---|---| -| 5437 | `routes/openclaw_bot_routes.py` | P0 巨型 Blueprint | route / bot command service / report service / scheduler hook | +| 5240 | `routes/openclaw_bot_routes.py` | P0 巨型 Blueprint | route / bot command service / report service / scheduler hook | | 2653 | `routes/sales_routes.py` | P0 巨型 Blueprint | page routes / API routes / chart query service / calendar service | | 2644 | `scheduler.py` | P0 排程總管 | task registry / crawler jobs / report jobs / notification jobs | | 1662 | `routes/ai_routes.py` | P1 AI Blueprint | route glue / AI orchestration service / prompt builders | @@ -31,7 +31,7 @@ ## 工作項目 -1. P0:持續拆 `routes/openclaw_bot_routes.py`;Telegram API helper 已先搬到 `services/openclaw_bot/telegram_api.py`,下一步拆 menu keyboard 或 report formatting。 +1. P0:持續拆 `routes/openclaw_bot_routes.py`;Telegram API helper 已搬到 `services/openclaw_bot/telegram_api.py`,Inline Keyboard builders 已搬到 `services/openclaw_bot/menu_keyboards.py`,下一步拆 report formatting 或 command dispatcher。 2. P0:拆 `routes/sales_routes.py`,先把 chart/query/calendar 計算搬到 `services/sales/`。 3. P0:拆 `scheduler.py`,建立 `jobs/` 或 `services/scheduler/` task registry。 4. P1:把 `routes/ai_routes.py` 與 `routes/vendor_routes.py` 的資料處理移出 route。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index e41b337..5218ad4 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -51,6 +51,7 @@ - **action_plans schema drift 修復**: CodeReview pipeline 寫入 action plan 時發現線上表只有 NemoTron Group B 欄位;啟動期 PostgreSQL metadata repair 會補 `action_type` / `description` / `priority` / `metadata_json` 與 index,恢復 AI code review action plan 閉環。 - **Gitea runner label 隔離**: EWOOOC CD workflow 改用 `ewoooc-host`;110 的 `/home/wooo/act-runner` runner config 必須只宣告 `ewoooc-host`,避免 user-level runner 混接 AWOOOI workflow。 - **CD sync hot reload**: 一般 Python/模板同步不再 `restart momo-app`,改為 `docker kill -s HUP momo-pro-system` 讓 Gunicorn 熱重載 workers,只重啟 scheduler / telegram-bot;Gunicorn 關閉 `preload_app`,確保 HUP 後 workers 會 import 新版 app code。 +- **OpenClaw Bot 第二刀拆分**: Inline Keyboard builders 移到 `services/openclaw_bot/menu_keyboards.py`,透過 `configure_menu_keyboards()` 注入 `latest_date/_GOALS/TAIPEI_TZ`,route 檔下降到 5,240 行並補選單回歸測試。 ### 2026-04-28~29:Phase 3e 重構大戰 + daily_sales cache 隱形 bug 根除 - **app.py 縮減 -10.8%**: 7,386 → 6,590 行,11 commits 全綠零 502。 diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 54cf75a..826bab7 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -44,6 +44,16 @@ from services.openclaw_bot.telegram_api import ( send_photo, send_typing, ) +from services.openclaw_bot.menu_keyboards import ( + _BACK, + _SUBMENUS, + _submenu_goals, + _submenu_market, + _submenu_sales, + _submenu_trend, + configure_menu_keyboards, + main_menu_keyboard, +) try: from services.openclaw_learning_service import ( build_rag_context, store_conversation, store_insight, @@ -2837,217 +2847,6 @@ def register_commands(): return _tg('setMyCommands', {'commands': cmds}) -# ── Inline Keyboard ─────────────────────────────────────────── -_BACK = [{'text': '← 返回主選單', 'callback_data': 'menu:main'}] - -def main_menu_keyboard(): - """第一層主選單 — 7大功能類別""" - return [ - [{'text': '📊 業績查詢', 'callback_data': 'menu:sales'}, - {'text': '🏆 商品廠商', 'callback_data': 'menu:products'}], - [{'text': '🎯 目標管理', 'callback_data': 'menu:goals'}, - {'text': '📈 智能分析', 'callback_data': 'menu:analysis'}], - [{'text': '📄 簡報報表', 'callback_data': 'menu:reports'}, - {'text': '🌐 市場情報', 'callback_data': 'menu:market'}], - [{'text': '🔍 競品日報', 'callback_data': 'menu:competitor'}], - [{'text': '❓ 使用說明', 'callback_data': 'cmd:help'}], - ] - -def _submenu_sales(): - ld = latest_date() or '' - yesterday = '' - current_month = datetime.now(TAIPEI_TZ).strftime('%Y/%m') - if ld: - try: - from datetime import datetime as _dt - yesterday = (_dt.strptime(ld.replace('/', '-'), '%Y-%m-%d') - timedelta(days=1)).strftime('%Y/%m/%d') - except Exception: - pass - d_label = ld[-5:] if ld else '-' - y_label = yesterday[-5:] if yesterday else '-' - return [ - [{'text': f'📊 今日 ({d_label})', 'callback_data': f'cmd:sales:{ld}'}, - {'text': f'⬅ 昨日 ({y_label})', 'callback_data': f'cmd:sales:{yesterday}'}], - [{'text': '📅 每週業績', 'callback_data': 'cmd:trend:week'}, - {'text': '📅 每月業績', 'callback_data': f'cmd:history:{current_month}'}], - [{'text': '📅 每季業績', 'callback_data': 'cmd:trend:quarter'}, - {'text': '📅 近半年', 'callback_data': 'cmd:trend:half'}], - [{'text': '📈 趨勢分析', 'callback_data': 'menu:trend'}, - {'text': '🔄 同期比較', 'callback_data': f'cmd:compare:{ld}'}], - [{'text': '🗂 分類業績', 'callback_data': f'cmd:category:{ld}'}, - {'text': '📅 日期/區間', 'callback_data': 'await:date_range_sales'}], - [{'text': '🗃 月份覽', 'callback_data': 'cmd:history'}], - _BACK, - ] - -def _submenu_products(): - ld = latest_date() or '' - yesterday = '' - if ld: - try: - from datetime import datetime as _dt - yesterday = (_dt.strptime(ld.replace('/', '-'), '%Y-%m-%d') - timedelta(days=1)).strftime('%Y/%m/%d') - except Exception: - pass - d_label = ld[-5:] if ld else '-' - y_label = yesterday[-5:] if yesterday else '-' - return [ - [{'text': f'🏆 熱銷商品 ({d_label})', 'callback_data': f'cmd:top:{ld}'}, - {'text': f'🏭 熱銷廠商 ({d_label})', 'callback_data': f'cmd:vendor:{ld}'}], - [{'text': f'⬅ 昨日商品 ({y_label})', 'callback_data': f'cmd:top:{yesterday}'}, - {'text': '🧬 商品健康', 'callback_data': f'cmd:health:{ld}'}], - [{'text': '📦 補貨預測', 'callback_data': 'cmd:restock'}, - {'text': '🗂 分類鑽取', 'callback_data': 'menu:category'}], - [{'text': '📅 指定日期', 'callback_data': 'await:date_top'}], - _BACK, - ] - -def _submenu_goals(): - dg = _GOALS.get('daily', 0) - mg = _GOALS.get('monthly', 0) - qg = _GOALS.get('quarterly', 0) - hg = _GOALS.get('half', 0) - yg = _GOALS.get('yearly', 0) - def _fmt(v): return f'{v/10000:.0f}萬' if v else '未設' - return [ - [{'text': '📋 查看達成率', 'callback_data': 'cmd:goal'}], - [{'text': f'日目標 ({_fmt(dg)})', 'callback_data': 'await:goal_daily'}, - {'text': f'月目標 ({_fmt(mg)})', 'callback_data': 'await:goal_monthly'}], - [{'text': f'季目標 ({_fmt(qg)})', 'callback_data': 'await:goal_quarterly'}, - {'text': f'半年目標 ({_fmt(hg)})', 'callback_data': 'await:goal_half'}], - [{'text': f'年目標 ({_fmt(yg)})', 'callback_data': 'await:goal_yearly'}], - _BACK, - ] - -def _submenu_analysis(): - ld = latest_date() or '' - return [ - [{'text': '🎲 策略矩陣', 'callback_data': f'cmd:strategy:{ld}'}, - {'text': '📈 業績趨勢', 'callback_data': 'menu:trend'}], - [{'text': '🧬 商品健康', 'callback_data': f'cmd:health:{ld}'}, - {'text': '🗂 分類業績', 'callback_data': f'cmd:category:{ld}'}], - [{'text': '🎉 促銷追蹤', 'callback_data': 'await:promo_range'}, - {'text': '📦 補貨預測', 'callback_data': 'cmd:restock'}], - [{'text': '📊 趨勢圖表', 'callback_data': 'cmd:chart'}, # P8 exposed - {'text': '🔄 同期比較', 'callback_data': f'cmd:compare:{ld}'}], - [{'text': '📅 指定日期', 'callback_data': 'await:date_analysis'}], - _BACK, - ] - - -def _submenu_category(): - """分類業績鑽取 — 顯示 L1 固定分類按鈕""" - ld = latest_date() or '' - CATS = [ - ('美妝保養', '💄'), ('保健食品/用品', '💊'), ('母嬰', '👶'), - ('食品飲料', '🍱'), ('家電', '🏠'), ('服裝內著', '👕'), - ('個人清潔', '🧴'), ('運動用品/器材', '🏃'), ('寵物', '🐾'), ('其他', '📦'), - ] - rows = [] - for i in range(0, len(CATS), 2): - pair = [] - for cat, icon in CATS[i:i+2]: - pair.append({'text': f'{icon} {cat}', 'callback_data': f'cmd:catdetail:{cat}:{ld}'}) - rows.append(pair) - rows.append([{'text': '🗂 全分類清單', 'callback_data': f'cmd:category:{ld}'}]) - rows.append(_BACK) - return rows - - -def _submenu_trend(): - return [ - [{'text': '📅 近7日', 'callback_data': 'cmd:trend:7'}, - {'text': '📅 近1個月', 'callback_data': 'cmd:trend:month'}], - [{'text': '📅 近3個月', 'callback_data': 'cmd:trend:quarter'}, - {'text': '📅 近半年', 'callback_data': 'cmd:trend:half'}], - [{'text': '📅 本年度', 'callback_data': 'cmd:trend:year'}, - {'text': '📅 指定月份', 'callback_data': 'await:date_trend_month'}], - [{'text': '📅 指定年份', 'callback_data': 'await:date_trend_year'}, - {'text': '📅 指定季度', 'callback_data': 'await:date_trend_quarter'}], - [{'text': '← 返回業績查詢', 'callback_data': 'menu:sales'}], - ] - -def _submenu_reports(): - return [ - # ── 定期報告 - [{'text': '📄 日報', 'callback_data': 'cmd:ppt:daily'}, - {'text': '📈 週報', 'callback_data': 'cmd:ppt:weekly'}], - [{'text': '📅 月報', 'callback_data': 'cmd:ppt:monthly'}, - {'text': '📋 下載報表','callback_data': 'cmd:report'}], - # ── 策略簡報 - [{'text': '🧩 策略(日)', 'callback_data': 'cmd:ppt:strategy'}, - {'text': '🧩 策略(週)', 'callback_data': 'cmd:ppt:strategy weekly'}], - [{'text': '🧩 策略(月)', 'callback_data': 'cmd:ppt:strategy monthly'}, - {'text': '🧩 策略(季)', 'callback_data': 'cmd:ppt:strategy quarterly'}], - [{'text': '🧩 策略(半年)','callback_data': 'cmd:ppt:strategy half'}, - {'text': '🧩 策略(年)', 'callback_data': 'cmd:ppt:strategy yearly'}], - # ── 促銷 & 競品 - [{'text': '🎉 促銷效益簡報', 'callback_data': 'await:promo_range'}, - {'text': '🔍 競品比較', 'callback_data': 'menu:competitor'}], - # ── 自訂 - [{'text': '📅 指定日期日報', 'callback_data': 'await:date_ppt_daily'}, - {'text': '📅 指定月份月報', 'callback_data': 'await:date_ppt_monthly'}], - _BACK, - ] - -def _submenu_market(): - return [ - [{'text': '📰 電商新聞', 'callback_data': 'cmd:news'}, - {'text': '🌤 台北天氣', 'callback_data': 'cmd:weather'}], - [{'text': '🔥 Google熱搜', 'callback_data': 'cmd:trends'}, - {'text': '💬 Dcard口碑', 'callback_data': 'cmd:dcard'}], - [{'text': '💱 台銀匯率', 'callback_data': 'cmd:exchange'}, - {'text': '📅 電商節慶', 'callback_data': 'cmd:calendar'}], - [{'text': '▶️ YouTube爆紅商品', 'callback_data': 'cmd:youtube'}, - {'text': '🧠 AI學習狀態', 'callback_data': 'cmd:learn'}], - [{'text': '🔍 關鍵字比價', 'callback_data': 'await:search_compare'}, - {'text': '📷 圖片比價說明', 'callback_data': 'cmd:photo_search_help'}], - _BACK, - ] - - -def _submenu_competitor(): - """競品日報第二層:所有選項直接產 PPT""" - today = datetime.now(TAIPEI_TZ).date() - yesterday = today - timedelta(days=1) - td_str = today.strftime('%Y/%m/%d') - yd_str = yesterday.strftime('%Y/%m/%d') - td_label = today.strftime('%m/%d') - yd_label = yesterday.strftime('%m/%d') - return [ - [{'text': f'📊 今日簡報 ({td_label})', 'callback_data': f'cmd:ppt:competitor {td_str}'}, - {'text': f'📊 昨日簡報 ({yd_label})', 'callback_data': f'cmd:ppt:competitor {yd_str}'}], - [{'text': '📈 本週比較', 'callback_data': 'cmd:ppt:competitor weekly'}, - {'text': '📆 本月比較', 'callback_data': 'cmd:ppt:competitor monthly'}], - [{'text': '🗃 本季比較', 'callback_data': 'cmd:ppt:competitor quarterly'}, - {'text': '📅 指定日期', 'callback_data': 'await:date_competitor'}], - [{'text': '📄 更多週期 →', 'callback_data': 'menu:competitor_ppt'}], - _BACK, - ] - - -def _submenu_competitor_ppt(): - """競品 PPT 長週期選單(第三層)— 半年/年;日/週/月/季已在第二層""" - return [ - [{'text': '📆 半年比較', 'callback_data': 'cmd:ppt:competitor half'}, - {'text': '🗓 年比較', 'callback_data': 'cmd:ppt:competitor yearly'}], - [{'text': '← 返回競品日報', 'callback_data': 'menu:competitor'}], - ] - -_SUBMENUS = { - 'main': main_menu_keyboard, - 'sales': _submenu_sales, - 'products': _submenu_products, - 'goals': _submenu_goals, - 'analysis': _submenu_analysis, - 'trend': _submenu_trend, - 'reports': _submenu_reports, - 'market': _submenu_market, - 'competitor': _submenu_competitor, - 'competitor_ppt': _submenu_competitor_ppt, - 'category': _submenu_category, -} - _AWAIT_PROMPTS = { 'date_sales': ('📅 請輸入查詢日期\n格式:`2026/04/15`', '業績日期'), 'date_range_sales': ( @@ -3109,6 +2908,9 @@ def latest_date() -> str: return None +configure_menu_keyboards(latest_date_provider=latest_date, goals=_GOALS, taipei_tz=TAIPEI_TZ) + + def query_sales(d: str) -> dict: try: with _db().connect() as c: diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py new file mode 100644 index 0000000..d4a45ae --- /dev/null +++ b/services/openclaw_bot/menu_keyboards.py @@ -0,0 +1,240 @@ +"""OpenClaw Telegram inline keyboard builders.""" + +from datetime import datetime, timedelta, timezone + + +TAIPEI_TZ = timezone(timedelta(hours=8)) +_GOALS = {} +_latest_date_provider = lambda: None + +_BACK = [{'text': '← 返回主選單', 'callback_data': 'menu:main'}] + + +def configure_menu_keyboards(latest_date_provider=None, goals=None, taipei_tz=None): + """Inject runtime dependencies owned by the route module.""" + global _latest_date_provider, _GOALS, TAIPEI_TZ + if latest_date_provider is not None: + _latest_date_provider = latest_date_provider + if goals is not None: + _GOALS = goals + if taipei_tz is not None: + TAIPEI_TZ = taipei_tz + + +def _latest_date(): + return _latest_date_provider() or '' + + +def _yesterday_from(date_str): + if not date_str: + return '' + try: + return ( + datetime.strptime(date_str.replace('/', '-'), '%Y-%m-%d') + - timedelta(days=1) + ).strftime('%Y/%m/%d') + except Exception: + return '' + + +def main_menu_keyboard(): + """第一層主選單 — 7大功能類別""" + return [ + [{'text': '📊 業績查詢', 'callback_data': 'menu:sales'}, + {'text': '🏆 商品廠商', 'callback_data': 'menu:products'}], + [{'text': '🎯 目標管理', 'callback_data': 'menu:goals'}, + {'text': '📈 智能分析', 'callback_data': 'menu:analysis'}], + [{'text': '📄 簡報報表', 'callback_data': 'menu:reports'}, + {'text': '🌐 市場情報', 'callback_data': 'menu:market'}], + [{'text': '🔍 競品日報', 'callback_data': 'menu:competitor'}], + [{'text': '❓ 使用說明', 'callback_data': 'cmd:help'}], + ] + + +def _submenu_sales(): + ld = _latest_date() + yesterday = _yesterday_from(ld) + current_month = datetime.now(TAIPEI_TZ).strftime('%Y/%m') + d_label = ld[-5:] if ld else '-' + y_label = yesterday[-5:] if yesterday else '-' + return [ + [{'text': f'📊 今日 ({d_label})', 'callback_data': f'cmd:sales:{ld}'}, + {'text': f'⬅ 昨日 ({y_label})', 'callback_data': f'cmd:sales:{yesterday}'}], + [{'text': '📅 每週業績', 'callback_data': 'cmd:trend:week'}, + {'text': '📅 每月業績', 'callback_data': f'cmd:history:{current_month}'}], + [{'text': '📅 每季業績', 'callback_data': 'cmd:trend:quarter'}, + {'text': '📅 近半年', 'callback_data': 'cmd:trend:half'}], + [{'text': '📈 趨勢分析', 'callback_data': 'menu:trend'}, + {'text': '🔄 同期比較', 'callback_data': f'cmd:compare:{ld}'}], + [{'text': '🗂 分類業績', 'callback_data': f'cmd:category:{ld}'}, + {'text': '📅 日期/區間', 'callback_data': 'await:date_range_sales'}], + [{'text': '🗃 月份覽', 'callback_data': 'cmd:history'}], + _BACK, + ] + + +def _submenu_products(): + ld = _latest_date() + yesterday = _yesterday_from(ld) + d_label = ld[-5:] if ld else '-' + y_label = yesterday[-5:] if yesterday else '-' + return [ + [{'text': f'🏆 熱銷商品 ({d_label})', 'callback_data': f'cmd:top:{ld}'}, + {'text': f'🏭 熱銷廠商 ({d_label})', 'callback_data': f'cmd:vendor:{ld}'}], + [{'text': f'⬅ 昨日商品 ({y_label})', 'callback_data': f'cmd:top:{yesterday}'}, + {'text': '🧬 商品健康', 'callback_data': f'cmd:health:{ld}'}], + [{'text': '📦 補貨預測', 'callback_data': 'cmd:restock'}, + {'text': '🗂 分類鑽取', 'callback_data': 'menu:category'}], + [{'text': '📅 指定日期', 'callback_data': 'await:date_top'}], + _BACK, + ] + + +def _submenu_goals(): + dg = _GOALS.get('daily', 0) + mg = _GOALS.get('monthly', 0) + qg = _GOALS.get('quarterly', 0) + hg = _GOALS.get('half', 0) + yg = _GOALS.get('yearly', 0) + + def _fmt(v): + return f'{v/10000:.0f}萬' if v else '未設' + + return [ + [{'text': '📋 查看達成率', 'callback_data': 'cmd:goal'}], + [{'text': f'日目標 ({_fmt(dg)})', 'callback_data': 'await:goal_daily'}, + {'text': f'月目標 ({_fmt(mg)})', 'callback_data': 'await:goal_monthly'}], + [{'text': f'季目標 ({_fmt(qg)})', 'callback_data': 'await:goal_quarterly'}, + {'text': f'半年目標 ({_fmt(hg)})', 'callback_data': 'await:goal_half'}], + [{'text': f'年目標 ({_fmt(yg)})', 'callback_data': 'await:goal_yearly'}], + _BACK, + ] + + +def _submenu_analysis(): + ld = _latest_date() + return [ + [{'text': '🎲 策略矩陣', 'callback_data': f'cmd:strategy:{ld}'}, + {'text': '📈 業績趨勢', 'callback_data': 'menu:trend'}], + [{'text': '🧬 商品健康', 'callback_data': f'cmd:health:{ld}'}, + {'text': '🗂 分類業績', 'callback_data': f'cmd:category:{ld}'}], + [{'text': '🎉 促銷追蹤', 'callback_data': 'await:promo_range'}, + {'text': '📦 補貨預測', 'callback_data': 'cmd:restock'}], + [{'text': '📊 趨勢圖表', 'callback_data': 'cmd:chart'}, + {'text': '🔄 同期比較', 'callback_data': f'cmd:compare:{ld}'}], + [{'text': '📅 指定日期', 'callback_data': 'await:date_analysis'}], + _BACK, + ] + + +def _submenu_category(): + """分類業績鑽取 — 顯示 L1 固定分類按鈕""" + ld = _latest_date() + cats = [ + ('美妝保養', '💄'), ('保健食品/用品', '💊'), ('母嬰', '👶'), + ('食品飲料', '🍱'), ('家電', '🏠'), ('服裝內著', '👕'), + ('個人清潔', '🧴'), ('運動用品/器材', '🏃'), ('寵物', '🐾'), ('其他', '📦'), + ] + rows = [] + for i in range(0, len(cats), 2): + pair = [] + for cat, icon in cats[i:i + 2]: + pair.append({'text': f'{icon} {cat}', 'callback_data': f'cmd:catdetail:{cat}:{ld}'}) + rows.append(pair) + rows.append([{'text': '🗂 全分類清單', 'callback_data': f'cmd:category:{ld}'}]) + rows.append(_BACK) + return rows + + +def _submenu_trend(): + return [ + [{'text': '📅 近7日', 'callback_data': 'cmd:trend:7'}, + {'text': '📅 近1個月', 'callback_data': 'cmd:trend:month'}], + [{'text': '📅 近3個月', 'callback_data': 'cmd:trend:quarter'}, + {'text': '📅 近半年', 'callback_data': 'cmd:trend:half'}], + [{'text': '📅 本年度', 'callback_data': 'cmd:trend:year'}, + {'text': '📅 指定月份', 'callback_data': 'await:date_trend_month'}], + [{'text': '📅 指定年份', 'callback_data': 'await:date_trend_year'}, + {'text': '📅 指定季度', 'callback_data': 'await:date_trend_quarter'}], + [{'text': '← 返回業績查詢', 'callback_data': 'menu:sales'}], + ] + + +def _submenu_reports(): + return [ + [{'text': '📄 日報', 'callback_data': 'cmd:ppt:daily'}, + {'text': '📈 週報', 'callback_data': 'cmd:ppt:weekly'}], + [{'text': '📅 月報', 'callback_data': 'cmd:ppt:monthly'}, + {'text': '📋 下載報表', 'callback_data': 'cmd:report'}], + [{'text': '🧩 策略(日)', 'callback_data': 'cmd:ppt:strategy'}, + {'text': '🧩 策略(週)', 'callback_data': 'cmd:ppt:strategy weekly'}], + [{'text': '🧩 策略(月)', 'callback_data': 'cmd:ppt:strategy monthly'}, + {'text': '🧩 策略(季)', 'callback_data': 'cmd:ppt:strategy quarterly'}], + [{'text': '🧩 策略(半年)', 'callback_data': 'cmd:ppt:strategy half'}, + {'text': '🧩 策略(年)', 'callback_data': 'cmd:ppt:strategy yearly'}], + [{'text': '🎉 促銷效益簡報', 'callback_data': 'await:promo_range'}, + {'text': '🔍 競品比較', 'callback_data': 'menu:competitor'}], + [{'text': '📅 指定日期日報', 'callback_data': 'await:date_ppt_daily'}, + {'text': '📅 指定月份月報', 'callback_data': 'await:date_ppt_monthly'}], + _BACK, + ] + + +def _submenu_market(): + return [ + [{'text': '📰 電商新聞', 'callback_data': 'cmd:news'}, + {'text': '🌤 台北天氣', 'callback_data': 'cmd:weather'}], + [{'text': '🔥 Google熱搜', 'callback_data': 'cmd:trends'}, + {'text': '💬 Dcard口碑', 'callback_data': 'cmd:dcard'}], + [{'text': '💱 台銀匯率', 'callback_data': 'cmd:exchange'}, + {'text': '📅 電商節慶', 'callback_data': 'cmd:calendar'}], + [{'text': '▶️ YouTube爆紅商品', 'callback_data': 'cmd:youtube'}, + {'text': '🧠 AI學習狀態', 'callback_data': 'cmd:learn'}], + [{'text': '🔍 關鍵字比價', 'callback_data': 'await:search_compare'}, + {'text': '📷 圖片比價說明', 'callback_data': 'cmd:photo_search_help'}], + _BACK, + ] + + +def _submenu_competitor(): + """競品日報第二層:所有選項直接產 PPT""" + today = datetime.now(TAIPEI_TZ).date() + yesterday = today - timedelta(days=1) + td_str = today.strftime('%Y/%m/%d') + yd_str = yesterday.strftime('%Y/%m/%d') + td_label = today.strftime('%m/%d') + yd_label = yesterday.strftime('%m/%d') + return [ + [{'text': f'📊 今日簡報 ({td_label})', 'callback_data': f'cmd:ppt:competitor {td_str}'}, + {'text': f'📊 昨日簡報 ({yd_label})', 'callback_data': f'cmd:ppt:competitor {yd_str}'}], + [{'text': '📈 本週比較', 'callback_data': 'cmd:ppt:competitor weekly'}, + {'text': '📆 本月比較', 'callback_data': 'cmd:ppt:competitor monthly'}], + [{'text': '🗃 本季比較', 'callback_data': 'cmd:ppt:competitor quarterly'}, + {'text': '📅 指定日期', 'callback_data': 'await:date_competitor'}], + [{'text': '📄 更多週期 →', 'callback_data': 'menu:competitor_ppt'}], + _BACK, + ] + + +def _submenu_competitor_ppt(): + """競品 PPT 長週期選單(第三層)— 半年/年;日/週/月/季已在第二層""" + return [ + [{'text': '📆 半年比較', 'callback_data': 'cmd:ppt:competitor half'}, + {'text': '🗓 年比較', 'callback_data': 'cmd:ppt:competitor yearly'}], + [{'text': '← 返回競品日報', 'callback_data': 'menu:competitor'}], + ] + + +_SUBMENUS = { + 'main': main_menu_keyboard, + 'sales': _submenu_sales, + 'products': _submenu_products, + 'goals': _submenu_goals, + 'analysis': _submenu_analysis, + 'trend': _submenu_trend, + 'reports': _submenu_reports, + 'market': _submenu_market, + 'competitor': _submenu_competitor, + 'competitor_ppt': _submenu_competitor_ppt, + 'category': _submenu_category, +} diff --git a/tests/test_openclaw_bot_menu_keyboards.py b/tests/test_openclaw_bot_menu_keyboards.py new file mode 100644 index 0000000..f1b92e5 --- /dev/null +++ b/tests/test_openclaw_bot_menu_keyboards.py @@ -0,0 +1,63 @@ +from datetime import timezone, timedelta +from pathlib import Path + + +def test_sales_menu_uses_injected_latest_date(): + from services.openclaw_bot import menu_keyboards + + menu_keyboards.configure_menu_keyboards( + latest_date_provider=lambda: "2026/04/30", + goals={}, + taipei_tz=timezone(timedelta(hours=8)), + ) + + rows = menu_keyboards._submenu_sales() + + assert rows[0][0]["callback_data"] == "cmd:sales:2026/04/30" + assert rows[0][0]["text"] == "📊 今日 (04/30)" + assert rows[0][1]["callback_data"] == "cmd:sales:2026/04/29" + assert rows[-1] == menu_keyboards._BACK + + +def test_goal_menu_uses_injected_goal_state(): + from services.openclaw_bot import menu_keyboards + + menu_keyboards.configure_menu_keyboards(goals={"daily": 1_500_000, "monthly": 0}) + + rows = menu_keyboards._submenu_goals() + + assert rows[1][0]["text"] == "日目標 (150萬)" + assert rows[1][1]["text"] == "月目標 (未設)" + + +def test_category_menu_and_submenu_registry_are_stable(): + from services.openclaw_bot import menu_keyboards + + menu_keyboards.configure_menu_keyboards(latest_date_provider=lambda: "2026/04/30") + + rows = menu_keyboards._submenu_category() + + assert rows[0][0]["callback_data"] == "cmd:catdetail:美妝保養:2026/04/30" + assert rows[-2] == [{"text": "🗂 全分類清單", "callback_data": "cmd:category:2026/04/30"}] + assert rows[-1] == menu_keyboards._BACK + assert set(menu_keyboards._SUBMENUS) >= { + "main", + "sales", + "products", + "goals", + "analysis", + "trend", + "reports", + "market", + "competitor", + "competitor_ppt", + "category", + } + + +def test_openclaw_routes_import_menu_keyboard_helpers(): + route_source = Path("routes/openclaw_bot_routes.py").read_text(encoding="utf-8") + + assert "from services.openclaw_bot.menu_keyboards import" in route_source + assert "configure_menu_keyboards(latest_date_provider=latest_date" in route_source + assert "def main_menu_keyboard():" not in route_source