Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5726b8f
feat: add bot slash command router and multi-mode platform specs
yazelin Feb 26, 2026
7a0f638
feat: implement identity router for unbound user routing (Group 3)
yazelin Feb 26, 2026
e3693a1
feat: add bot-restricted and bot-debug agent definitions (Group 4)
yazelin Feb 26, 2026
edcdb8d
feat: implement rate limiter for unbound users (Group 5)
yazelin Feb 26, 2026
431f7f8
feat: add debug-skill with 5 diagnostic scripts (Group 6)
yazelin Feb 26, 2026
2ded10f
feat: implement /debug admin diagnostic command (Group 7)
yazelin Feb 26, 2026
68ab06f
feat: add is_public flag to knowledge base and library folder access …
yazelin Feb 26, 2026
e525b05
feat: add integration tests for bot multi-mode platform (Group 9)
yazelin Feb 26, 2026
61fcd96
chore: archive bot-multi-mode-platform and sync specs
yazelin Feb 26, 2026
51257c6
fix: resolve critical bugs found in code review
yazelin Feb 26, 2026
3ff6110
fix: harden bot multi-mode platform security and robustness
yazelin Feb 26, 2026
ff451c3
test: add restricted mode routing coverage for linebot_router
yazelin Feb 26, 2026
1b6d17d
fix: address code review findings (Gemini + self-review)
yazelin Feb 26, 2026
baefd55
test: add Telegram restricted mode routing tests for coverage
yazelin Feb 26, 2026
c46e3bc
fix: address code review H1-H4, M1-M4 findings
yazelin Feb 26, 2026
db95651
fix: ensure Telegram _ensure_bot_user/_ensure_bot_group return str
yazelin Feb 26, 2026
092ec28
refactor: remove dead code from reset command migration
yazelin Feb 26, 2026
4e412d9
fix: add reply fallback, deduplicate user query, and add /debug AI log
yazelin Feb 26, 2026
943bc68
refactor: extract get_command_user_context shared helper
yazelin Feb 26, 2026
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
33 changes: 33 additions & 0 deletions backend/migrations/versions/009_add_bot_usage_tracking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""新增 bot_usage_tracking 資料表

追蹤未綁定用戶的訊息使用量,支援 rate limiting。

