-
Notifications
You must be signed in to change notification settings - Fork 8
feat: Bot 主動推送通知功能(Proactive Push) #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
8be4f9d
feat: add bot-proactive-push openspec change
yazelin 00f9608
feat: 實作 bot 主動推送通知功能完整整合
yazelin e6ddbf9
fix: PDF 列印改用 pdf2ps 轉 PostScript,繞過 RICOH PDF 解譯器相容問題
yazelin 283b20a
fix: 補提交主動推送核心模組及修正 research-skill 推送 bug
yazelin b5f8321
test: 補齊主動推送功能測試覆蓋率
yazelin 9b72b07
fix: 修復 CI 失敗測試並達成 85% 覆蓋率門檻
yazelin fd53e92
fix: 修正群組 restricted 策略行為 — 群組未綁定用戶應走受限模式
yazelin f9069bb
fix: 修正 research-skill 推送摘要欄位名稱及 CSS 硬編碼顏色
yazelin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
41 changes: 41 additions & 0 deletions
41
backend/migrations/versions/014_add_proactive_push_settings.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
| """ | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+32
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 函式 from datetime import datetime, timedelta
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
today = datetime.now().date()
for i in range(8): # 掃描今天及過去 7 天
date_to_check = today - timedelta(days=i)
date_dir = base / date_to_check.strftime("%Y-%m-%d")
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} | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在 Alembic migration 中直接使用 f-string 組合 SQL 語句雖然在此情境下(
now.isoformat())是安全的,但這不是一個好的實踐,可能在其他地方被誤用導致 SQL injection 風險。建議使用 Alembic 提供的op.bulk_insert搭配sqlalchemy.sql.table來進行資料插入,這樣更符合 Alembic 的慣例,也更安全。