refactor(openclaw): 抽出選單鍵盤 builders
All checks were successful
CD Pipeline / deploy (push) Successful in 1m46s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m46s
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文件定義專案開發的核心準則與不可違反的規範
|
||||
> **建立日期**: 2026-01-12
|
||||
> **當前版本**: V10.29 (Gunicorn HUP hot reload 修正版)
|
||||
> **當前版本**: V10.30 (OpenClaw 選單鍵盤模組化)
|
||||
> **最後更新**: 2026-04-30
|
||||
|
||||
---
|
||||
|
||||
4
app.py
4
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 防護函數
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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:
|
||||
|
||||
240
services/openclaw_bot/menu_keyboards.py
Normal file
240
services/openclaw_bot/menu_keyboards.py
Normal file
@@ -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,
|
||||
}
|
||||
63
tests/test_openclaw_bot_menu_keyboards.py
Normal file
63
tests/test_openclaw_bot_menu_keyboards.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user