前言

這是 Line Bot 系列的最後一篇。前面我們完成了 Webhook 架構檔案處理AI 對話整合群組專案綁定

這篇要實作一個實用功能:透過 Line Bot 搜尋並發送 NAS 檔案

使用情境:

  • 「給我亦達的 layout 圖」
  • 「找一下水切爐的規格 PDF」
  • 「那個資料夾還有什麼檔案?」

AI 會搜尋 NAS 共享資料夾,圖片直接顯示在對話中,其他檔案則附上下載連結。


架構概覽

用戶:「給我亦達的 layout 圖」
              │
              ▼
┌──────────────────────────────────────────┐
│  AI 助理                                  │
│  ┌─────────────────────────────────────┐ │
│  │ 1. search_nas_files                 │ │
│  │    keywords: "亦達,layout"          │ │
│  │    file_types: "jpg,png,pdf,dwg"    │ │
│  └──────────────┬──────────────────────┘ │
│                 │                         │
│                 ▼ 找到多個檔案            │
│  ┌─────────────────────────────────────┐ │
│  │ 2. 列出供用戶選擇                   │ │
│  │    1. 亦達_layout_v1.png           │ │
│  │    2. 亦達_layout_v2.dwg           │ │
│  │    3. 亦達_規格.pdf                │ │
│  └──────────────┬──────────────────────┘ │
│                 │                         │
│                 ▼ 用戶:「第一個」        │
│  ┌─────────────────────────────────────┐ │
│  │ 3. prepare_file_message             │ │
│  │    - 建立分享連結                   │ │
│  │    - 判斷是否為圖片                 │ │
│  └──────────────┬──────────────────────┘ │
│                 │                         │
│                 ▼                         │
│  ┌─────────────────────────────────────┐ │
│  │ 4. 回覆                             │ │
│  │    - 圖片:ImageMessage 直接顯示    │ │
│  │    - 其他:文字連結                 │ │
│  └─────────────────────────────────────┘ │
└──────────────────────────────────────────┘

MCP 工具實作

搜尋 NAS 檔案

@mcp.tool()
async def search_nas_files(
    keywords: str,
    file_types: str | None = None,
    limit: int = 100,
) -> str:
    """
    搜尋 NAS 共享檔案

    Args:
        keywords: 搜尋關鍵字,多個關鍵字用逗號分隔(AND 匹配)
        file_types: 檔案類型過濾,如:pdf,xlsx,dwg
        limit: 最大回傳數量
    """
    from pathlib import Path

    # 取得專案掛載點路徑
    projects_path = Path(settings.projects_mount_path)

    if not projects_path.exists():
        return f"錯誤:掛載點 {settings.projects_mount_path} 不存在"

    # 解析關鍵字(大小寫不敏感)
    keyword_list = [k.strip().lower() for k in keywords.split(",") if k.strip()]
    if not keyword_list:
        return "錯誤:請提供至少一個關鍵字"

    # 解析檔案類型
    type_list = []
    if file_types:
        type_list = [t.strip().lower().lstrip(".") for t in file_types.split(",")]

    # 搜尋檔案
    matched_files = []
    for file_path in projects_path.rglob("*"):
        if not file_path.is_file():
            continue

        # 取得相對路徑
        rel_path = file_path.relative_to(projects_path)
        rel_path_lower = str(rel_path).lower()

        # 關鍵字匹配(所有關鍵字都要匹配路徑)
        if not all(kw in rel_path_lower for kw in keyword_list):
            continue

        # 檔案類型匹配
        if type_list:
            suffix = file_path.suffix.lower().lstrip(".")
            if suffix not in type_list:
                continue

        # 取得檔案資訊
        stat = file_path.stat()
        matched_files.append({
            "path": f"/{rel_path}",
            "name": file_path.name,
            "size": stat.st_size,
            "modified": datetime.fromtimestamp(stat.st_mtime),
        })

        if len(matched_files) >= limit:
            break

    if not matched_files:
        return f"找不到符合「{keywords}」的檔案"

    # 格式化輸出
    result = f"找到 {len(matched_files)} 個檔案:\n\n"
    for i, f in enumerate(matched_files[:20], 1):
        size_str = format_file_size(f["size"])
        result += f"{i}. {f['name']}{size_str}\n"
        result += f"   路徑:{f['path']}\n"

    if len(matched_files) > 20:
        result += f"\n...還有 {len(matched_files) - 20} 個檔案"

    return result

取得檔案資訊

