Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/migrations/versions/014_add_proactive_push_settings.py
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
"""
)
Comment on lines +23 to +31

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在 Alembic migration 中直接使用 f-string 組合 SQL 語句雖然在此情境下(now.isoformat())是安全的,但這不是一個好的實踐,可能在其他地方被誤用導致 SQL injection 風險。建議使用 Alembic 提供的 op.bulk_insert 搭配 sqlalchemy.sql.table 來進行資料插入,這樣更符合 Alembic 的慣例,也更安全。



def downgrade() -> None:
op.execute(
"""
DELETE FROM bot_settings
WHERE key = 'proactive_push_enabled'
AND platform IN ('line', 'telegram')
"""
)
13 changes: 13 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 21 additions & 5 deletions backend/src/ching_tech_os/api/bot_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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):
Expand All @@ -90,6 +93,7 @@ class BotSettingsStatusResponse(BaseModel):
"""Bot 設定狀態回應"""
platform: str
fields: dict[str, FieldStatus]
proactive_push_enabled: bool = False


class BotSettingsDeleteResponse(BaseModel):
Expand All @@ -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)
Expand All @@ -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} 設定已更新")


Expand Down
151 changes: 151 additions & 0 deletions backend/src/ching_tech_os/api/internal_push.py
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

函式 _find_status_file 的註解提到「掃描最近 7 天」,但目前的實作是遍歷所有日期的資料夾 (sorted(base.iterdir(), reverse=True))。當累積的資料夾過多時,這可能會有效能問題。建議修改實作,使其符合註解的描述,只掃描最近 7 天的資料夾,以提高效率並避免不必要的 I/O 操作。

    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}
1 change: 1 addition & 0 deletions backend/src/ching_tech_os/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion backend/src/ching_tech_os/services/bot/command_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions backend/src/ching_tech_os/services/bot/identity_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions backend/src/ching_tech_os/services/bot_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 憑證

Expand Down
Loading