Revision ID: 009
"""

from alembic import op

revision = "009"
down_revision = "008"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.execute("""
CREATE TABLE bot_usage_tracking (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_user_id UUID NOT NULL REFERENCES bot_users(id) ON DELETE CASCADE,
period_type VARCHAR(10) NOT NULL,
period_key VARCHAR(20) NOT NULL,
message_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(bot_user_id, period_type, period_key)
)
""")
# UNIQUE 約束已隱含建立唯一索引,無需額外 CREATE INDEX


def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS bot_usage_tracking")
90 changes: 78 additions & 12 deletions backend/src/ching_tech_os/api/linebot_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@
check_line_access,
update_group_settings,
reply_text,
push_text,
get_line_user_record,
)
from ..services.linebot_ai import handle_text_message

Expand Down Expand Up @@ -259,21 +261,85 @@ async def process_message_event(event: MessageEvent) -> None:

if not has_access:
if deny_reason == "user_not_bound":
# 個人對話:回覆提示訊息
if not is_group and event.reply_token:
# 身份分流:根據策略決定拒絕或進入受限模式
from ..services.bot.identity_router import (
route_unbound,
handle_restricted_mode,
)

route_result = route_unbound(
platform_type="line", is_group=is_group
)
if route_result.action == "reject":
if event.reply_token:
try:
await reply_text(event.reply_token, route_result.reply_text)
except Exception as e:
logger.warning(f"回覆未綁定訊息失敗: {e}")
elif route_result.action == "restricted":
# 受限模式:先攔截斜線指令
from ..services.bot.commands import router as cmd_router

parsed_cmd = cmd_router.parse(content)
if parsed_cmd:
cmd, cmd_args = parsed_cmd
from ..services.bot.commands import CommandContext

cmd_ctx = CommandContext(
platform_type="line",
platform_user_id=line_user_id,
bot_user_id=str(user_uuid),
ctos_user_id=None, # 未綁定
is_admin=False,
is_group=is_group,
group_id=str(group_uuid) if group_uuid else None,
reply_token=event.reply_token,
raw_args=cmd_args,
)
cmd_reply = await cmd_router.dispatch(cmd, cmd_args, cmd_ctx)
if cmd_reply and event.reply_token:
try:
await reply_text(event.reply_token, cmd_reply)
except Exception:
await push_text(line_user_id, cmd_reply)
return

# 受限模式 AI 處理
try:
await reply_text(
event.reply_token,
"請先在 CTOS 系統綁定您的 Line 帳號才能使用此服務。\n\n"
"步驟:\n"
"1. 登入 CTOS 系統\n"
"2. 進入 Line Bot 管理頁面\n"
"3. 點擊「綁定 Line 帳號」產生驗證碼\n"
"4. 將驗證碼發送給我完成綁定",
# 取得使用者顯示名稱
display_name = None
user_row = await get_line_user_record(
line_user_id, "display_name"
)
if user_row:
display_name = user_row["display_name"]

reply = await handle_restricted_mode(
content=content,
platform_user_id=line_user_id,
bot_user_id=str(user_uuid),
is_group=is_group,
line_group_id=group_uuid,
message_uuid=message_uuid,
user_display_name=display_name,
)
if reply:
# reply_token 可能在長時間 AI 處理後過期,
# 先嘗試 reply,失敗則 fallback 到 push
if event.reply_token:
try:
await reply_text(event.reply_token, reply)
except Exception:
await push_text(line_user_id, reply)
else:
await push_text(line_user_id, reply)
except Exception as e:
logger.warning(f"回覆未綁定訊息失敗: {e}")
# 群組對話:靜默不回應
logger.error(f"受限模式 AI 處理失敗: {e}", exc_info=True)
try:
await push_text(line_user_id, "抱歉,處理訊息時發生錯誤,請稍後再試。")
except Exception as push_e:
logger.warning(f"推送錯誤訊息失敗: {push_e}")
# silent: 群組靜默忽略
Comment on lines +279 to +342

Choose a reason for hiding this comment

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

medium

這個 elif route_result.action == "restricted": 區塊的邏輯相當複雜且長,在單一函式中混合了指令處理和 AI 處理兩種不同的路徑。

為了提高可讀性和可維護性,建議將這段邏輯提取到一個獨立的輔助函式中,例如 _handle_restricted_mode_event。這樣可以讓 process_message_event 的主流程更清晰,只負責分派事件,而將受限模式的具體處理細節封裝起來。

雖然無法在此提供可直接套用的程式碼建議來新增函式,但重構的方向是將 lines 280-341 的內容移至一個新的 async def _handle_restricted_mode_event(...) 函式中,並在原處呼叫它。

elif deny_reason == "group_not_allowed":
# 群組未開啟 AI 回應,靜默不回應
pass
Expand Down
32 changes: 32 additions & 0 deletions backend/src/ching_tech_os/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,38 @@ class Settings:
"ctos", # 系統簡稱
]

# ===================
# Bot 多模式平台設定
# ===================
# 未綁定用戶策略:reject(預設,拒絕並提示綁定)/ restricted(走受限模式 Agent)
bot_unbound_user_policy: str = _get_env("BOT_UNBOUND_USER_POLICY", "reject")
# 受限模式使用的 AI 模型(控制成本,預設用較輕量的 haiku)
bot_restricted_model: str = _get_env("BOT_RESTRICTED_MODEL", "haiku")
# Debug 模式使用的 AI 模型
bot_debug_model: str = _get_env("BOT_DEBUG_MODEL", "sonnet")
# 頻率限制開關(僅在 restricted 模式下生效)
bot_rate_limit_enabled: bool = _get_env_bool("BOT_RATE_LIMIT_ENABLED", True)
# 每小時訊息上限(未綁定用戶)
bot_rate_limit_hourly: int = _get_env_int("BOT_RATE_LIMIT_HOURLY", 20)
# 每日訊息上限(未綁定用戶)
bot_rate_limit_daily: int = _get_env_int("BOT_RATE_LIMIT_DAILY", 50)
# 驗證 hourly <= daily(若設定不合理則修正)
if bot_rate_limit_hourly > bot_rate_limit_daily:
logger.warning(
"BOT_RATE_LIMIT_HOURLY (%d) > BOT_RATE_LIMIT_DAILY (%d),"
"將 hourly 限制調整為與 daily 相同",
bot_rate_limit_hourly,
bot_rate_limit_daily,
)
bot_rate_limit_hourly = bot_rate_limit_daily

# 圖書館公開資料夾(逗號分隔,未綁定用戶只能看到這些資料夾)
library_public_folders: list[str] = [
f.strip()
for f in _get_env("LIBRARY_PUBLIC_FOLDERS", "產品資料,教育訓練").split(",")
if f.strip()
]

# ===================
# Telegram Bot 設定
# ===================
Expand Down
4 changes: 4 additions & 0 deletions backend/src/ching_tech_os/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ async def lifespan(app: FastAPI):
app.state.skillhub_client = SkillHubClient()
await init_db_pool()

# 註冊 Bot 斜線指令
from .services.bot.command_handlers import register_builtin_commands
register_builtin_commands()

# 啟動模組初始化(依啟停狀態)
for module_id, info in get_module_registry().items():
if not is_module_enabled(module_id):
Expand Down
5 changes: 5 additions & 0 deletions backend/src/ching_tech_os/models/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class KnowledgeCreate(BaseModel):
category: str = "technical"
scope: str = "personal" # 預設為個人知識(global、personal 或 project)
project_id: str | None = None # 關聯專案 UUID(scope=project 時使用)
is_public: bool = False # 是否允許未綁定用戶查詢
tags: KnowledgeTags = Field(default_factory=KnowledgeTags)
source: KnowledgeSource | None = None
related: list[str] = Field(default_factory=list)
Expand All @@ -81,6 +82,7 @@ class KnowledgeUpdate(BaseModel):
category: str | None = None
scope: str | None = None # global, personal, project
owner: str | None = None # 擁有者帳號(設為空字串可清除)
is_public: bool | None = None # 是否允許未綁定用戶查詢
tags: KnowledgeTags | None = None
source: KnowledgeSource | None = None
related: list[str] | None = None
Expand All @@ -96,6 +98,7 @@ class KnowledgeResponse(BaseModel):
scope: str = "global" # global、personal 或 project
owner: str | None = None # 擁有者帳號(個人知識用)
project_id: str | None = None # 關聯專案 UUID(專案知識用)
is_public: bool = False # 是否允許未綁定用戶查詢
tags: KnowledgeTags
source: KnowledgeSource
related: list[str]
Expand All @@ -116,6 +119,7 @@ class KnowledgeListItem(BaseModel):
scope: str = "global" # global、personal 或 project
owner: str | None = None # 擁有者帳號(個人知識用)
project_id: str | None = None # 關聯專案 UUID(專案知識用)
is_public: bool = False # 是否允許未綁定用戶查詢
tags: KnowledgeTags
author: str
updated_at: date
Expand Down Expand Up @@ -176,6 +180,7 @@ class IndexEntry(BaseModel):
scope: str = "global" # global、personal 或 project
owner: str | None = None # 擁有者帳號(個人知識用)
project_id: str | None = None # 關聯專案 UUID(專案知識用)
is_public: bool = False # 是否允許未綁定用戶查詢
tags: KnowledgeTags
author: str
created_at: str
Expand Down
139 changes: 139 additions & 0 deletions backend/src/ching_tech_os/services/bot/command_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""內建指令 handler

註冊所有內建的斜線指令到 CommandRouter。
"""