@mcp.tool()
async def get_nas_file_info(file_path: str) -> str:
    """
    取得 NAS 檔案詳細資訊

    Args:
        file_path: 檔案路徑(相對或完整路徑)
    """
    from pathlib import Path

    projects_path = Path(settings.projects_mount_path)

    # 正規化路徑
    if file_path.startswith(settings.projects_mount_path):
        full_path = Path(file_path)
    else:
        rel_path = file_path.lstrip("/")
        full_path = projects_path / rel_path

    # 安全檢查:確保路徑在允許範圍內
    full_path = full_path.resolve()
    if not str(full_path).startswith(str(projects_path.resolve())):
        return "錯誤:不允許存取此路徑"

    if not full_path.exists():
        return f"錯誤:檔案不存在 - {file_path}"

    # 取得檔案資訊
    stat = full_path.stat()
    size_str = format_file_size(stat.st_size)
    modified = datetime.fromtimestamp(stat.st_mtime)

    # 判斷檔案類型
    suffix = full_path.suffix.lower()
    type_map = {
        ".pdf": "PDF 文件",
        ".xlsx": "Excel 試算表",
        ".dwg": "AutoCAD 圖檔",
        ".png": "PNG 圖片",
        ".jpg": "JPEG 圖片",
    }
    file_type = type_map.get(suffix, f"{suffix} 檔案")

    return f"""📄 **{full_path.name}**

類型:{file_type}
大小:{size_str}
修改時間:{modified.strftime('%Y-%m-%d %H:%M:%S')}
完整路徑:{str(full_path)}
"""

準備檔案訊息

這是最關鍵的工具,會根據檔案類型決定如何回覆:

@mcp.tool()
async def prepare_file_message(file_path: str) -> str:
    """
    準備檔案訊息供 Line Bot 回覆。
    圖片會直接顯示,其他檔案會以連結形式呈現。

    Args:
        file_path: NAS 檔案的完整路徑
    """
    import json
    from pathlib import Path

    # 驗證檔案路徑
    full_path = validate_nas_file_path(file_path)

    # 取得檔案資訊
    file_name = full_path.name
    file_size = full_path.stat().st_size
    file_ext = full_path.suffix.lower().lstrip(".")

    # 格式化檔案大小
    if file_size >= 1024 * 1024:
        size_str = f"{file_size / 1024 / 1024:.1f}MB"
    else:
        size_str = f"{file_size / 1024:.1f}KB"

    # 判斷是否為圖片(Line ImageMessage 支援的格式)
    image_extensions = {"jpg", "jpeg", "png", "gif", "webp"}
    is_image = file_ext in image_extensions

    # Line ImageMessage 限制 10MB
    max_image_size = 10 * 1024 * 1024

    # 產生分享連結
    result = await create_share_link(
        resource_type="nas_file",
        resource_id=file_path,
        expires_in="24h",
    )
    download_url = result.full_url.replace("/s/", "/api/public/") + "/download"

    # 組合檔案訊息標記
    if is_image and file_size <= max_image_size:
        # 小圖片:標記為 image 類型
        file_info = {
            "type": "image",
            "url": download_url,
            "name": file_name,
        }
        hint = f"已準備好圖片 {file_name},會顯示在回覆中"
    else:
        # 其他檔案或大圖片:標記為 file 類型
        file_info = {
            "type": "file",
            "url": result.full_url,
            "name": file_name,
            "size": size_str,
        }
        hint = f"已準備好檔案 {file_name},會附上下載連結"

    # 使用特殊標記,讓回覆處理程式識別
    marker = f"__FILE_MESSAGE__{json.dumps(file_info)}__END_FILE__"

    return f"{hint}\n{marker}"

檔案訊息處理

AI 回覆中的檔案標記需要特殊處理:

FILE_MESSAGE_PATTERN = re.compile(
    r"__FILE_MESSAGE__(.+?)__END_FILE__",
    re.DOTALL
)


def extract_file_messages(text: str) -> tuple[str, list[dict]]:
    """
    從文字中提取檔案訊息標記

    Returns:
        (清理後的文字, 檔案訊息列表)
    """
    file_messages = []

    def replace_marker(match):
        try:
            file_info = json.loads(match.group(1))
            file_messages.append(file_info)
        except json.JSONDecodeError:
            pass
        return ""

    clean_text = FILE_MESSAGE_PATTERN.sub(replace_marker, text)
    clean_text = clean_text.strip()

    return clean_text, file_messages

回覆訊息組合

根據檔案類型組合不同的訊息:

