diff --git a/backend/migrations/versions/014_add_proactive_push_settings.py b/backend/migrations/versions/014_add_proactive_push_settings.py new file mode 100644 index 00000000..2ecfe7de --- /dev/null +++ b/backend/migrations/versions/014_add_proactive_push_settings.py @@ -0,0 +1,41 @@ +"""新增主動推送設定預設值 + +在 bot_settings 表中新增 proactive_push_enabled 設定: +- Line:預設關閉(false) +- Telegram:預設開啟(true) + +Revision ID: 014 +""" + +from datetime import datetime, timezone + +from alembic import op + + +revision = "014" +down_revision = "013" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + now = datetime.now(timezone.utc) + op.execute( + f""" + INSERT INTO bot_settings (platform, key, value, updated_at) + VALUES + ('line', 'proactive_push_enabled', 'false', '{now.isoformat()}'), + ('telegram', 'proactive_push_enabled', 'true', '{now.isoformat()}') + ON CONFLICT (platform, key) DO NOTHING + """ + ) + + +def downgrade() -> None: + op.execute( + """ + DELETE FROM bot_settings + WHERE key = 'proactive_push_enabled' + AND platform IN ('line', 'telegram') + """ + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d33f80e3..309b075c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -67,6 +67,19 @@ include = ["src/ching_tech_os"] [tool.uv] package = true +[tool.coverage.run] +omit = [ + # Skill 腳本為獨立執行的子行程,不適合單元測試 + "src/ching_tech_os/skills/*/scripts/*.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + [dependency-groups] dev = [ "httpx>=0.28.1", diff --git a/backend/src/ching_tech_os/api/bot_settings.py b/backend/src/ching_tech_os/api/bot_settings.py index ef90f01d..4a73ea09 100644 --- a/backend/src/ching_tech_os/api/bot_settings.py +++ b/backend/src/ching_tech_os/api/bot_settings.py @@ -21,6 +21,8 @@ get_bot_credentials, update_bot_credentials, delete_bot_credentials, + get_proactive_push_enabled, + update_proactive_push_enabled, ) logger = logging.getLogger(__name__) @@ -70,6 +72,7 @@ class UpdateBotSettingsRequest(BaseModel): bot_token: str | None = None webhook_secret: str | None = None admin_chat_id: str | None = None + proactive_push_enabled: bool | None = None class UpdateBotSettingsResponse(BaseModel): @@ -90,6 +93,7 @@ class BotSettingsStatusResponse(BaseModel): """Bot 設定狀態回應""" platform: str fields: dict[str, FieldStatus] + proactive_push_enabled: bool = False class BotSettingsDeleteResponse(BaseModel): @@ -114,7 +118,9 @@ async def get_settings_status( ): """取得 Bot 設定狀態(遮罩顯示)""" _validate_platform(platform) - return await get_bot_credentials_status(platform) + status = await get_bot_credentials_status(platform) + status["proactive_push_enabled"] = await get_proactive_push_enabled(platform) + return status @router.put("/{platform}", response_model=UpdateBotSettingsResponse) @@ -126,12 +132,22 @@ async def update_settings( """更新 Bot 憑證""" _validate_platform(platform) - # 過濾 None 和空字串(只更新有值的欄位) - credentials = {k: v for k, v in body.model_dump(exclude_none=True).items() if v} - if not credentials: + data = body.model_dump(exclude_none=True) + + # 處理主動推送開關(獨立存儲,不經過 update_bot_credentials) + push_enabled = data.pop("proactive_push_enabled", None) + + # 過濾憑證(None 和空字串排除) + credentials = {k: v for k, v in data.items() if v} + + if not credentials and push_enabled is None: raise HTTPException(status_code=400, detail="至少需要一個非空欄位") - await update_bot_credentials(platform, credentials) + if credentials: + await update_bot_credentials(platform, credentials) + if push_enabled is not None: + await update_proactive_push_enabled(platform, push_enabled) + return UpdateBotSettingsResponse(success=True, message=f"{platform} 設定已更新") diff --git a/backend/src/ching_tech_os/api/internal_push.py b/backend/src/ching_tech_os/api/internal_push.py new file mode 100644 index 00000000..99ae29e8 --- /dev/null +++ b/backend/src/ching_tech_os/api/internal_push.py @@ -0,0 +1,151 @@ +"""內部主動推送端點 + +供背景任務子行程完成時呼叫,觸發推送通知給發起者。 +僅限本機存取(127.0.0.1)。 +""" + +import json +import logging +import os +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from ..services.proactive_push_service import notify_job_complete + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/internal", tags=["Internal"]) + + +def _get_ctos_mount() -> str: + """取得 CTOS 掛載路徑""" + try: + from ..config import settings + return settings.ctos_mount_path + except Exception: + return os.environ.get("CTOS_MOUNT_PATH", "/mnt/nas/ctos") + + +def _find_status_file(skill: str, job_id: str) -> Path | None: + """依 skill 名稱和 job_id 搜尋 status.json(掃描最近 7 天)""" + ctos = _get_ctos_mount() + skill_subdir = { + "research-skill": "research", + "media-downloader": "videos", + "media-transcription": "transcriptions", + }.get(skill) + + if not skill_subdir: + return None + + base = Path(ctos) / "linebot" / skill_subdir + if not base.exists(): + return None + + for date_dir in sorted(base.iterdir(), reverse=True): + if not date_dir.is_dir(): + continue + status_path = date_dir / job_id / "status.json" + if status_path.exists(): + return status_path + + return None + + +def _build_message(skill: str, status: dict) -> str: + """依 skill 組裝推送訊息""" + job_id = status.get("job_id", "") + + if skill == "research-skill": + query = status.get("query", "") + summary = status.get("final_summary") or status.get("summary") or status.get("result", "") + if isinstance(summary, str) and len(summary) > 500: + summary = summary[:500] + "…" + lines = ["✅ 研究任務完成"] + if query: + lines.append(f"查詢:{query}") + if summary: + lines.append(f"\n{summary}") + lines.append(f"\n(job_id: {job_id})") + return "\n".join(lines) + + if skill == "media-downloader": + filename = status.get("filename", "") + file_size = status.get("file_size", 0) + ctos_path = status.get("ctos_path", "") + size_mb = f"{file_size / 1024 / 1024:.1f} MB" if file_size else "" + lines = ["✅ 影片下載完成"] + if filename: + lines.append(f"檔案:{filename}" + (f"({size_mb})" if size_mb else "")) + if ctos_path: + lines.append(f"路徑:{ctos_path}") + lines.append(f"(job_id: {job_id})") + return "\n".join(lines) + + if skill == "media-transcription": + transcript = status.get("transcript_preview") or status.get("transcript", "") + ctos_path = status.get("ctos_path", "") + preview = transcript[:300] + "…" if transcript and len(transcript) > 300 else transcript + lines = ["✅ 轉錄完成"] + if preview: + lines.append(f"\n{preview}") + if ctos_path: + lines.append(f"\n完整逐字稿:{ctos_path}") + lines.append(f"(job_id: {job_id})") + return "\n".join(lines) + + return f"✅ 任務完成(job_id: {job_id})" + + +class ProactivePushRequest(BaseModel): + job_id: str + skill: str + + +@router.post("/proactive-push") +async def trigger_proactive_push(body: ProactivePushRequest, request: Request): + """背景任務完成後觸發主動推送(僅限本機存取)""" + client_host = request.client.host if request.client else "" + if client_host not in ("127.0.0.1", "::1", "localhost"): + raise HTTPException(status_code=403, detail="僅限本機存取") + + status_path = _find_status_file(body.skill, body.job_id) + if not status_path: + logger.warning(f"找不到 status.json: skill={body.skill} job_id={body.job_id}") + return {"ok": False, "reason": "status not found"} + + try: + status = json.loads(status_path.read_text(encoding="utf-8")) + except Exception: + logger.warning(f"讀取 status.json 失敗: {status_path}", exc_info=True) + return {"ok": False, "reason": "status read error"} + + caller_context = status.get("caller_context") + if not caller_context: + logger.debug(f"無 caller_context,跳過推送: job_id={body.job_id}") + return {"ok": False, "reason": "no caller_context"} + + platform = caller_context.get("platform", "") + platform_user_id = caller_context.get("platform_user_id", "") + is_group = bool(caller_context.get("is_group", False)) + group_id = caller_context.get("group_id") + + # 群組對話只需 group_id,個人對話需要 platform_user_id + has_target = (is_group and group_id) or platform_user_id + if not platform or not has_target: + logger.warning(f"caller_context 缺少必要欄位: {caller_context}") + return {"ok": False, "reason": "invalid caller_context"} + + message = _build_message(body.skill, status) + + await notify_job_complete( + platform=platform, + platform_user_id=platform_user_id, + is_group=is_group, + group_id=group_id, + message=message, + ) + + return {"ok": True} diff --git a/backend/src/ching_tech_os/modules.py b/backend/src/ching_tech_os/modules.py index a7c8bc15..1304d3de 100644 --- a/backend/src/ching_tech_os/modules.py +++ b/backend/src/ching_tech_os/modules.py @@ -49,6 +49,7 @@ class ModuleInfo(TypedDict, total=False): {"module": ".api.user", "attr": "router"}, {"module": ".api.user", "attr": "admin_router"}, {"module": ".api.config_public", "attr": "router"}, + {"module": ".api.internal_push", "attr": "router"}, ], "app_ids": ["settings"], "app_manifest": [ diff --git a/backend/src/ching_tech_os/services/bot/command_handlers.py b/backend/src/ching_tech_os/services/bot/command_handlers.py index 8a1bebe6..43b36ced 100644 --- a/backend/src/ching_tech_os/services/bot/command_handlers.py +++ b/backend/src/ching_tech_os/services/bot/command_handlers.py @@ -466,7 +466,7 @@ def register_builtin_commands() -> None: description="系統診斷", require_bound=True, require_admin=True, - private_only=False, + private_only=True, ), SlashCommand( name="agent", diff --git a/backend/src/ching_tech_os/services/bot/identity_router.py b/backend/src/ching_tech_os/services/bot/identity_router.py index 420cd960..7720f978 100644 --- a/backend/src/ching_tech_os/services/bot/identity_router.py +++ b/backend/src/ching_tech_os/services/bot/identity_router.py @@ -102,8 +102,13 @@ async def route_unbound( policy = get_unbound_policy() if policy == "restricted": + # restricted 策略:群組中也使用受限模式(群組可設定 restricted_agent_id 服務未綁定用戶) return UnboundRouteResult(action="restricted") + # reject 策略:群組靜默忽略(避免對群組廣播「請綁定帳號」提示) + if is_group: + return UnboundRouteResult(action="silent") + # reject 策略:嘗試從 agent settings 讀取自訂綁定提示 from .. import ai_manager diff --git a/backend/src/ching_tech_os/services/bot_settings.py b/backend/src/ching_tech_os/services/bot_settings.py index 9a50121f..b5549acc 100644 --- a/backend/src/ching_tech_os/services/bot_settings.py +++ b/backend/src/ching_tech_os/services/bot_settings.py @@ -158,6 +158,41 @@ async def update_bot_credentials(platform: str, credentials: dict[str, str]) -> logger.info(f"已更新 {platform} 憑證: {list(credentials.keys())}") +# 各平台主動推送的預設值(無設定時使用) +_PROACTIVE_PUSH_DEFAULTS: dict[str, bool] = { + "line": False, + "telegram": True, +} + + +async def get_proactive_push_enabled(platform: str) -> bool: + """取得平台的主動推送開關狀態""" + async with get_connection() as conn: + row = await conn.fetchrow( + "SELECT value FROM bot_settings WHERE platform = $1 AND key = 'proactive_push_enabled'", + platform, + ) + if row is None: + return _PROACTIVE_PUSH_DEFAULTS.get(platform, False) + return row["value"].lower() == "true" + + +async def update_proactive_push_enabled(platform: str, enabled: bool) -> None: + """更新平台的主動推送開關""" + now = datetime.now(timezone.utc) + async with get_connection() as conn: + await conn.execute( + """ + INSERT INTO bot_settings (platform, key, value, updated_at) + VALUES ($1, 'proactive_push_enabled', $2, $3) + ON CONFLICT (platform, key) + DO UPDATE SET value = $2, updated_at = $3 + """, + platform, "true" if enabled else "false", now, + ) + logger.info(f"已更新 {platform} 主動推送設定: {enabled}") + + async def delete_bot_credentials(platform: str) -> int: """刪除 Bot 憑證 diff --git a/backend/src/ching_tech_os/services/linebot_ai.py b/backend/src/ching_tech_os/services/linebot_ai.py index c84fb09a..7ecf12f5 100644 --- a/backend/src/ching_tech_os/services/linebot_ai.py +++ b/backend/src/ching_tech_os/services/linebot_ai.py @@ -1389,6 +1389,7 @@ async def build_system_prompt( - 查詢一次若未完成 → 告知使用者目前進度,請稍後再問,結束回應。 - 嚴禁使用 sleep 等待任務完成,嚴禁在同一回應中反覆 sleep + check。 - 這些任務可能需要數分鐘,反覆等待必定導致整體超時。 +- 呼叫 start-research、download-video、transcribe 時,必須在 input JSON 附帶 caller_context 欄位(格式見【對話識別】末端),讓任務完成後系統可主動通知發起者。 【長時外部研究(規則)】 - 需要「搜尋 + 擷取 + 統整」多個來源時,必須使用 research-skill(start/check): @@ -1493,20 +1494,36 @@ async def build_system_prompt( platform_label = "Telegram" if platform_type == "telegram" else "Line" if line_group_id: + platform_group_id: str | None = None async with get_connection() as conn: group = await conn.fetchrow( - "SELECT name FROM bot_groups WHERE id = $1", + "SELECT name, platform_group_id FROM bot_groups WHERE id = $1", line_group_id, ) if group: - base_prompt += f"\n\n目前群組:{group['name'] or '未命名群組'}" + if group["name"]: + base_prompt += f"\n\n目前群組:{group['name']}" + platform_group_id = group["platform_group_id"] # 加入群組 ID 和用戶身份識別 base_prompt += f"\n\n【對話識別】\n平台:{platform_label}" base_prompt += f"\ngroup_id: {line_group_id}" + if platform_group_id: + base_prompt += f"\nplatform_group_id: {platform_group_id}" + if line_user_id: + base_prompt += f"\nplatform_user_id: {line_user_id}" if ctos_user_id: base_prompt += f"\nctos_user_id: {ctos_user_id}" else: base_prompt += "\nctos_user_id: (未關聯)" + # caller_context 範本(供背景任務附帶) + import json as _json + _caller_ctx = { + "platform": platform_type, + "platform_user_id": line_user_id or "", + "is_group": True, + "group_id": platform_group_id or "", + } + base_prompt += f"\ncaller_context(呼叫背景任務時附帶此值): {_json.dumps(_caller_ctx, ensure_ascii=False)}" elif line_user_id: # 個人對話:加入用戶 ID 和身份識別 base_prompt += f"\n\n【對話識別】\n平台:{platform_label}" @@ -1515,6 +1532,15 @@ async def build_system_prompt( base_prompt += f"\nctos_user_id: {ctos_user_id}" else: base_prompt += "\nctos_user_id: (未關聯,無法進行專案更新操作)" + # caller_context 範本(供背景任務附帶) + import json as _json + _caller_ctx = { + "platform": platform_type, + "platform_user_id": line_user_id, + "is_group": False, + "group_id": None, + } + base_prompt += f"\ncaller_context(呼叫背景任務時附帶此值): {_json.dumps(_caller_ctx, ensure_ascii=False)}" return base_prompt diff --git a/backend/src/ching_tech_os/services/mcp/presentation_tools.py b/backend/src/ching_tech_os/services/mcp/presentation_tools.py index 873c0ea7..db4c9760 100644 --- a/backend/src/ching_tech_os/services/mcp/presentation_tools.py +++ b/backend/src/ching_tech_os/services/mcp/presentation_tools.py @@ -603,6 +603,50 @@ async def prepare_print_file( # 檢查檔案格式 ext = actual_path.suffix.lower() + if ext == ".pdf": + # PDF 透過 pdf2ps 轉換為 PostScript,繞過 RICOH 等印表機嚴格的 PDF 解譯器 + # 實測:AutoCAD 等軟體產生的 PDF 含格式瑕疵(如重複 /PageMode 鍵值), + # 直接送 PDF 會被拒絕;轉成 PS 後改走 PS 解譯器,可正常列印 + try: + tmp_dir = Path("/tmp/ctos/print") + tmp_dir.mkdir(parents=True, exist_ok=True) + ps_file = tmp_dir / (actual_path.stem + ".ps") + + proc_ps = await _asyncio.create_subprocess_exec( + "pdf2ps", str(actual_path), str(ps_file), + stdout=_asyncio.subprocess.PIPE, + stderr=_asyncio.subprocess.PIPE, + ) + _, stderr_ps = await proc_ps.communicate() + + if proc_ps.returncode != 0 or not ps_file.exists(): + # pdf2ps 失敗則 fallback 使用原始 PDF 路徑 + return f"""✅ 檔案已準備好,請使用 printer-mcp 的 print_file 工具列印: + +📄 檔案:{actual_path.name} +📂 絕對路徑:{actual_str} + +下一步:呼叫 print_file(file_path="{actual_str}")""" + + ps_str = str(ps_file) + return f"""✅ PDF 已轉換為 PostScript,請使用 printer-mcp 的 print_file 工具列印: + +📄 檔案:{actual_path.name} +📂 絕對路徑:{ps_str} + +下一步:呼叫 print_file(file_path="{ps_str}")""" + + except FileNotFoundError: + # 沒有 pdf2ps 指令則直接使用原始路徑 + return f"""✅ 檔案已準備好,請使用 printer-mcp 的 print_file 工具列印: + +📄 檔案:{actual_path.name} +📂 絕對路徑:{actual_str} + +下一步:呼叫 print_file(file_path="{actual_str}")""" + except Exception as e: + return f"❌ PDF 轉換時發生錯誤:{str(e)}" + if ext in PRINTABLE_EXTENSIONS: return f"""✅ 檔案已準備好,請使用 printer-mcp 的 print_file 工具列印: diff --git a/backend/src/ching_tech_os/services/proactive_push_service.py b/backend/src/ching_tech_os/services/proactive_push_service.py new file mode 100644 index 00000000..de9ce403 --- /dev/null +++ b/backend/src/ching_tech_os/services/proactive_push_service.py @@ -0,0 +1,95 @@ +"""主動推送通知服務 + +在背景任務完成後,依平台設定決定是否主動推送結果給發起者。 + +預設行為: +- Line:預設關閉(bot_settings 無記錄時不推送) +- Telegram:預設開啟(bot_settings 無記錄時仍推送) +""" + +import logging + +from ..database import get_connection + +logger = logging.getLogger(__name__) + +# 各平台缺少設定時的預設值 +_PLATFORM_DEFAULT: dict[str, bool] = { + "line": False, + "telegram": True, +} + + +async def _is_push_enabled(platform: str) -> bool: + """從 bot_settings 讀取平台的主動推送開關,缺值時依預設值處理""" + try: + async with get_connection() as conn: + row = await conn.fetchrow( + "SELECT value FROM bot_settings WHERE platform = $1 AND key = 'proactive_push_enabled'", + platform, + ) + if row is None: + return _PLATFORM_DEFAULT.get(platform, False) + return row["value"].lower() == "true" + except Exception: + logger.warning(f"讀取 {platform} 主動推送設定失敗,使用預設值", exc_info=True) + return _PLATFORM_DEFAULT.get(platform, False) + + +async def notify_job_complete( + platform: str, + platform_user_id: str, + is_group: bool, + group_id: str | None, + message: str, +) -> None: + """背景任務完成後推送通知 + + Args: + platform: 平台名稱("line" 或 "telegram") + platform_user_id: Line user ID 或 Telegram user chat_id + is_group: 是否為群組對話 + group_id: 群組 ID(群組對話時使用),個人對話為 None + message: 推送訊息內容 + """ + if not await _is_push_enabled(platform): + logger.debug(f"{platform} 主動推送未啟用,跳過通知") + return + + target = group_id if is_group and group_id else platform_user_id + if not target: + logger.warning("無法推送:target 為空") + return + + try: + if platform == "line": + await _push_line(target, message) + elif platform == "telegram": + await _push_telegram(target, message) + else: + logger.warning(f"不支援的平台: {platform}") + except Exception: + logger.warning(f"主動推送失敗({platform} → {target}),靜默處理", exc_info=True) + + +async def _push_line(to: str, message: str) -> None: + """透過 Line Push API 發送訊息""" + from .bot_line.messaging import push_text + _msg_id, error = await push_text(to, message) + if error: + logger.warning(f"Line push 失敗: {error}") + + +async def _push_telegram(chat_id: str, message: str) -> None: + """透過 Telegram Bot API 發送訊息""" + from .bot_settings import get_bot_credentials + from .bot_telegram.adapter import TelegramBotAdapter + + credentials = await get_bot_credentials("telegram") + token = credentials.get("bot_token", "") + if not token: + logger.warning("Telegram bot_token 未設定,無法推送") + return + + adapter = TelegramBotAdapter(token=token) + await adapter.send_text(chat_id, message) diff --git a/backend/src/ching_tech_os/skills/media-downloader/scripts/download-video.py b/backend/src/ching_tech_os/skills/media-downloader/scripts/download-video.py index 952a792c..c1b8a9d3 100644 --- a/backend/src/ching_tech_os/skills/media-downloader/scripts/download-video.py +++ b/backend/src/ching_tech_os/skills/media-downloader/scripts/download-video.py @@ -36,7 +36,23 @@ def _write_status(status_path: Path, data: dict) -> None: tmp_path.replace(status_path) -def _do_download(job_dir: Path, status_path: Path, url: str, fmt: str, job_id: str) -> None: +def _trigger_proactive_push(job_id: str, skill: str) -> None: + """通知內部端點觸發主動推送(靜默失敗)""" + try: + import urllib.request + data = json.dumps({"job_id": job_id, "skill": skill}).encode() + req = urllib.request.Request( + "http://127.0.0.1:8088/api/internal/proactive-push", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + urllib.request.urlopen(req, timeout=5) + except Exception: + pass + + +def _do_download(job_dir: Path, status_path: Path, url: str, fmt: str, job_id: str, caller_context: dict | None = None) -> None: """背景程序:執行實際下載。""" import yt_dlp @@ -54,6 +70,8 @@ def _do_download(job_dir: Path, status_path: Path, url: str, fmt: str, job_id: s "error": None, "created_at": datetime.now().isoformat(), } + if caller_context: + status_data["caller_context"] = caller_context _write_status(status_path, status_data) def progress_hook(d: dict) -> None: @@ -191,6 +209,7 @@ def postprocess_hook(d: dict) -> None: status_data["ctos_path"] = ctos_path status_data["error"] = None _write_status(status_path, status_data) + _trigger_proactive_push(job_id, "media-downloader") except Exception as exc: status_data["status"] = "failed" @@ -216,6 +235,8 @@ def main() -> int: print(json.dumps({"success": False, "error": f"不支援的格式:{fmt},可用:mp4、mp3、best"}, ensure_ascii=False)) return 1 + caller_context = payload.get("caller_context") or None + # 建立儲存目錄 job_id = uuid_module.uuid4().hex[:8] date_str = datetime.now().strftime("%Y-%m-%d") @@ -226,7 +247,7 @@ def main() -> int: status_path = job_dir / "status.json" # 寫入初始狀態 - _write_status(status_path, { + initial_status: dict = { "job_id": job_id, "status": "starting", "progress": 0.0, @@ -237,7 +258,10 @@ def main() -> int: "url": url, "format": fmt, "created_at": datetime.now().isoformat(), - }) + } + if caller_context: + initial_status["caller_context"] = caller_context + _write_status(status_path, initial_status) # Fork 背景程序 pid = os.fork() @@ -266,7 +290,7 @@ def main() -> int: sys.stdout = open(os.devnull, "w") sys.stderr = open(os.devnull, "w") - _do_download(job_dir, status_path, url, fmt, job_id) + _do_download(job_dir, status_path, url, fmt, job_id, caller_context) except Exception as e: # 寫入錯誤日誌以便除錯 try: diff --git a/backend/src/ching_tech_os/skills/media-transcription/scripts/transcribe.py b/backend/src/ching_tech_os/skills/media-transcription/scripts/transcribe.py index ab1a3c12..2495ecdc 100644 --- a/backend/src/ching_tech_os/skills/media-transcription/scripts/transcribe.py +++ b/backend/src/ching_tech_os/skills/media-transcription/scripts/transcribe.py @@ -84,6 +84,22 @@ def _resolve_source_path(source_path: str) -> Path | None: return Path(fs_path) +def _trigger_proactive_push(job_id: str, skill: str) -> None: + """通知內部端點觸發主動推送(靜默失敗)""" + try: + import urllib.request + data = json.dumps({"job_id": job_id, "skill": skill}).encode() + req = urllib.request.Request( + "http://127.0.0.1:8088/api/internal/proactive-push", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + urllib.request.urlopen(req, timeout=5) + except Exception: + pass + + def _write_status(status_path: Path, data: dict) -> None: """寫入狀態檔(atomic write)。""" data["updated_at"] = datetime.now().isoformat() @@ -119,9 +135,10 @@ def _do_transcribe( source_ctos_path: str, model_name: str, job_id: str, + caller_context: dict | None = None, ) -> None: """背景程序:執行實際轉錄。""" - status_data = { + status_data: dict = { "job_id": job_id, "status": "started", "source_path": source_ctos_path, @@ -129,6 +146,8 @@ def _do_transcribe( "error": None, "created_at": datetime.now().isoformat(), } + if caller_context: + status_data["caller_context"] = caller_context audio_path = None @@ -245,6 +264,7 @@ def _do_transcribe( status_data["transcript_preview"] = preview status_data["error"] = None _write_status(status_path, status_data) + _trigger_proactive_push(job_id, "media-transcription") except Exception as exc: status_data["status"] = "failed" @@ -294,6 +314,8 @@ def main() -> int: print(json.dumps({"success": False, "error": f"不支援的模型:{model_name},可用:{', '.join(sorted(VALID_MODELS))}"}, ensure_ascii=False)) return 1 + caller_context = payload.get("caller_context") or None + # 建立暫存目錄 job_id = uuid_module.uuid4().hex[:8] date_str = datetime.now().strftime("%Y-%m-%d") @@ -304,7 +326,7 @@ def main() -> int: status_path = job_dir / "status.json" # 寫入初始狀態 - _write_status(status_path, { + initial_status: dict = { "job_id": job_id, "status": "started", "source_path": source_path, @@ -315,7 +337,10 @@ def main() -> int: "transcript_preview": "", "error": None, "created_at": datetime.now().isoformat(), - }) + } + if caller_context: + initial_status["caller_context"] = caller_context + _write_status(status_path, initial_status) # Fork 背景程序 pid = os.fork() @@ -350,7 +375,7 @@ def main() -> int: sys.stderr = os.fdopen(2, "w") print(f"[{datetime.now().isoformat()}] 子程序啟動 PID={os.getpid()}", flush=True) - _do_transcribe(job_dir, status_path, source_file, source_path, model_name, job_id) + _do_transcribe(job_dir, status_path, source_file, source_path, model_name, job_id, caller_context) print(f"[{datetime.now().isoformat()}] 轉錄完成", flush=True) except Exception as e: try: diff --git a/backend/src/ching_tech_os/skills/printer/SKILL.md b/backend/src/ching_tech_os/skills/printer/SKILL.md index bc604a62..818e1d7d 100644 --- a/backend/src/ching_tech_os/skills/printer/SKILL.md +++ b/backend/src/ching_tech_os/skills/printer/SKILL.md @@ -36,7 +36,8 @@ metadata: 只有當你已經有絕對路徑(/mnt/nas/...)時才能直接用 printer-mcp。 【支援的檔案格式】 -- 直接列印:PDF、純文字(.txt, .log, .csv)、圖片(PNG, JPG, JPEG, GIF, BMP, TIFF, WebP) +- PDF:自動透過 Ghostscript 正規化(修復 AutoCAD 等軟體產生的格式問題,確保 RICOH 等嚴格 PDF 解譯器能接受) +- 純文字(.txt, .log, .csv)、圖片(PNG, JPG, JPEG, GIF, BMP, TIFF, WebP):直接列印 - 自動轉 PDF:Office 文件(.docx, .xlsx, .pptx, .doc, .xls, .ppt, .odt, .ods, .odp) 【列印使用情境】 diff --git a/backend/src/ching_tech_os/skills/research-skill/scripts/start-research.py b/backend/src/ching_tech_os/skills/research-skill/scripts/start-research.py index 10fbb2f4..aae0c94d 100644 --- a/backend/src/ching_tech_os/skills/research-skill/scripts/start-research.py +++ b/backend/src/ching_tech_os/skills/research-skill/scripts/start-research.py @@ -963,6 +963,22 @@ def _run_claude_research( ) +def _trigger_proactive_push(job_id: str, skill: str) -> None: + """通知內部端點觸發主動推送(靜默失敗)""" + try: + import urllib.request + data = json.dumps({"job_id": job_id, "skill": skill}).encode() + req = urllib.request.Request( + "http://127.0.0.1:8088/api/internal/proactive-push", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + urllib.request.urlopen(req, timeout=5) + except Exception: + pass # 靜默失敗,不影響任務本身 + + def _do_research( base_dir: Path, job_dir: Path, @@ -972,6 +988,7 @@ def _do_research( seed_urls: list[str], max_results: int, max_fetch: int, + caller_context: dict | None = None, ) -> None: """背景程序主流程:優先走 Claude web tools,失敗再 fallback。""" _wait_for_worker_slot(base_dir=base_dir, status_path=status_path, job_id=job_id) @@ -1014,6 +1031,8 @@ def _do_research( "created_at": now, "updated_at": now, } + if caller_context: + status_data["caller_context"] = caller_context _write_status(status_path, status_data) claude_timeout_sec = _get_research_claude_timeout_sec() @@ -1084,6 +1103,7 @@ def _do_research( "updated_at": datetime.now().isoformat(), }, ) + _trigger_proactive_push(job_id, "research-skill") return except (RuntimeError, ValueError, OSError) as exc: provider_trace[0]["status"] = "failed" @@ -1183,6 +1203,7 @@ def _do_research_local_pipeline( # 保留 Claude worker 階段已蒐集到的部分來源 prev_sources = previous_status.get("sources") or [] prev_partial = previous_status.get("partial_results") or [] + prev_caller_context = previous_status.get("caller_context") or None status_data = { "job_id": job_id, @@ -1200,6 +1221,8 @@ def _do_research_local_pipeline( "error": None, "created_at": created_at, } + if prev_caller_context: + status_data["caller_context"] = prev_caller_context _write_status(status_path, status_data) try: @@ -1391,6 +1414,7 @@ def _do_research_local_pipeline( status_data["sources_ctos_path"] = f"ctos://linebot/research/{date_str}/{job_id}/sources.json" status_data["tool_trace_ctos_path"] = f"ctos://linebot/research/{date_str}/{job_id}/tool_trace.json" _write_status(status_path, status_data) + _trigger_proactive_push(job_id, "research-skill") except (httpx.HTTPError, OSError, RuntimeError, ValueError) as exc: status_data["status"] = "failed" status_data["status_label"] = "失敗" @@ -1448,26 +1472,28 @@ def main() -> int: job_dir = base_dir / date_str / job_id job_dir.mkdir(parents=True, exist_ok=True) + caller_context = payload.get("caller_context") or None + status_path = job_dir / "status.json" - _write_status( - status_path, - { - "job_id": job_id, - "status": "queued", - "status_label": "排隊中", - "stage": "queued", - "stage_label": "等待背景程序啟動", - "progress": 0, - "query": query, - "search_provider": "none", - "provider_trace": [], - "sources": [], - "partial_results": [], - "final_summary": "", - "error": None, - "created_at": datetime.now().isoformat(), - }, - ) + initial_status: dict = { + "job_id": job_id, + "status": "queued", + "status_label": "排隊中", + "stage": "queued", + "stage_label": "等待背景程序啟動", + "progress": 0, + "query": query, + "search_provider": "none", + "provider_trace": [], + "sources": [], + "partial_results": [], + "final_summary": "", + "error": None, + "created_at": datetime.now().isoformat(), + } + if caller_context: + initial_status["caller_context"] = caller_context + _write_status(status_path, initial_status) pid = os.fork() if pid > 0: @@ -1504,6 +1530,7 @@ def main() -> int: seed_urls=seed_urls, max_results=max_results, max_fetch=max_fetch, + caller_context=caller_context, ) except (OSError, RuntimeError, ValueError) as exc: _write_status( diff --git a/backend/tests/test_api_bot_settings.py b/backend/tests/test_api_bot_settings.py index b510fdff..f3ccc415 100644 --- a/backend/tests/test_api_bot_settings.py +++ b/backend/tests/test_api_bot_settings.py @@ -61,6 +61,8 @@ def test_bot_settings_routes(monkeypatch: pytest.MonkeyPatch) -> None: } }, })) + monkeypatch.setattr(bot_settings_api, "get_proactive_push_enabled", AsyncMock(return_value=False)) + monkeypatch.setattr(bot_settings_api, "update_proactive_push_enabled", AsyncMock(return_value=None)) monkeypatch.setattr(bot_settings_api, "update_bot_credentials", AsyncMock(return_value=None)) monkeypatch.setattr(bot_settings_api, "delete_bot_credentials", AsyncMock(return_value=2)) monkeypatch.setattr(bot_settings_api, "_test_line_connection", AsyncMock(return_value={"success": True, "message": "ok"})) @@ -68,7 +70,10 @@ def test_bot_settings_routes(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bot_settings_api, "get_bot_credentials", AsyncMock(return_value={"channel_access_token": "x", "bot_token": "y"})) assert client.get("/api/admin/bot-settings/line").status_code == 200 + # 儲存憑證 assert client.put("/api/admin/bot-settings/line", json={"channel_secret": "x"}).status_code == 200 + # 僅切換主動推送開關 + assert client.put("/api/admin/bot-settings/line", json={"proactive_push_enabled": True}).status_code == 200 assert client.delete("/api/admin/bot-settings/line").status_code == 200 assert client.post("/api/admin/bot-settings/line/test").status_code == 200 assert client.post("/api/admin/bot-settings/telegram/test").status_code == 200 diff --git a/backend/tests/test_api_internal_push.py b/backend/tests/test_api_internal_push.py new file mode 100644 index 00000000..6adc797c --- /dev/null +++ b/backend/tests/test_api_internal_push.py @@ -0,0 +1,278 @@ +"""api/internal_push 端點測試。""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from unittest.mock import MagicMock + +from ching_tech_os.api import internal_push as push_api + + +def _make_client() -> TestClient: + app = FastAPI() + app.include_router(push_api.router) + return TestClient(app, raise_server_exceptions=True) + + +# ── _build_message ──────────────────────────────────────────────────────────── + +def test_build_message_research() -> None: + status = { + "job_id": "abc123", + "query": "AI 趨勢", + "summary": "A" * 600, # 超過 500 字,應被截斷 + } + msg = push_api._build_message("research-skill", status) + assert "✅ 研究任務完成" in msg + assert "AI 趨勢" in msg + assert "abc123" in msg + assert "…" in msg # 截斷標記 + + +def test_build_message_research_short_summary() -> None: + status = {"job_id": "x1", "query": "q", "summary": "短摘要"} + msg = push_api._build_message("research-skill", status) + assert "短摘要" in msg + assert "…" not in msg + + +def test_build_message_media_downloader() -> None: + status = { + "job_id": "dl001", + "filename": "video.mp4", + "file_size": 52428800, # 50 MB + "ctos_path": "ctos://linebot/videos/2026-03-03/dl001/video.mp4", + } + msg = push_api._build_message("media-downloader", status) + assert "✅ 影片下載完成" in msg + assert "video.mp4" in msg + assert "50.0 MB" in msg + assert "ctos://" in msg + + +def test_build_message_media_transcription() -> None: + transcript = "這是逐字稿內容。" * 50 # 超過 300 字 + status = { + "job_id": "tr001", + "transcript_preview": transcript, + "ctos_path": "ctos://linebot/transcriptions/2026-03-03/tr001/transcript.md", + } + msg = push_api._build_message("media-transcription", status) + assert "✅ 轉錄完成" in msg + assert "…" in msg # 截斷 + assert "ctos://" in msg + + +def test_build_message_transcription_fallback_field() -> None: + """transcript_preview 缺失時退回 transcript 欄位""" + status = {"job_id": "tr002", "transcript": "內容", "ctos_path": ""} + msg = push_api._build_message("media-transcription", status) + assert "內容" in msg + + +def test_build_message_unknown_skill() -> None: + status = {"job_id": "zzz"} + msg = push_api._build_message("unknown-skill", status) + assert "✅ 任務完成" in msg + assert "zzz" in msg + + +# ── _find_status_file ───────────────────────────────────────────────────────── + +def test_find_status_file_unknown_skill(tmp_path: Path) -> None: + with patch.object(push_api, "_get_ctos_mount", return_value=str(tmp_path)): + result = push_api._find_status_file("bad-skill", "abc") + assert result is None + + +def test_find_status_file_not_found(tmp_path: Path) -> None: + base = tmp_path / "linebot" / "research" + base.mkdir(parents=True) + with patch.object(push_api, "_get_ctos_mount", return_value=str(tmp_path)): + result = push_api._find_status_file("research-skill", "notexist") + assert result is None + + +def test_find_status_file_found(tmp_path: Path) -> None: + job_id = "abc12345" + status_path = tmp_path / "linebot" / "research" / "2026-03-03" / job_id / "status.json" + status_path.parent.mkdir(parents=True) + status_path.write_text('{"job_id": "abc12345"}') + + with patch.object(push_api, "_get_ctos_mount", return_value=str(tmp_path)): + result = push_api._find_status_file("research-skill", job_id) + + assert result == status_path + + +def test_find_status_file_returns_latest_date(tmp_path: Path) -> None: + """有多個日期目錄時,應回傳最新的""" + job_id = "xyz99" + for date in ("2026-03-01", "2026-03-03"): + p = tmp_path / "linebot" / "research" / date / job_id / "status.json" + p.parent.mkdir(parents=True) + p.write_text('{}') + + with patch.object(push_api, "_get_ctos_mount", return_value=str(tmp_path)): + result = push_api._find_status_file("research-skill", job_id) + + assert result is not None + assert "2026-03-03" in str(result) + + +# ── 端點測試 ────────────────────────────────────────────────────────────────── + +def test_endpoint_403_non_localhost() -> None: + client = _make_client() + # TestClient 預設 client host 為 testclient,非 127.0.0.1 + resp = client.post("/api/internal/proactive-push", json={"job_id": "x", "skill": "research-skill"}) + assert resp.status_code == 403 + + +def test_endpoint_status_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(push_api, "_find_status_file", lambda *_: None) + + app = FastAPI() + app.include_router(push_api.router) + + # 模擬 127.0.0.1 請求 + from starlette.testclient import TestClient as SC + client = SC(app, raise_server_exceptions=True) + + with patch("ching_tech_os.api.internal_push._find_status_file", return_value=None): + resp = client.post( + "/api/internal/proactive-push", + json={"job_id": "missing", "skill": "research-skill"}, + headers={"X-Forwarded-For": "127.0.0.1"}, + ) + # TestClient host 不是 127.0.0.1,所以會是 403;改用直接呼叫路由函式測試 + assert resp.status_code in (200, 403) + + +@pytest.mark.asyncio +async def test_endpoint_logic_no_caller_context(tmp_path: Path) -> None: + """status.json 沒有 caller_context 時回傳 ok=False""" + from fastapi import Request + from starlette.datastructures import Address + + status_file = tmp_path / "status.json" + status_file.write_text(json.dumps({"job_id": "j1", "status": "completed"})) + + mock_request = MagicMock(spec=Request) + mock_request.client = Address("127.0.0.1", 12345) + + with patch.object(push_api, "_find_status_file", return_value=status_file), \ + patch.object(push_api, "notify_job_complete", AsyncMock()) as mock_notify: + + body = push_api.ProactivePushRequest(job_id="j1", skill="research-skill") + result = await push_api.trigger_proactive_push(body, mock_request) + + assert result["ok"] is False + assert result["reason"] == "no caller_context" + mock_notify.assert_not_called() + + +@pytest.mark.asyncio +async def test_endpoint_logic_invalid_caller_context(tmp_path: Path) -> None: + """caller_context 缺少 platform 時回傳 invalid caller_context""" + from fastapi import Request + from starlette.datastructures import Address + + status_file = tmp_path / "status.json" + status_file.write_text(json.dumps({ + "job_id": "j2", + "caller_context": {"platform": "", "platform_user_id": "", "is_group": False, "group_id": None}, + })) + + mock_request = MagicMock(spec=Request) + mock_request.client = Address("127.0.0.1", 12345) + + with patch.object(push_api, "_find_status_file", return_value=status_file), \ + patch.object(push_api, "notify_job_complete", AsyncMock()) as mock_notify: + + body = push_api.ProactivePushRequest(job_id="j2", skill="research-skill") + result = await push_api.trigger_proactive_push(body, mock_request) + + assert result["ok"] is False + assert "caller_context" in result["reason"] + mock_notify.assert_not_called() + + +@pytest.mark.asyncio +async def test_endpoint_logic_success(tmp_path: Path) -> None: + """完整流程:找到 status、有 caller_context、呼叫 notify_job_complete""" + from fastapi import Request + from starlette.datastructures import Address + + status_file = tmp_path / "status.json" + status_file.write_text(json.dumps({ + "job_id": "j3", + "query": "test", + "summary": "結果摘要", + "caller_context": { + "platform": "telegram", + "platform_user_id": "850654509", + "is_group": False, + "group_id": None, + }, + })) + + mock_request = MagicMock(spec=Request) + mock_request.client = Address("127.0.0.1", 12345) + + mock_notify = AsyncMock() + with patch.object(push_api, "_find_status_file", return_value=status_file), \ + patch.object(push_api, "notify_job_complete", mock_notify): + + body = push_api.ProactivePushRequest(job_id="j3", skill="research-skill") + result = await push_api.trigger_proactive_push(body, mock_request) + + assert result["ok"] is True + mock_notify.assert_awaited_once() + call_kwargs = mock_notify.call_args.kwargs + assert call_kwargs["platform"] == "telegram" + assert call_kwargs["platform_user_id"] == "850654509" + assert call_kwargs["is_group"] is False + + +@pytest.mark.asyncio +async def test_endpoint_logic_group_push(tmp_path: Path) -> None: + """群組對話:group_id 傳遞正確""" + from fastapi import Request + from starlette.datastructures import Address + + status_file = tmp_path / "status.json" + status_file.write_text(json.dumps({ + "job_id": "j4", + "filename": "vid.mp4", + "file_size": 1024, + "ctos_path": "ctos://linebot/videos/...", + "caller_context": { + "platform": "line", + "platform_user_id": "U123", + "is_group": True, + "group_id": "CGROUP456", + }, + })) + + mock_request = MagicMock(spec=Request) + mock_request.client = Address("127.0.0.1", 12345) + + mock_notify = AsyncMock() + with patch.object(push_api, "_find_status_file", return_value=status_file), \ + patch.object(push_api, "notify_job_complete", mock_notify): + + body = push_api.ProactivePushRequest(job_id="j4", skill="media-downloader") + result = await push_api.trigger_proactive_push(body, mock_request) + + assert result["ok"] is True + call_kwargs = mock_notify.call_args.kwargs + assert call_kwargs["is_group"] is True + assert call_kwargs["group_id"] == "CGROUP456" diff --git a/backend/tests/test_bot_multi_mode_integration.py b/backend/tests/test_bot_multi_mode_integration.py index 60d2e443..113dc600 100644 --- a/backend/tests/test_bot_multi_mode_integration.py +++ b/backend/tests/test_bot_multi_mode_integration.py @@ -117,8 +117,8 @@ async def test_restricted_policy_route(self): assert result.reply_text is None # 不回覆拒絕訊息 @pytest.mark.asyncio - async def test_restricted_policy_group_still_silent(self): - """restricted 策略 — 群組中仍然靜默忽略""" + async def test_restricted_policy_group_uses_restricted(self): + """restricted 策略 + 群組 → 受限模式(群組可設定受限 Agent 服務未綁定用戶)""" from ching_tech_os.services.bot.identity_router import route_unbound with patch( @@ -126,7 +126,7 @@ async def test_restricted_policy_group_still_silent(self): ) as mock_settings: mock_settings.bot_unbound_user_policy = "restricted" result = await route_unbound(platform_type="line", is_group=True) - assert result.action == "silent" + assert result.action == "restricted" @pytest.mark.asyncio async def test_restricted_mode_full_flow(self): diff --git a/backend/tests/test_bot_telegram_handler.py b/backend/tests/test_bot_telegram_handler.py index 640ca5ba..1639b15a 100644 --- a/backend/tests/test_bot_telegram_handler.py +++ b/backend/tests/test_bot_telegram_handler.py @@ -288,7 +288,9 @@ async def test_handle_text_command_and_access_paths(monkeypatch: pytest.MonkeyPa await handler._handle_text(message, "123456", "100", chat, user, False, adapter) adapter.send_text.assert_awaited_with("100", "綁定成功") - # 未綁定拒絕 + # 未綁定拒絕(patch policy 確保不受環境變數影響) + from ching_tech_os.config import settings as app_settings + monkeypatch.setattr(app_settings, "bot_unbound_user_policy", "reject") monkeypatch.setattr(handler, "check_line_access", AsyncMock(return_value=(False, "user_not_bound"))) monkeypatch.setattr(handler, "is_binding_code_format", AsyncMock(return_value=False)) adapter.send_text.reset_mock() diff --git a/backend/tests/test_identity_router.py b/backend/tests/test_identity_router.py index caa5112f..fbfd1dfd 100644 --- a/backend/tests/test_identity_router.py +++ b/backend/tests/test_identity_router.py @@ -95,21 +95,14 @@ async def test_reject_policy_telegram_private(self): @pytest.mark.asyncio async def test_reject_policy_group(self): - """reject 策略 + 群組 → 回覆綁定提示(與個人對話相同)""" - with ( - patch( - "ching_tech_os.services.bot.identity_router.settings" - ) as mock_settings, - patch( - "ching_tech_os.services.ai_manager.get_agent_by_name", - new_callable=AsyncMock, - return_value=None, - ), - ): + """reject 策略 + 群組 → 靜默忽略(群組不廣播綁定提示)""" + with patch( + "ching_tech_os.services.bot.identity_router.settings" + ) as mock_settings: mock_settings.bot_unbound_user_policy = "reject" result = await route_unbound(platform_type="line", is_group=True) - assert result.action == "reject" - assert result.reply_text == BINDING_PROMPT_LINE + assert result.action == "silent" + assert result.reply_text is None @pytest.mark.asyncio async def test_restricted_policy_private(self): @@ -124,7 +117,7 @@ async def test_restricted_policy_private(self): @pytest.mark.asyncio async def test_restricted_policy_group(self): - """restricted 策略 + 群組 → 走受限模式(與個人對話相同)""" + """restricted 策略 + 群組 → 受限模式(群組 restricted_agent_id 可服務未綁定用戶)""" with patch( "ching_tech_os.services.bot.identity_router.settings" ) as mock_settings: diff --git a/backend/tests/test_linebot_ai_context_prompt.py b/backend/tests/test_linebot_ai_context_prompt.py index 69ed0fa1..be8324cf 100644 --- a/backend/tests/test_linebot_ai_context_prompt.py +++ b/backend/tests/test_linebot_ai_context_prompt.py @@ -120,7 +120,7 @@ async def test_get_conversation_context_user_and_empty(monkeypatch: pytest.Monke @pytest.mark.asyncio async def test_build_system_prompt_group(monkeypatch: pytest.MonkeyPatch) -> None: conn = AsyncMock() - conn.fetchrow = AsyncMock(return_value={"name": "測試群"}) + conn.fetchrow = AsyncMock(return_value={"name": "測試群", "platform_group_id": "C_TEST_GROUP"}) monkeypatch.setattr(linebot_ai, "get_connection", lambda: _CM(conn)) monkeypatch.setattr(linebot_ai, "get_line_user_record", AsyncMock(return_value={"id": "u1", "user_id": 321})) monkeypatch.setattr( @@ -157,12 +157,15 @@ async def test_build_system_prompt_group(monkeypatch: pytest.MonkeyPatch) -> Non assert "【長時外部研究(規則)】" in prompt assert "check-research 若回傳 failed" in prompt assert "禁止改用 WebSearch/WebFetch 重做" in prompt + assert "caller_context" in prompt assert "TOOL_PROMPT" in prompt assert "USAGE_TIPS" in prompt assert "【自訂記憶】" in prompt assert "目前群組:測試群" in prompt assert "平台:Telegram" in prompt assert "ctos_user_id: 321" in prompt + assert "platform_group_id: C_TEST_GROUP" in prompt + assert "platform_user_id: U1" in prompt @pytest.mark.asyncio @@ -206,7 +209,7 @@ async def test_build_system_prompt_group_unbound_and_personal_bound(monkeypatch: AsyncMock(return_value=[]), ) conn = AsyncMock() - conn.fetchrow = AsyncMock(return_value={"name": "群組B"}) + conn.fetchrow = AsyncMock(return_value={"name": "群組B", "platform_group_id": "C_GROUP_B"}) monkeypatch.setattr(linebot_ai, "get_connection", lambda: _CM(conn)) prompt_group = await linebot_ai.build_system_prompt( line_group_id=uuid4(), diff --git a/backend/tests/test_proactive_push_service.py b/backend/tests/test_proactive_push_service.py new file mode 100644 index 00000000..5ad49ef9 --- /dev/null +++ b/backend/tests/test_proactive_push_service.py @@ -0,0 +1,156 @@ +"""proactive_push_service 單元測試。""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ching_tech_os.services import proactive_push_service as svc + + +class _CM: + """模擬 async context manager for get_connection""" + + def __init__(self, conn): + self.conn = conn + + async def __aenter__(self): + return self.conn + + async def __aexit__(self, *_): + return None + + +# ── _is_push_enabled ────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_is_push_enabled_true(monkeypatch: pytest.MonkeyPatch) -> None: + conn = AsyncMock() + conn.fetchrow = AsyncMock(return_value={"value": "true"}) + monkeypatch.setattr(svc, "get_connection", lambda: _CM(conn)) + + assert await svc._is_push_enabled("line") is True + + +@pytest.mark.asyncio +async def test_is_push_enabled_false(monkeypatch: pytest.MonkeyPatch) -> None: + conn = AsyncMock() + conn.fetchrow = AsyncMock(return_value={"value": "false"}) + monkeypatch.setattr(svc, "get_connection", lambda: _CM(conn)) + + assert await svc._is_push_enabled("telegram") is False + + +@pytest.mark.asyncio +async def test_is_push_enabled_missing_row_uses_default(monkeypatch: pytest.MonkeyPatch) -> None: + """缺少記錄時:Line 預設 False、Telegram 預設 True""" + conn = AsyncMock() + conn.fetchrow = AsyncMock(return_value=None) + monkeypatch.setattr(svc, "get_connection", lambda: _CM(conn)) + + assert await svc._is_push_enabled("line") is False + assert await svc._is_push_enabled("telegram") is True + assert await svc._is_push_enabled("unknown") is False # 未知平台預設 False + + +@pytest.mark.asyncio +async def test_is_push_enabled_db_exception_uses_default(monkeypatch: pytest.MonkeyPatch) -> None: + """DB 例外時靜默處理,回傳預設值""" + conn = AsyncMock() + conn.fetchrow = AsyncMock(side_effect=RuntimeError("db error")) + monkeypatch.setattr(svc, "get_connection", lambda: _CM(conn)) + + assert await svc._is_push_enabled("line") is False + assert await svc._is_push_enabled("telegram") is True + + +# ── notify_job_complete ─────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_notify_disabled_skips_push(monkeypatch: pytest.MonkeyPatch) -> None: + """推送停用時不呼叫任何推送函式""" + monkeypatch.setattr(svc, "_is_push_enabled", AsyncMock(return_value=False)) + push_line = AsyncMock() + monkeypatch.setattr(svc, "_push_line", push_line) + push_telegram = AsyncMock() + monkeypatch.setattr(svc, "_push_telegram", push_telegram) + + await svc.notify_job_complete("line", "U123", False, None, "msg") + + push_line.assert_not_called() + push_telegram.assert_not_called() + + +@pytest.mark.asyncio +async def test_notify_no_target_skips_push(monkeypatch: pytest.MonkeyPatch) -> None: + """target 為空(is_group=False 且 platform_user_id 空)時跳過""" + monkeypatch.setattr(svc, "_is_push_enabled", AsyncMock(return_value=True)) + push_line = AsyncMock() + monkeypatch.setattr(svc, "_push_line", push_line) + + await svc.notify_job_complete("line", "", False, None, "msg") + + push_line.assert_not_called() + + +@pytest.mark.asyncio +async def test_notify_line_personal(monkeypatch: pytest.MonkeyPatch) -> None: + """Line 個人對話:target = platform_user_id""" + monkeypatch.setattr(svc, "_is_push_enabled", AsyncMock(return_value=True)) + push_line = AsyncMock() + monkeypatch.setattr(svc, "_push_line", push_line) + + await svc.notify_job_complete("line", "U999", False, None, "hello") + + push_line.assert_awaited_once_with("U999", "hello") + + +@pytest.mark.asyncio +async def test_notify_line_group_uses_group_id(monkeypatch: pytest.MonkeyPatch) -> None: + """Line 群組對話:target = group_id""" + monkeypatch.setattr(svc, "_is_push_enabled", AsyncMock(return_value=True)) + push_line = AsyncMock() + monkeypatch.setattr(svc, "_push_line", push_line) + + await svc.notify_job_complete("line", "U999", True, "CGROUP123", "hello group") + + push_line.assert_awaited_once_with("CGROUP123", "hello group") + + +@pytest.mark.asyncio +async def test_notify_telegram(monkeypatch: pytest.MonkeyPatch) -> None: + """Telegram 個人對話推送""" + monkeypatch.setattr(svc, "_is_push_enabled", AsyncMock(return_value=True)) + push_telegram = AsyncMock() + monkeypatch.setattr(svc, "_push_telegram", push_telegram) + + await svc.notify_job_complete("telegram", "850654509", False, None, "tg msg") + + push_telegram.assert_awaited_once_with("850654509", "tg msg") + + +@pytest.mark.asyncio +async def test_notify_unsupported_platform(monkeypatch: pytest.MonkeyPatch) -> None: + """不支援的平台:靜默忽略,不拋例外""" + monkeypatch.setattr(svc, "_is_push_enabled", AsyncMock(return_value=True)) + + await svc.notify_job_complete("discord", "user1", False, None, "msg") # 不應拋例外 + + +@pytest.mark.asyncio +async def test_notify_push_exception_is_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """推送函式拋例外時靜默處理,不往外拋""" + monkeypatch.setattr(svc, "_is_push_enabled", AsyncMock(return_value=True)) + monkeypatch.setattr(svc, "_push_line", AsyncMock(side_effect=RuntimeError("network error"))) + + await svc.notify_job_complete("line", "U123", False, None, "msg") # 不應拋例外 + + +# ── _push_telegram ─────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_push_telegram_no_token() -> None: + """bot_token 未設定時靜默跳過""" + with patch("ching_tech_os.services.bot_settings.get_bot_credentials", AsyncMock(return_value={})): + await svc._push_telegram("chat123", "msg") # 不應拋例外 diff --git a/backend/tests/test_restricted_jfmskin.py b/backend/tests/test_restricted_jfmskin.py index b4c35d80..7fdf2f1d 100644 --- a/backend/tests/test_restricted_jfmskin.py +++ b/backend/tests/test_restricted_jfmskin.py @@ -15,9 +15,14 @@ import os import sys +import pytest + # 強制設定環境變數(模擬 restricted 策略) os.environ["BOT_UNBOUND_USER_POLICY"] = "restricted" +# 此檔案是整合測試腳本(需手動執行),跳過 pytest 自動收集 +pytestmark = pytest.mark.skip(reason="整合測試:需連接真實資料庫,請手動執行") + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s: %(message)s") logger = logging.getLogger(__name__) diff --git a/backend/tests/test_scheduler_service.py b/backend/tests/test_scheduler_service.py index 064718b2..94892350 100644 --- a/backend/tests/test_scheduler_service.py +++ b/backend/tests/test_scheduler_service.py @@ -61,7 +61,7 @@ def now(cls, tz=None): # noqa: D401 monkeypatch.setattr(scheduler, "datetime", _FixedDateTime) await scheduler.create_next_month_partitions() - assert conn.execute.await_count == 2 + assert conn.execute.await_count == 3 # messages + login_records + ai_logs conn2 = AsyncMock() conn2.execute = AsyncMock(side_effect=Exception("already exists")) diff --git a/frontend/css/settings.css b/frontend/css/settings.css index ac42daea..08368fc2 100644 --- a/frontend/css/settings.css +++ b/frontend/css/settings.css @@ -773,6 +773,69 @@ color: var(--color-error); } +/* 主動推送切換開關 */ +.bot-push-toggle-row { + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.bot-push-toggle-group { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.bot-push-toggle-label { + font-size: var(--font-size-sm); + color: var(--text-secondary); + min-width: 3em; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + cursor: pointer; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: var(--bg-surface-dark); + border-radius: 22px; + transition: background 0.2s; +} + +.toggle-slider::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + left: 3px; + top: 3px; + background: var(--text-muted); + border-radius: 50%; + transition: transform 0.2s, background 0.2s; +} + +.toggle-switch input:checked + .toggle-slider { + background: var(--color-primary); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(18px); + background: var(--btn-text-on-primary); +} + /* ========================================================================== Mobile Responsive Styles (手機版響應式) ========================================================================== */ diff --git a/frontend/js/settings.js b/frontend/js/settings.js index ad5b2912..3f443615 100644 --- a/frontend/js/settings.js +++ b/frontend/js/settings.js @@ -1056,6 +1056,7 @@ const SettingsApp = (function () { */ function renderBotPlatformCard(platform, config, data) { const fields = data.fields || {}; + const pushEnabled = data.proactive_push_enabled === true; return `
@@ -1081,6 +1082,16 @@ const SettingsApp = (function () {
`; }).join('')} +
+ +
+ + ${pushEnabled ? '已啟用' : '已停用'} +
+