from __future__ import annotations

import logging
import re
import time

from .commands import CommandContext, SlashCommand, router
from ..bot_line.trigger import reset_conversation

logger = logging.getLogger(__name__)


async def _handle_reset(ctx: CommandContext) -> str | None:
"""重置對話歷史"""
# 群組檢查已由 CommandRouter.dispatch() 的 private_only 處理
await reset_conversation(ctx.platform_user_id)
return "已清除對話歷史,開始新對話!有什麼可以幫你的嗎?"


async def _handle_debug(ctx: CommandContext) -> str | None:
"""管理員系統診斷指令

使用 bot-debug Agent 和 debug-skill 腳本分析系統狀態。
"""
from .. import ai_manager
from ..claude_agent import call_claude
from ..bot.ai import parse_ai_response
from ...config import settings

# 取得 bot-debug Agent
agent = await ai_manager.get_agent_by_name("bot-debug")
if not agent:
return "⚠️ bot-debug Agent 不存在,請確認系統已正確初始化。"

# 取得 system prompt
system_prompt_data = agent.get("system_prompt")
if isinstance(system_prompt_data, dict):
system_prompt = system_prompt_data.get("content", "")
else:
system_prompt = ""

if not system_prompt:
return "⚠️ bot-debug Agent 缺少 system_prompt 設定。"