async def reply_with_files(
    reply_token: str,
    text: str,
    file_messages: list[dict],
) -> list[str]:
    """
    回覆文字和檔案(混合訊息)

    Args:
        reply_token: Line 回覆 token
        text: 文字內容
        file_messages: 檔案訊息列表
    """
    from linebot.v3.messaging import TextMessage, ImageMessage

    messages = []

    # 先加入文字訊息
    if text:
        messages.append(TextMessage(text=text))

    # 處理檔案訊息
    for file_info in file_messages:
        file_type = file_info.get("type", "file")
        url = file_info.get("url", "")
        name = file_info.get("name", "")
        size = file_info.get("size", "")

        if file_type == "image" and url:
            # 圖片:使用 ImageMessage 直接顯示
            messages.append(ImageMessage(
                original_content_url=url,
                preview_image_url=url,
            ))
        elif file_type == "file" and url:
            # 非圖片檔案:加入連結文字
            link_text = f"📎 {name}"
            if size:
                link_text += f"({size})"
            link_text += f"\n{url}\n⏰ 連結 24 小時內有效"

            # 追加到現有文字訊息
            if messages and isinstance(messages[0], TextMessage):
                messages[0] = TextMessage(
                    text=messages[0].text + "\n\n" + link_text
                )
            else:
                messages.append(TextMessage(text=link_text))

    # Line 限制每次最多 5 則訊息
    if len(messages) > 5:
        # 超出的圖片轉為連結
        extra_messages = messages[5:]
        messages = messages[:5]

        extra_links = []
        for msg in extra_messages:
            if isinstance(msg, ImageMessage):
                extra_links.append(msg.original_content_url)

        if extra_links and isinstance(messages[0], TextMessage):
            messages[0] = TextMessage(
                text=messages[0].text + "\n\n其他圖片連結:\n" + "\n".join(extra_links)
            )

    return await reply_messages(reply_token, messages)

Line 訊息限制

Line Messaging API 有幾個重要限制:

限制 說明
每次最多 5 則訊息 可混合文字和圖片
圖片最大 10MB 超過需轉為連結
圖片格式 jpg, jpeg, png, gif, webp
圖片需 HTTPS 分享連結需走 HTTPS

處理多張圖片

當搜尋結果有多張圖片時:

# 情境:找到 10 張圖片
# 處理:前 4 張直接顯示,其餘附連結

messages = [
    TextMessage(text="找到 10 張圖片,顯示前 4 張:"),
    ImageMessage(...),  # 圖片 1
    ImageMessage(...),  # 圖片 2
    ImageMessage(...),  # 圖片 3
    ImageMessage(...),  # 圖片 4
]
# 第 5-10 張附在文字中以連結形式呈現

使用情境

情境一:搜尋並發送單一圖片

用戶:給我亦達的 layout 圖

AI:(使用 search_nas_files)
AI:找到 3 個檔案:
    1. 亦達_layout_v1.png(2.5MB)
    2. 亦達_layout_v2.dwg(8.1MB)
    3. 亦達_規格.pdf(512KB)

    要傳送哪一個?

用戶:第一個

AI:(使用 prepare_file_message)
AI:[顯示圖片]

情境二:發送多張圖片

用戶:給我那個資料夾的所有圖

AI:(使用 search_nas_files)
AI:這個資料夾有 8 張圖,我先傳 4 張:
    [圖片 1]
    [圖片 2]
    [圖片 3]
    [圖片 4]

    其他圖片連結:
    https://...
    https://...
    https://...
    https://...

情境三:發送非圖片檔案

用戶:給我水切爐的規格 PDF

AI:(使用 search_nas_files)
AI:(使用 prepare_file_message)
AI:📎 水切爐規格書.pdf(1.2MB)
    https://ctos.example.com/s/abc123
    ⏰ 連結 24 小時內有效

安全考量

路徑安全檢查

def validate_nas_file_path(file_path: str) -> Path:
    """
    驗證並正規化 NAS 檔案路徑

    Raises:
        NasFileAccessDenied: 路徑不在允許範圍
        NasFileNotFoundError: 檔案不存在
    """
    projects_path = Path(settings.projects_mount_path)

    # 正規化路徑
    if file_path.startswith(settings.projects_mount_path):
        full_path = Path(file_path)
    else:
        rel_path = file_path.lstrip("/")
        full_path = projects_path / rel_path

    # 解析符號連結並檢查
    full_path = full_path.resolve()
    if not str(full_path).startswith(str(projects_path.resolve())):
        raise NasFileAccessDenied("不允許存取此路徑")

    if not full_path.exists():
        raise NasFileNotFoundError(f"檔案不存在:{file_path}")

    return full_path

分享連結有效期

所有分享連結預設 24 小時有效,避免長期暴露:

result = await create_share_link(
    resource_type="nas_file",
    resource_id=file_path,
    expires_in="24h",  # 24 小時後失效
)

小結

這篇完成了 NAS 檔案搜尋與發送功能:

  • search_nas_files:關鍵字搜尋檔案
  • get_nas_file_info:取得檔案詳細資訊
  • prepare_file_message:準備檔案訊息
  • 圖片直接顯示:使用 ImageMessage
  • 檔案附連結:24 小時有效的分享連結

至此,Line Bot 整合系列告一段落。接下來會介紹 MCP 協議入門,深入了解讓 AI 使用工具的標準協議。


系列總覽

  1. Webhook 架構與訊息接收
  2. 檔案處理:圖片自動下載到 NAS
  3. 與 Claude AI 對話整合
  4. 群組管理與專案綁定
  5. 搜尋並發送 NAS 檔案(本篇)

參考資源