# 取得 Agent 定義的工具
agent_tools = agent.get("tools") or ["run_skill_script"]

# 準備 prompt(使用者的問題描述,或預設「分析系統目前狀態」)
user_problem = ctx.raw_args.strip() if ctx.raw_args else ""
if user_problem:
prompt = f"管理員問題:{user_problem}"
Comment on lines +54 to +56

Choose a reason for hiding this comment

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

security-medium medium

Potential prompt injection vulnerability. The user-provided problem description is directly embedded into the prompt sent to the diagnostic agent. Although this command is restricted to administrators, using delimiters to wrap the user input is a best practice to prevent unintended manipulation of the LLM's behavior.

else:
prompt = "請執行系統綜合健康檢查(check-system-health),分析目前系統狀態並回報結果。"

# 呼叫 Claude CLI
model = settings.bot_debug_model
start_time = time.time()

try:
Comment on lines +54 to +64

Choose a reason for hiding this comment

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

security-medium medium

Untrusted user input (user_problem) is directly concatenated into the prompt sent to the LLM in the /debug command handler. Although this command is restricted to administrators, it still poses a risk of prompt injection if an administrator's account is compromised or if an administrator is tricked into providing malicious input. Structured prompts and input sanitization should be used.

response = await call_claude(
prompt=prompt,
model=model,
system_prompt=system_prompt,
timeout=180, # 3 分鐘(診斷腳本可能需要時間)
tools=agent_tools,
ctos_user_id=ctx.ctos_user_id,
)
except Exception:
logger.exception("Debug 指令 AI 呼叫失敗")
return "⚠️ 診斷執行失敗,請查看系統日誌。"

duration_ms = int((time.time() - start_time) * 1000)
logger.info(f"/debug 診斷完成,耗時 {duration_ms}ms")

# 記錄 AI Log
try:
from ..linebot_ai import log_linebot_ai_call

await log_linebot_ai_call(
message_uuid=None,
line_group_id=None,
is_group=False,
input_prompt=prompt,
history=None,
system_prompt=system_prompt,
allowed_tools=agent_tools,
model=model,
response=response,
duration_ms=duration_ms,
context_type_override="bot-debug",
)
except Exception:
logger.warning("記錄 /debug AI Log 失敗", exc_info=True)

# 解析回應
reply_text, _files = parse_ai_response(response.message)

if not reply_text:
return "診斷完成,但未產生回報內容。"

# 過濾 FILE_MESSAGE(debug 不需要檔案傳送)
reply_text = re.sub(r"\[FILE_MESSAGE:[^\]]+\]", "", reply_text).strip()

return reply_text or "診斷完成,但未產生回報內容。"


def register_builtin_commands() -> None:
"""註冊所有內建指令"""

# /reset 指令(包含所有別名)
router.register(
SlashCommand(
name="reset",
aliases=["新對話", "新对话", "清除對話", "清除对话", "忘記", "忘记"],
handler=_handle_reset,
require_bound=False, # 受限模式用戶也可以重置
require_admin=False,
private_only=True,
)
)

# /debug 指令(管理員專用,僅限個人對話)
router.register(
SlashCommand(
name="debug",
aliases=["診斷", "diag"],
handler=_handle_debug,
require_bound=True,
require_admin=True,
private_only=True,
)
)

logger.info(f"已註冊 {len({id(v) for v in router._commands.values()})} 個內建指令")
Loading