diff --git a/.env.example b/.env.example index 8189513..47d1428 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Channel plugins (bidirectional DM bridge) +# Channel plugins (bidirectional DM bridge via --channels) ## ─── Telegram ──────────────────────────────────── TELEGRAM_BOT_TOKEN= @@ -6,14 +6,13 @@ TELEGRAM_BOT_TOKEN= ## ─── Discord ───────────────────────────────────── DISCORD_BOT_TOKEN= -# MCP plugins (outbound tool integrations, not channels) +# Broker channels (standalone message broker, not --channels) -## ─── Slack (MCP only — OAuth preferred, tokens for direct API) ─── -# SLACK_BOT_TOKEN= -# SLACK_APP_TOKEN= +## ─── Slack (broker: polling DMs, invokes claude -p) ─── +## Slack Bot Token (from https://api.slack.com/apps) +SLACK_BOT_TOKEN= -# Planned - -## ─── LINE ──────────────────────────────────────── -# LINE_CHANNEL_ACCESS_TOKEN= -# LINE_CHANNEL_SECRET= +## ─── LINE (broker: webhook server, invokes claude -p) ─── +## LINE Developers Console (from https://developers.line.biz/console/) +LINE_CHANNEL_ACCESS_TOKEN= +LINE_CHANNEL_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3bfed..629eb4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,71 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.3.0] - 2026-03-23 + +### Added + +- LINE channel integration via message broker (webhook + `claude -p`) + - Webhook server with signature verification + - Image download and analysis support + - Group chat support with access control + - Reply API (free) with Push API fallback + - Rate limiting and busy guard +- LINE documentation (plan, install) in EN and zh-TW +- Log file persistence for both Slack and LINE brokers +- Tool access (`--allowedTools`) and system prompt for broker channels +- GitHub topic: `line-bot` + +## [0.2.0] - 2026-03-22 + +### Added + +- Slack channel integration via message broker (polling + `claude -p`) + - Polls Slack DMs, pipes to Claude CLI, replies in thread + - Image attachment download and analysis + - Access control via `access.json` allowlist + - Cursor tracking for message deduplication +- Slack token verification script (`scripts/verify_slack.sh`) +- Plugin architecture documentation (EN + zh-TW) +- Pre-push reviewer agent (`.claude/agents/pre-push-reviewer.md`) +- GitHub community files (CONTRIBUTING, SECURITY, issue/PR templates) +- README badges (CI, license, issues, stars) +- Usage examples and screenshots sections +- Prerequisites doc (shared Bun/Claude Code setup) +- All `install.zh-tw.md` translations + +### Changed + +- Rename `*_zh-tw.md` to `*.zh-tw.md` (BCP 47 convention) +- Move `docs/discord/issue.md` to `docs/issues.md` (cross-channel) +- Slack status: Planned → Broker (not a channel plugin) +- `.env.example`: separate channel plugins vs broker channels +- `start.sh`: support broker channels (Slack, LINE) + +### Fixed + +- CI markdownlint config (`.markdownlint-cli2.jsonc`) +- shellcheck SC1090 warning in `verify_slack.sh` + +### Documented + +- Issue #1: STATE_DIR path mismatch (PR #866 submitted) +- Issue #2: Token leakage via command arguments +- Issue #3: Slack plugin is MCP-only, not a channel plugin +- Issue #4: Claude Code `--channels server:` dev mode never approved + ## [0.1.0] - 2026-03-21 ### Added - Telegram channel integration via Claude Code Channels plugin -- Bidirectional messaging (Telegram <-> Claude Code session) -- Approval workflow pattern (approve/reject via Telegram) +- Discord channel integration via Claude Code Channels plugin +- Bidirectional messaging (Telegram/Discord <-> Claude Code session) +- Approval workflow pattern (approve/reject via messaging) - Multi-channel launcher script (`start.sh`) - Per-channel documentation structure (`docs//`) - MIT license +[0.3.0]: https://github.com/osisdie/claude-code-channels/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/osisdie/claude-code-channels/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/osisdie/claude-code-channels/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 26c4486..2855fbc 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A project-level setup for running [Claude Code](https://docs.anthropic.com/en/do | Telegram | Ready | [docs/telegram/](docs/telegram/) | | Discord | Ready | [docs/discord/](docs/discord/) | | Slack | Broker | [docs/slack/](docs/slack/) | -| LINE | Planned | - | +| LINE | Broker | [docs/line/](docs/line/) | ## Quick Start @@ -129,10 +129,20 @@ You: approve │ │ ├── plan.zh-tw.md # Planning doc (zh-TW) │ │ ├── install.md # Installation & integration notes │ │ └── install.zh-tw.md # Installation notes (zh-TW) -│ └── slack/ -│ ├── plan.md # Integration plan (MCP only, not channel) +│ ├── slack/ +│ │ ├── plan.md # Integration plan (MCP only, not channel) +│ │ ├── install.md # Installation & integration notes +│ │ └── install.zh-tw.md # Installation notes (zh-TW) +│ └── line/ +│ ├── plan.md # Integration planning doc +│ ├── plan.zh-tw.md # Planning doc (zh-TW) │ ├── install.md # Installation & integration notes │ └── install.zh-tw.md # Installation notes (zh-TW) +├── external_plugins/ +│ ├── slack-channel/ +│ │ └── broker.ts # Slack message broker +│ └── line-channel/ +│ └── broker.ts # LINE webhook broker ├── scripts/ │ └── verify_slack.sh # Slack token verification & smoke test ├── .github/ @@ -168,6 +178,12 @@ You: approve |-----|-------| | ![Slack Ask](docs/screenshots/slack/ask.png) | ![Slack Reply](docs/screenshots/slack/reply.png) | +### LINE + +| Ask (Flower) | Ask (Weather) | +|------|------| +| LINE Flower | LINE Weather | + ### Claude Code Terminal ![Claude Code Channel Messages](docs/screenshots/claude_code/channel_messages.png) @@ -182,6 +198,8 @@ You: approve - [Discord -- Planning Document](docs/discord/plan.md) - [Slack -- Installation & Integration Notes](docs/slack/install.md) - [Slack -- Planning Document](docs/slack/plan.md) +- [LINE -- Installation & Integration Notes](docs/line/install.md) +- [LINE -- Planning Document](docs/line/plan.md) ### General diff --git a/README.zh-TW.md b/README.zh-TW.md index b1570b4..6d90814 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -15,7 +15,7 @@ | Telegram | 可用 | [docs/telegram/](docs/telegram/) | | Discord | 可用 | [docs/discord/](docs/discord/) | | Slack | Broker | [docs/slack/](docs/slack/) | -| LINE | 規劃中 | - | +| LINE | Broker | [docs/line/](docs/line/) | ## 快速開始 @@ -124,10 +124,20 @@ Bot: "即將執行 `rm -rf dist/` — approve 或 reject?" │ │ ├── plan.zh-tw.md # 整合規劃文件(zh-TW) │ │ ├── install.md # 安裝與整合筆記 │ │ └── install.zh-tw.md # 安裝與整合筆記(zh-TW) -│ └── slack/ -│ ├── plan.md # 整合規劃(僅 MCP,非 channel) +│ ├── slack/ +│ │ ├── plan.md # 整合規劃(僅 MCP,非 channel) +│ │ ├── install.md # 安裝與整合筆記 +│ │ └── install.zh-tw.md # 安裝與整合筆記(zh-TW) +│ └── line/ +│ ├── plan.md # 整合規劃文件 +│ ├── plan.zh-tw.md # 整合規劃文件(zh-TW) │ ├── install.md # 安裝與整合筆記 │ └── install.zh-tw.md # 安裝與整合筆記(zh-TW) +├── external_plugins/ +│ ├── slack-channel/ +│ │ └── broker.ts # Slack 訊息 broker +│ └── line-channel/ +│ └── broker.ts # LINE webhook broker ├── scripts/ │ └── verify_slack.sh # Slack token 驗證與煙霧測試 ├── .github/ @@ -157,6 +167,8 @@ Bot: "即將執行 `rm -rf dist/` — approve 或 reject?" - [Discord — 規劃文件](docs/discord/plan.zh-tw.md) - [Slack — 安裝與整合筆記](docs/slack/install.zh-tw.md) - [Slack — 規劃文件](docs/slack/plan.md) +- [LINE — 安裝與整合筆記](docs/line/install.zh-tw.md) +- [LINE — 規劃文件](docs/line/plan.zh-tw.md) ### 一般 diff --git a/docs/line/create_a_new_channel.png b/docs/line/create_a_new_channel.png new file mode 100644 index 0000000..2d08d36 Binary files /dev/null and b/docs/line/create_a_new_channel.png differ diff --git a/docs/line/entry-line-biz-form-entry-unverified.png b/docs/line/entry-line-biz-form-entry-unverified.png new file mode 100644 index 0000000..62b1434 Binary files /dev/null and b/docs/line/entry-line-biz-form-entry-unverified.png differ diff --git a/docs/line/install.md b/docs/line/install.md new file mode 100644 index 0000000..c010022 --- /dev/null +++ b/docs/line/install.md @@ -0,0 +1,311 @@ +# Claude Code x LINE - Installation & Integration Notes + +## Overview + +This document records the actual installation and integration experience of connecting Claude Code to LINE via the message broker (2026/03). + +**Architecture:** + +```text +LINE App (Mobile/Desktop) + | (LINE Platform, webhook POST) +ngrok / Cloudflare Tunnel + | (forwards to localhost:3000) +LINE Broker (Bun HTTP server) + | (subprocess: claude -p) +Claude CLI (stateless, per-message) +``` + +**Environment:** + +- OS: WSL2 (Linux 6.6.87.2-microsoft-standard-WSL2) +- Claude Code: v2.1.81 +- Runtime: Bun +- Tunnel: ngrok +- LINE Official Account: Claude Code Lab + +--- + +## Installation Steps (Executed) + +### 1. Create LINE Official Account + +1. Go to [LINE Developers Console](https://developers.line.biz/console/) +2. Create a **Provider** (your organization name) +3. Create a **LINE Official Account** with **Messaging API** enabled +4. In channel settings: + - Copy **Channel Secret** (Basic settings tab) + - Issue **Channel Access Token** (Messaging API tab > Issue) + +**Account settings:** + +| Field | Recommended | +| ----- | ----------- | +| Account name | `Claude Code Lab` (or similar lab/dev name) | +| Category | IT / Internet / Communication > Software / Web Services | + +> **Note:** Category (業種) cannot be changed after creation. Choose IT-related category. + +### 2. Store Tokens + +```bash +echo "LINE_CHANNEL_ACCESS_TOKEN=your-token" >> .env +echo "LINE_CHANNEL_SECRET=your-secret" >> .env +chmod 600 .env +``` + +> **Warning:** Do NOT pass tokens as command arguments. They leak into conversation history. See [Issue #2](../issues.md). + +### 3. Set Up Tunnel + +LINE requires a public HTTPS webhook URL. For local development: + +```bash +ngrok http 3000 +# Copy the https://xxxx.ngrok-free.app URL +``` + +> **Important:** ngrok free plan URLs change on every restart. Update the webhook URL in LINE console each time. + +### 4. Configure Webhook in LINE Console + +**Messaging API** tab > **Webhook settings:** + +1. Set Webhook URL to: `https://xxxx.ngrok-free.app/webhook` +2. Click **Verify** to test connectivity +3. Enable **Use webhook** (toggle ON) + +> **Common mistake:** Forgetting `/webhook` at the end of the URL. The broker only listens on `/webhook` path, not the root `/`. + +### 5. Disable Auto-Reply + +In [LINE Official Account Manager](https://manager.line.biz/): + +1. Select your account > **Settings** > **Response settings** +2. Set **Auto-reply messages** to **OFF** +3. Set **Greeting messages** to **OFF** (optional) + +Without this, LINE's built-in auto-reply intercepts messages before they reach the webhook. + +### 6. Enable Group Chat (Optional) + +By default, LINE bots **cannot be invited to group chats**. To enable: + +1. [LINE Official Account Manager](https://manager.line.biz/) > **Settings** > **Account settings** +2. Find **Allow bot to join group chats** > set to **ON** + +Or in [LINE Developers Console](https://developers.line.biz/console/): + +1. Select channel > **Messaging API** tab +2. **Allow bot to join group chats** > **Enabled** + +> **Gotcha:** Without this setting, the bot will immediately leave any group it's invited to. The broker log shows `left group: Cxxxxxxx` as the symptom. + +### 7. Set Up Access Control + +```bash +mkdir -p .claude/channels/line +``` + +For DM only (allow all users): + +```bash +cat > .claude/channels/line/access.json << 'EOF' +{ + "dmPolicy": "allowlist", + "allowFrom": [], + "groups": {}, + "pending": {} +} +EOF +``` + +To restrict to specific users + enable a group: + +```bash +cat > .claude/channels/line/access.json << 'EOF' +{ + "dmPolicy": "allowlist", + "allowFrom": ["Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"], + "groups": { + "Cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": { + "allowFrom": [] + } + }, + "pending": {} +} +EOF +``` + +**How to find IDs:** + +- **User ID**: Send a DM to the bot, check broker logs for `Uxxxxxxx: [text] ...` +- **Group ID**: Invite bot to group, check logs for `joined group: Cxxxxxxx` +- `allowFrom: []` in groups means **any member** can trigger the bot +- `allowFrom: ["Uxxxxxx"]` restricts to specific users + +> **Note:** The broker re-reads `access.json` on every message — no restart needed after editing. + +### 8. Launch + +```bash +./start.sh line +``` + +Expected output: + +```text +Starting line broker... +[broker] LINE webhook server running on port 3000 +[broker] webhook URL: http://localhost:3000/webhook +[broker] project: /mnt/c/writable/git/nwpie/ClawProjects/claude-claw +[broker] state: .../. claude/channels/line +``` + +--- + +## Verified Features + +| Feature | Status | +| ------- | ------ | +| Text DM > Claude > reply | Verified | +| Group message > Claude > reply | Verified | +| Image download + analysis | Verified | +| WebSearch tool (real-time info) | Verified | +| Rate limiting (per-user cooldown) | Verified | +| Busy guard (concurrent request rejection) | Verified | +| Webhook signature verification | Verified | +| Reply API (free) with Push API fallback | Verified | +| Multi-chunk response (>5000 chars) | Verified | +| Log file persistence | Verified | + +--- + +## Image Handling + +LINE sends images as separate `message` events with `type: "image"`. The broker: + +1. Downloads the image via LINE Content API (`/v2/bot/message/{id}/content`) +2. Saves to `.claude/channels/line/inbox/` +3. Includes the file path in the prompt to Claude +4. Claude reads and analyzes the image + +**Limitations:** + +- LINE doesn't support sending text + image in a single message. They arrive as separate events +- Send the image first, then follow up with a text question if needed +- Image-only messages auto-prompt: "Describe the attached file(s)" +- Max image size: 10MB + +--- + +## Tool Access + +The broker runs `claude -p` with `--allowedTools` to enable real-time capabilities: + +**Default tools enabled:** + +- `WebSearch` — search the web (weather, news, prices, etc.) +- `WebFetch` — fetch web pages +- `Bash(curl:*)` — API calls +- `Bash(python3:*)` — computation +- `Read` — read local files and images + +**Customize via environment variable:** + +```bash +BROKER_ALLOWED_TOOLS="WebSearch,Read" ./start.sh line +``` + +**System prompt:** The broker includes a system prompt that instructs Claude to use tools proactively for real-time queries. Customize via `BROKER_SYSTEM_PROMPT` env var. + +--- + +## Rate Limiting & Busy Guard + +| Protection | Behavior | Default | +| ---------- | -------- | ------- | +| **Busy guard** | If Claude is processing a message, new messages get "⏳ Processing..." reply | Always active | +| **Rate limit** | Per-user cooldown between messages | 5 seconds (`RATE_LIMIT_MS=5000`) | + +Both use LINE's Reply API (free) — no Push API quota consumed for rejection messages. + +--- + +## Security Notes + +1. **Webhook signature verification** — Every incoming webhook is verified using HMAC-SHA256 with Channel Secret. Invalid signatures return 403 +2. **Token storage** — Tokens in `.env` (gitignored). Never pass as command arguments +3. **Access control** — `access.json` controls who can interact. Empty `allowFrom` = all users allowed +4. **No conversation persistence** — Each message spawns a fresh `claude -p` call. No chat history stored +5. **Group isolation** — Groups must be explicitly opted-in via `access.json`. Non-opted groups are silently ignored +6. **File permissions** — Downloaded files saved to `inbox/` (gitignored). Bot state in `.claude/channels/line/` (gitignored) + +--- + +## Tunnel Considerations + +### ngrok Free Plan + +- URL changes on every restart — must update LINE webhook URL each time +- Authenticated accounts (with authtoken) don't show browser interstitial +- Run `ngrok config add-authtoken ` once to authenticate +- Consider ngrok paid plan or Cloudflare Tunnel for stable URLs + +### Cloudflare Tunnel (Alternative) + +```bash +cloudflared tunnel --url http://localhost:3000 +``` + +- Free, no interstitial +- URL also changes on restart (unless using named tunnels with paid plan) + +### WSL2 + +- Tunnels work from WSL2 (outbound connections) +- Broker binds to `0.0.0.0:3000` — accessible from WSL2 localhost + +--- + +## Configuration + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `LINE_CHANNEL_ACCESS_TOKEN` | (required) | Channel Access Token | +| `LINE_CHANNEL_SECRET` | (required) | Channel Secret (for webhook verification) | +| `LINE_STATE_DIR` | `.claude/channels/line` | State directory | +| `PORT` | `3000` | Webhook server port | +| `CLAUDE_BIN` | `claude` | Path to claude CLI | +| `BROKER_ALLOWED_TOOLS` | `WebSearch,WebFetch,...` | Comma-separated tool list | +| `BROKER_SYSTEM_PROMPT` | (built-in) | Custom system prompt for Claude | +| `RATE_LIMIT_MS` | `5000` | Per-user cooldown in milliseconds | + +--- + +## Key Files + +| File | Purpose | +| ---- | ------- | +| `external_plugins/line-channel/broker.ts` | LINE webhook broker | +| `.env` | `LINE_CHANNEL_ACCESS_TOKEN`, `LINE_CHANNEL_SECRET` (gitignored) | +| `.claude/channels/line/access.json` | Access control (gitignored) | +| `.claude/channels/line/inbox/` | Downloaded images/files (gitignored) | +| `.claude/channels/line/logs/` | Broker logs by date (gitignored) | +| `docs/line/plan.md` | Integration planning document | +| `docs/line/install.md` | This document | + +--- + +## Gotchas & Lessons Learned + +1. **Webhook URL must end with `/webhook`** — The broker only listens on the `/webhook` path. Setting the root URL (without `/webhook`) in LINE console will result in 404 and no messages received +2. **Bot cannot join groups by default** — Must enable "Allow bot to join group chats" in LINE Official Account settings. Without this, the bot immediately leaves any group (log shows `left group: Cxxxxxxx`) +3. **Group ID must be in access.json** — Even after enabling group chat, messages from groups are silently ignored unless the group ID is added to `access.json` `groups` field +4. **Auto-reply must be disabled** — LINE's built-in auto-reply intercepts messages before they reach the webhook. Disable in Official Account Manager > Response settings +5. **Category (業種) cannot be changed** — Choose the right category when creating the LINE Official Account. IT / Software is recommended +6. **Image and text are separate events** — LINE doesn't support combined text + image messages. Send image first, then text as follow-up +7. **Reply API free but expires in ~60s** — If Claude takes longer than ~60 seconds, the replyToken expires. Broker falls back to Push API (monthly quota: 500 free) +8. **ngrok URL changes on restart** — Must update webhook URL in LINE console each time ngrok restarts. Consider paid tunnel for stable URL +9. **Stateless per message** — Each message spawns an independent `claude -p` call. No conversation context between messages +10. **Rate limiting** — Default 5s cooldown per user. Busy messages use Reply API (free). Configure via `RATE_LIMIT_MS` diff --git a/docs/line/install.zh-tw.md b/docs/line/install.zh-tw.md new file mode 100644 index 0000000..e55601a --- /dev/null +++ b/docs/line/install.zh-tw.md @@ -0,0 +1,258 @@ +# Claude Code x LINE — 安裝與整合筆記 + +## 概觀 + +本文件記錄透過 message broker(2026/03)將 Claude Code 連接到 LINE 的實際安裝與整合經驗。 + +**架構:** + +```text +LINE App(手機/桌面) + | (LINE Platform, webhook POST) +ngrok / Cloudflare Tunnel + | (轉發到 localhost:3000) +LINE Broker (Bun HTTP server) + | (子行程:claude -p) +Claude CLI(無狀態,每訊息獨立) +``` + +--- + +## 安裝步驟(已執行) + +### 1. 建立 LINE 官方帳號 + +1. 前往 [LINE Developers Console](https://developers.line.biz/console/) +2. 建立 **Provider**(你的組織名稱) +3. 建立 **LINE 官方帳號**並啟用 **Messaging API** +4. 在 channel 設定中: + - 複製 **Channel Secret**(Basic settings 分頁) + - 核發 **Channel Access Token**(Messaging API 分頁 > Issue) + +**帳號設定建議:** + +| 欄位 | 建議值 | +| ---- | ------ | +| 帳號名稱 | `Claude Code Lab`(或類似的 lab/dev 名稱)| +| 業種 | IT・網際網路・通訊 > 軟體・網路服務 | + +> **注意:** 業種建立後**無法修改**。請選擇 IT 相關類別。 + +### 2. 儲存 Token + +```bash +echo "LINE_CHANNEL_ACCESS_TOKEN=your-token" >> .env +echo "LINE_CHANNEL_SECRET=your-secret" >> .env +chmod 600 .env +``` + +> **警告:** 不要將 token 作為指令參數傳遞。見 [Issue #2](../issues.md)。 + +### 3. 設定 Tunnel + +LINE 需要公開的 HTTPS webhook URL。本地開發: + +```bash +ngrok http 3000 +# 複製 https://xxxx.ngrok-free.app URL +``` + +> **注意:** ngrok 免費方案的 URL 每次重啟都會變。每次都需要更新 LINE console 的 webhook URL。 + +### 4. 設定 LINE Console 的 Webhook + +**Messaging API** 分頁 > **Webhook settings:** + +1. 設定 Webhook URL 為:`https://xxxx.ngrok-free.app/webhook` +2. 點擊 **Verify** 測試連通性 +3. 啟用 **Use webhook**(切換為 ON) + +> **常見錯誤:** URL 結尾忘記加 `/webhook`。Broker 只監聽 `/webhook` 路徑,不是根路徑 `/`。 + +### 5. 停用自動回覆 + +在 [LINE Official Account Manager](https://manager.line.biz/): + +1. 選擇帳號 > **設定** > **回應設定** +2. 將**自動回覆訊息**設為 **OFF** +3. 將**加入好友的歡迎訊息**設為 **OFF**(選用) + +不停用的話,LINE 內建自動回覆會攔截訊息,webhook 收不到。 + +### 6. 啟用群組聊天(選用) + +LINE bot 預設**無法被邀請加入群組**。要啟用: + +1. [LINE Official Account Manager](https://manager.line.biz/) > **設定** > **帳號設定** +2. 找到**允許被加入群組或多人聊天室** > 設為 **ON** + +> **注意:** 沒有啟用此設定,bot 被邀請後會立即離開群組。Broker log 會顯示 `left group: Cxxxxxxx`。 + +### 7. 設定存取控制 + +```bash +mkdir -p .claude/channels/line +``` + +僅限 DM(允許所有人): + +```bash +cat > .claude/channels/line/access.json << 'EOF' +{ + "dmPolicy": "allowlist", + "allowFrom": [], + "groups": {}, + "pending": {} +} +EOF +``` + +限制特定使用者 + 啟用群組: + +```bash +cat > .claude/channels/line/access.json << 'EOF' +{ + "dmPolicy": "allowlist", + "allowFrom": ["Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"], + "groups": { + "Cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": { + "allowFrom": [] + } + }, + "pending": {} +} +EOF +``` + +**如何找到 ID:** + +- **User ID**:向 bot 發送 DM,檢查 broker log 中的 `Uxxxxxxx: [text] ...` +- **Group ID**:邀請 bot 加入群組,檢查 log 中的 `joined group: Cxxxxxxx` +- 群組 `allowFrom: []` 表示**任何成員**都可觸發 bot +- 修改 `access.json` 後不需重啟 — broker 每次收到訊息都會重新讀取 + +### 8. 啟動 + +```bash +./start.sh line +``` + +--- + +## 已驗證功能 + +| 功能 | 狀態 | +| ---- | ---- | +| 文字 DM > Claude > 回覆 | 已驗證 | +| 群組訊息 > Claude > 回覆 | 已驗證 | +| 圖片下載 + 分析 | 已驗證 | +| WebSearch 工具(即時資訊)| 已驗證 | +| 頻率限制(每使用者冷卻)| 已驗證 | +| 忙碌防護(並行請求拒絕)| 已驗證 | +| Webhook 簽章驗證 | 已驗證 | +| Reply API(免費)+ Push API 退回 | 已驗證 | +| Log 檔案持久化 | 已驗證 | + +--- + +## 圖片處理 + +LINE 將圖片作為獨立的 `message` 事件(`type: "image"`)發送。Broker 會: + +1. 透過 LINE Content API 下載圖片 +2. 儲存到 `.claude/channels/line/inbox/` +3. 將檔案路徑包含在提示中發送給 Claude +4. Claude 讀取並分析圖片 + +**限制:** + +- LINE 不支援在單一訊息中同時發送文字 + 圖片,它們是獨立事件 +- 先發送圖片,再發送文字問題(如「What's this?」) +- 僅有圖片的訊息自動提示:「Describe the attached file(s)」 +- 圖片最大 10MB + +--- + +## 工具存取 + +Broker 以 `--allowedTools` 執行 `claude -p`,啟用即時功能: + +**預設啟用的工具:** + +- `WebSearch` — 搜尋網路(天氣、新聞、價格等) +- `WebFetch` — 抓取網頁 +- `Bash(curl:*)` — API 呼叫 +- `Bash(python3:*)` — 運算 +- `Read` — 讀取本地檔案和圖片 + +**透過環境變數自訂:** + +```bash +BROKER_ALLOWED_TOOLS="WebSearch,Read" ./start.sh line +``` + +--- + +## 頻率限制與忙碌防護 + +| 防護 | 行為 | 預設值 | +| ---- | ---- | ------ | +| **忙碌防護** | Claude 處理中時,新訊息回覆「⏳ Processing...」| 永遠啟用 | +| **頻率限制** | 每使用者的訊息間隔 | 5 秒(`RATE_LIMIT_MS=5000`)| + +兩者都使用 LINE Reply API(免費)— 不消耗 Push API 配額。 + +--- + +## 安全注意事項 + +1. **Webhook 簽章驗證** — 每個 webhook 都使用 Channel Secret 進行 HMAC-SHA256 驗證 +2. **Token 儲存** — Token 在 `.env`(gitignored),絕不作為指令參數傳遞 +3. **存取控制** — `access.json` 控制誰可以互動。`allowFrom` 為空 = 所有人允許 +4. **群組隔離** — 群組必須在 `access.json` 中明確 opt-in,未 opt-in 的群組被靜默忽略 +5. **無對話持久化** — 每個訊息產生獨立的 `claude -p` 呼叫,無聊天歷史 + +--- + +## Tunnel 注意事項 + +### ngrok 免費方案 + +- URL 每次重啟都會改變 — 必須每次更新 LINE console 的 webhook URL +- 已認證帳號(有 authtoken)不會顯示瀏覽器攔截頁面 +- 執行 `ngrok config add-authtoken ` 一次即可認證 + +### WSL2 + +- Tunnel 從 WSL2 運作正常(outbound 連線) +- Broker 綁定到 `0.0.0.0:3000` + +--- + +## 設定 + +| 變數 | 預設值 | 說明 | +| ---- | ------ | ---- | +| `LINE_CHANNEL_ACCESS_TOKEN` | (必要)| Channel Access Token | +| `LINE_CHANNEL_SECRET` | (必要)| Channel Secret(webhook 驗證)| +| `LINE_STATE_DIR` | `.claude/channels/line` | 狀態目錄 | +| `PORT` | `3000` | Webhook server port | +| `CLAUDE_BIN` | `claude` | claude CLI 路徑 | +| `BROKER_ALLOWED_TOOLS` | `WebSearch,WebFetch,...` | 逗號分隔的工具列表 | +| `BROKER_SYSTEM_PROMPT` | (內建)| Claude 的自訂系統提示 | +| `RATE_LIMIT_MS` | `5000` | 每使用者冷卻時間(毫秒)| + +--- + +## 注意事項與經驗教訓 + +1. **Webhook URL 必須以 `/webhook` 結尾** — Broker 只監聽 `/webhook` 路徑。在 LINE console 設定根 URL(沒有 `/webhook`)會導致 404,收不到訊息 +2. **Bot 預設不能加入群組** — 必須在 LINE 官方帳號設定中啟用「允許被加入群組」。否則 bot 被邀請後立即離開(log 顯示 `left group: Cxxxxxxx`) +3. **Group ID 必須在 access.json 中** — 即使啟用了群組聊天,群組訊息仍會被靜默忽略,除非 group ID 加入 `access.json` 的 `groups` +4. **必須停用自動回覆** — LINE 內建自動回覆會攔截訊息。在 Official Account Manager > 回應設定中停用 +5. **業種無法更改** — 建立 LINE 官方帳號時選擇正確的業種。建議 IT / 軟體 +6. **圖片和文字是獨立事件** — LINE 不支援合併的文字 + 圖片訊息。先發圖片,再發文字 +7. **Reply API 免費但 ~60 秒過期** — 若 Claude 處理超過 ~60 秒,replyToken 過期,退回 Push API(每月免費配額 500 則) +8. **ngrok URL 重啟會變** — 每次 ngrok 重啟都須更新 LINE console 的 webhook URL +9. **每訊息無狀態** — 每個訊息產生獨立的 `claude -p` 呼叫,訊息間無對話上下文 +10. **頻率限制** — 預設每使用者 5 秒冷卻。忙碌回覆使用 Reply API(免費) diff --git a/docs/line/line_developer_console_channels.png b/docs/line/line_developer_console_channels.png new file mode 100644 index 0000000..680c5f5 Binary files /dev/null and b/docs/line/line_developer_console_channels.png differ diff --git a/docs/line/plan.md b/docs/line/plan.md new file mode 100644 index 0000000..a0d88a8 --- /dev/null +++ b/docs/line/plan.md @@ -0,0 +1,250 @@ +# Claude Code Channels x LINE Integration Plan + +## Context + +Using the LINE Messaging API to connect a Claude Code session with a LINE Official Account, enabling bidirectional communication. Unlike Telegram/Discord/Slack which all use outbound connections, **LINE requires a public HTTPS webhook** — there is no polling API. + +**Architecture:** + +```text +LINE App (Mobile/Desktop) + | (LINE Platform, webhook POST to your server) +LINE Broker (Bun process, local HTTP server + tunnel) + | (subprocess: claude -p) +Claude CLI (stateless, per-message) +``` + +**Key difference:** LINE requires an **inbound webhook**, breaking the "no inbound ports" pattern used by other channels. A tunnel (ngrok, Cloudflare Tunnel) is needed for local development. + +--- + +## Prerequisites + +- [x] Bun runtime (see [prerequisites](../prerequisites.md)) +- [x] Claude Code v2.1.80+ +- [ ] LINE Official Account (via [LINE Developers Console](https://developers.line.biz/console/)) +- [ ] Channel Access Token (`LINE_CHANNEL_ACCESS_TOKEN`) +- [ ] Channel Secret (`LINE_CHANNEL_SECRET`) +- [ ] Public HTTPS URL for webhook (tunnel for local dev) + +--- + +## Implementation Steps + +### Phase 1: Create LINE Official Account + +1. Go to [LINE Developers Console](https://developers.line.biz/console/) +2. Log in or create a LINE account +3. Accept the LINE Developers Agreement +4. Create a **Provider** (your organization or personal name) +5. Create a **LINE Official Account** and enable **Messaging API** + - This automatically creates a Messaging API channel +6. In the channel settings: + - Copy **Channel Secret** (Basic settings tab) + - Issue a **Channel Access Token** (Messaging API tab > Issue) + +### Phase 2: Configure Webhook + +LINE requires a publicly accessible HTTPS endpoint. For local development: + +#### Option A: ngrok (recommended for dev) + +```bash +# Install ngrok +brew install ngrok # or: snap install ngrok + +# Start tunnel (after broker is running on port 3000) +ngrok http 3000 +# Copy the https://xxxx.ngrok-free.app URL +``` + +#### Option B: Cloudflare Tunnel + +```bash +cloudflared tunnel --url http://localhost:3000 +``` + +Set the webhook URL in LINE Developers Console: + +- Messaging API tab > Webhook settings +- Webhook URL: `https://xxxx.ngrok-free.app/webhook` +- Click **Verify** to test +- Enable **Use webhook** + +### Phase 3: Store Tokens + +Write tokens to project-level `.env` (gitignored): + +```bash +echo "LINE_CHANNEL_ACCESS_TOKEN=your-token" >> .env +echo "LINE_CHANNEL_SECRET=your-secret" >> .env +chmod 600 .env +``` + +> **Warning:** Do NOT pass tokens as command arguments. They leak into conversation history. See [docs/issues.md Issue #2](../issues.md). + +### Phase 4: Launch LINE Broker + +```bash +./start.sh line +``` + +The broker will: + +1. Start a local HTTP server on port 3000 (configurable) +2. Receive webhook events from LINE Platform +3. Verify webhook signature using Channel Secret +4. For each user message: + - Download any attached images/files + - Run `claude -p --output-format text ""` + - Reply using the Reply API (free) or Push API (if replyToken expired) + +### Phase 5: Set Up Access Control + +```bash +mkdir -p .claude/channels/line +cat > .claude/channels/line/access.json << 'EOF' +{ + "dmPolicy": "allowlist", + "allowFrom": ["YOUR_LINE_USER_ID"], + "groups": {}, + "pending": {} +} +EOF +``` + +LINE user IDs are opaque strings (e.g., `U1234567890abcdef...`). Find yours: + +- Send a message to the bot +- Check broker logs for the `userId` field + +### Phase 6: Verify + +1. **Basic test**: Send a text message to the bot on LINE, confirm Claude replies +2. **Image test**: Send an image, verify it's downloaded and analyzed +3. **Webhook verify**: Use LINE console's "Verify" button to confirm connectivity +4. **Tunnel stability**: Ensure ngrok/cloudflare tunnel stays connected + +--- + +## Reply API vs Push API + +LINE has two ways to send messages: + +| Aspect | Reply API | Push API | +| ------ | --------- | -------- | +| Cost | Free | Paid (monthly quota) | +| Trigger | Requires `replyToken` from webhook | Can send anytime with user ID | +| Limit | 1 reply per user event | 500/month (free tier) | +| Token validity | ~1 minute | N/A | +| Use case | Immediate response | Async / delayed response | + +**Strategy for the broker:** + +1. **Try Reply API first** — it's free and has the replyToken from the webhook event +2. **Fall back to Push API** — if the response takes longer than ~1 minute (replyToken expired) +3. **Rate awareness** — track monthly Push API usage to avoid overages + +--- + +## Expected MCP Tools (Broker) + +Since LINE will use the broker pattern (like Slack), these are implemented as broker features rather than MCP tools: + +| Feature | Implementation | Status | +| ------- | -------------- | ------ | +| Text reply | Reply API / Push API | Planned | +| Image download | GET `/v2/bot/message/{id}/content` | Planned | +| Sticker (receive) | Parse sticker event, log package/sticker ID | Planned | +| File download | Same content API as images | Planned | +| Rich reply | Flex Messages (optional, future) | Future | + +--- + +## Key Differences from Other Channels + +| Aspect | Telegram | Discord | Slack | LINE | +| ------ | -------- | ------- | ----- | ---- | +| Connection | Outbound polling | Outbound WebSocket | Outbound polling | **Inbound webhook** | +| Public URL needed | No | No | No | **Yes** | +| Tokens | 1 (Bot Token) | 1 (Bot Token) | 1 (Bot Token) | 2 (Access Token + Secret) | +| Integration | `--channels` plugin | `--channels` plugin | Broker (polling) | Broker (webhook) | +| Reply model | Async (send anytime) | Async (send anytime) | Async (send anytime) | Reply (free) + Push (paid) | +| Text limit | 4096 chars | 2000 chars | 4000 chars | 5000 chars | +| File limit | 50MB | 25MB | Varies | 10MB (images) | +| Message history | Not available | `fetch_messages` | `conversations.history` | Not available (no API) | +| Cost | Free | Free | Free | Free (Reply) / Paid (Push) | +| SDK | grammy | discord.js | Slack Web API | @line/bot-sdk | + +--- + +## Webhook Security + +LINE webhook events must be verified using the Channel Secret: + +```typescript +import { validateSignature } from '@line/bot-sdk' + +// X-Line-Signature header contains HMAC-SHA256 of body using Channel Secret +const isValid = validateSignature(body, channelSecret, signature) +``` + +This prevents spoofed webhook calls. The broker must reject requests with invalid signatures. + +--- + +## Important Notes + +1. **Webhook is mandatory** — LINE has no polling API. You must expose a public HTTPS URL. Use ngrok or Cloudflare Tunnel for local development +2. **WSL2 considerations** — Tunnels work from WSL2 since they make outbound connections. The local HTTP server binds to `0.0.0.0:3000` +3. **ReplyToken expires in ~1 minute** — If Claude takes longer to respond, the replyToken becomes invalid. Fall back to Push API +4. **Push API costs money** — Free tier: 500 messages/month. Production plans have higher limits. Reply API is always free +5. **No message history** — LINE Bot API has no equivalent to Discord's `fetch_messages` or Slack's `conversations.history`. Only real-time webhook events +6. **User IDs are channel-scoped** — Same LINE user has different IDs across different channels/providers +7. **Bot auto-reply should be disabled** — In LINE Official Account settings, disable "Auto-reply messages" to prevent conflicts with the broker + +--- + +## Broker Architecture (Proposed) + +```text +LINE Platform + | (HTTPS POST to webhook URL) + v +ngrok / Cloudflare Tunnel + | (forwards to localhost:3000) + v +LINE Broker (Bun HTTP server) + | 1. Verify X-Line-Signature + | 2. Parse webhook events + | 3. Download attachments + | 4. Spawn: claude -p "" + | 5. Reply via Reply API (or Push API fallback) + v +Claude CLI (stateless, per-message) +``` + +Unlike the Slack broker (which polls), the LINE broker is an **HTTP server** that receives webhook POSTs. The rest of the flow is identical: download files, run `claude -p`, send response. + +--- + +## Key Files + +| File | Purpose | +| ---- | ------- | +| `external_plugins/line-channel/broker.ts` | LINE webhook broker (planned) | +| `.env` | `LINE_CHANNEL_ACCESS_TOKEN`, `LINE_CHANNEL_SECRET` (gitignored) | +| `.claude/channels/line/access.json` | Access control (gitignored) | +| `.claude/channels/line/inbox/` | Downloaded attachments (gitignored) | +| `docs/line/plan.md` | This planning document | + +--- + +## References + +- [LINE Messaging API Overview](https://developers.line.biz/en/docs/messaging-api/overview/) +- [LINE Developers Console](https://developers.line.biz/console/) +- [LINE Bot SDK for Node.js](https://github.com/line/line-bot-sdk-nodejs) +- [Webhook Events](https://developers.line.biz/en/docs/messaging-api/receiving-messages/) +- [Send Messages](https://developers.line.biz/en/docs/messaging-api/sending-messages/) +- [Channel Access Tokens](https://developers.line.biz/en/docs/messaging-api/channel-access-tokens/) diff --git a/docs/line/plan.zh-tw.md b/docs/line/plan.zh-tw.md new file mode 100644 index 0000000..a1799db --- /dev/null +++ b/docs/line/plan.zh-tw.md @@ -0,0 +1,224 @@ +# Claude Code Channels x LINE 整合規劃 + +## 背景 + +使用 LINE Messaging API 將 Claude Code session 連接到 LINE 官方帳號,實現雙向通訊。與 Telegram/Discord/Slack 都使用 outbound 連線不同,**LINE 需要公開的 HTTPS webhook** — 沒有 polling API。 + +**架構:** + +```text +LINE App(手機/桌面) + | (LINE Platform,webhook POST 到你的伺服器) +LINE Broker (Bun 行程,本地 HTTP server + tunnel) + | (子行程:claude -p) +Claude CLI(無狀態,每訊息獨立) +``` + +**關鍵差異:** LINE 需要 **inbound webhook**,打破了其他 channel 使用的「不需要 inbound port」模式。本地開發需要 tunnel(ngrok、Cloudflare Tunnel)。 + +--- + +## 前置條件 + +- [x] Bun runtime(見[前置條件](../prerequisites.zh-tw.md)) +- [x] Claude Code v2.1.80+ +- [ ] LINE 官方帳號(透過 [LINE Developers Console](https://developers.line.biz/console/)) +- [ ] Channel Access Token(`LINE_CHANNEL_ACCESS_TOKEN`) +- [ ] Channel Secret(`LINE_CHANNEL_SECRET`) +- [ ] 公開的 HTTPS URL 供 webhook 使用(本地開發需 tunnel) + +--- + +## 實作步驟 + +### Phase 1: 建立 LINE 官方帳號 + +1. 前往 [LINE Developers Console](https://developers.line.biz/console/) +2. 登入或建立 LINE 帳號 +3. 接受 LINE Developers Agreement +4. 建立 **Provider**(你的組織或個人名稱) +5. 建立 **LINE 官方帳號**並啟用 **Messaging API** + - 這會自動建立 Messaging API channel +6. 在 channel 設定中: + - 複製 **Channel Secret**(Basic settings 分頁) + - 核發 **Channel Access Token**(Messaging API 分頁 > Issue) + +### Phase 2: 設定 Webhook + +LINE 需要可公開存取的 HTTPS endpoint。本地開發選項: + +#### 選項 A: ngrok(推薦用於開發) + +```bash +# 安裝 ngrok +brew install ngrok # 或: snap install ngrok + +# 啟動 tunnel(broker 在 port 3000 運行後) +ngrok http 3000 +# 複製 https://xxxx.ngrok-free.app URL +``` + +#### 選項 B: Cloudflare Tunnel + +```bash +cloudflared tunnel --url http://localhost:3000 +``` + +在 LINE Developers Console 設定 webhook URL: + +- Messaging API 分頁 > Webhook settings +- Webhook URL: `https://xxxx.ngrok-free.app/webhook` +- 點擊 **Verify** 測試 +- 啟用 **Use webhook** + +### Phase 3: 儲存 Token + +將 token 寫入專案級 `.env`(gitignored): + +```bash +echo "LINE_CHANNEL_ACCESS_TOKEN=your-token" >> .env +echo "LINE_CHANNEL_SECRET=your-secret" >> .env +chmod 600 .env +``` + +> **警告:** 不要將 token 作為指令參數傳遞。它們會洩漏到對話歷史中。見 [docs/issues.md Issue #2](../issues.md)。 + +### Phase 4: 啟動 LINE Broker + +```bash +./start.sh line +``` + +Broker 會: + +1. 在 port 3000(可設定)啟動本地 HTTP server +2. 從 LINE Platform 接收 webhook 事件 +3. 使用 Channel Secret 驗證 webhook 簽章 +4. 對每個使用者訊息: + - 下載附件(圖片/檔案) + - 執行 `claude -p --output-format text "<訊息>"` + - 使用 Reply API(免費)或 Push API(replyToken 過期時)回覆 + +### Phase 5: 設定存取控制 + +```bash +mkdir -p .claude/channels/line +cat > .claude/channels/line/access.json << 'EOF' +{ + "dmPolicy": "allowlist", + "allowFrom": ["YOUR_LINE_USER_ID"], + "groups": {}, + "pending": {} +} +EOF +``` + +LINE user ID 是不透明字串(如 `U1234567890abcdef...`)。找到你的 ID: + +- 向 bot 發送訊息 +- 檢查 broker 日誌中的 `userId` 欄位 + +### Phase 6: 驗證 + +1. **基本測試**:在 LINE 上向 bot 發送文字訊息,確認 Claude 回覆 +2. **圖片測試**:發送圖片,驗證被下載並分析 +3. **Webhook 驗證**:使用 LINE console 的「Verify」按鈕確認連通性 +4. **Tunnel 穩定性**:確保 ngrok/cloudflare tunnel 持續連線 + +--- + +## Reply API vs Push API + +LINE 有兩種發送訊息的方式: + +| 面向 | Reply API | Push API | +| ---- | --------- | -------- | +| 費用 | 免費 | 付費(月配額)| +| 觸發 | 需要 webhook 事件的 `replyToken` | 可隨時用 user ID 發送 | +| 限制 | 每個使用者事件 1 次回覆 | 500 則/月(免費方案)| +| Token 有效期 | ~1 分鐘 | 不適用 | +| 使用時機 | 即時回應 | 非同步/延遲回應 | + +**Broker 策略:** + +1. **優先使用 Reply API** — 免費且有 webhook 事件的 replyToken +2. **退回 Push API** — 若回應時間超過 ~1 分鐘(replyToken 過期) +3. **配額意識** — 追蹤每月 Push API 使用量以避免超額 + +--- + +## 預期功能(Broker) + +| 功能 | 實作方式 | 狀態 | +| ---- | -------- | ---- | +| 文字回覆 | Reply API / Push API | 規劃中 | +| 圖片下載 | GET `/v2/bot/message/{id}/content` | 規劃中 | +| 貼圖(接收)| 解析 sticker 事件 | 規劃中 | +| 檔案下載 | 同圖片的 content API | 規劃中 | +| 豐富回覆 | Flex Messages(選用,未來)| 未來 | + +--- + +## 與其他 Channel 的差異 + +| 面向 | Telegram | Discord | Slack | LINE | +| ---- | -------- | ------- | ----- | ---- | +| 連線方式 | Outbound polling | Outbound WebSocket | Outbound polling | **Inbound webhook** | +| 需要公開 URL | 否 | 否 | 否 | **是** | +| Token 數量 | 1(Bot Token)| 1(Bot Token)| 1(Bot Token)| 2(Access Token + Secret)| +| 整合方式 | `--channels` 插件 | `--channels` 插件 | Broker(polling)| Broker(webhook)| +| 回覆模式 | 非同步 | 非同步 | 非同步 | Reply(免費)+ Push(付費)| +| 文字限制 | 4096 字元 | 2000 字元 | 4000 字元 | 5000 字元 | +| 檔案限制 | 50MB | 25MB | 各異 | 10MB(圖片)| +| 訊息歷史 | 不可用 | `fetch_messages` | `conversations.history` | 不可用 | +| 費用 | 免費 | 免費 | 免費 | 免費(Reply)/ 付費(Push)| + +--- + +## Webhook 安全 + +LINE webhook 事件必須使用 Channel Secret 驗證: + +```typescript +import { validateSignature } from '@line/bot-sdk' + +// X-Line-Signature header 包含使用 Channel Secret 計算的 HMAC-SHA256 +const isValid = validateSignature(body, channelSecret, signature) +``` + +這可防止偽造的 webhook 呼叫。Broker 必須拒絕簽章無效的請求。 + +--- + +## 重要事項 + +1. **Webhook 是強制的** — LINE 沒有 polling API。必須公開 HTTPS URL。本地開發使用 ngrok 或 Cloudflare Tunnel +2. **WSL2 考量** — Tunnel 從 WSL2 運作正常(它們建立 outbound 連線)。本地 HTTP server 綁定到 `0.0.0.0:3000` +3. **ReplyToken 在 ~1 分鐘後過期** — 若 Claude 回應較久,replyToken 會失效。退回 Push API +4. **Push API 要收費** — 免費方案:500 則/月。正式環境方案有更高額度。Reply API 永遠免費 +5. **沒有訊息歷史** — LINE Bot API 沒有類似 Discord `fetch_messages` 或 Slack `conversations.history` 的功能 +6. **User ID 是 channel 層級** — 同一 LINE 使用者在不同 channel/provider 有不同 ID +7. **應停用 Bot 自動回覆** — 在 LINE 官方帳號設定中,停用「自動回覆訊息」以避免與 broker 衝突 + +--- + +## 關鍵檔案 + +| 檔案 | 用途 | +| ---- | ---- | +| `external_plugins/line-channel/broker.ts` | LINE webhook broker(規劃中)| +| `.env` | `LINE_CHANNEL_ACCESS_TOKEN`、`LINE_CHANNEL_SECRET`(gitignored)| +| `.claude/channels/line/access.json` | 存取控制(gitignored)| +| `.claude/channels/line/inbox/` | 下載的附件(gitignored)| +| `docs/line/plan.md` | 本規劃文件(英文版)| + +--- + +## 參考資料 + +- [LINE Messaging API 概觀](https://developers.line.biz/en/docs/messaging-api/overview/) +- [LINE Developers Console](https://developers.line.biz/console/) +- [LINE Bot SDK for Node.js](https://github.com/line/line-bot-sdk-nodejs) +- [Webhook Events](https://developers.line.biz/en/docs/messaging-api/receiving-messages/) +- [發送訊息](https://developers.line.biz/en/docs/messaging-api/sending-messages/) +- [Channel Access Tokens](https://developers.line.biz/en/docs/messaging-api/channel-access-tokens/) diff --git a/docs/screenshots/line/ask_flower.jpg b/docs/screenshots/line/ask_flower.jpg new file mode 100644 index 0000000..e4d4452 Binary files /dev/null and b/docs/screenshots/line/ask_flower.jpg differ diff --git a/docs/screenshots/line/ask_weather.jpg b/docs/screenshots/line/ask_weather.jpg new file mode 100644 index 0000000..a176aee Binary files /dev/null and b/docs/screenshots/line/ask_weather.jpg differ diff --git a/external_plugins/line-channel/broker.ts b/external_plugins/line-channel/broker.ts new file mode 100644 index 0000000..6c83353 --- /dev/null +++ b/external_plugins/line-channel/broker.ts @@ -0,0 +1,418 @@ +#!/usr/bin/env bun +/** + * LINE message broker for Claude Code. + * + * HTTP server receives LINE webhook events → pipes to `claude` CLI → replies. + * Uses Reply API (free) first, falls back to Push API if replyToken expired. + * + * Usage: + * bun run broker.ts + * PORT=3000 bun run broker.ts + */ + +import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'fs' +import { join, resolve, dirname } from 'path' +import { fileURLToPath } from 'url' +import { spawn } from 'child_process' +import { createHmac } from 'crypto' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PROJECT_DIR = resolve(__dirname, '..', '..') + +// ── Load .env ────────────────────────────────────────────── +function loadEnvFile(path: string): void { + try { + for (const line of readFileSync(path, 'utf8').split('\n')) { + const m = line.match(/^(\w+)=["']?([^"'\n]*)["']?$/) + if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2] + } + } catch {} +} + +const STATE_DIR = process.env.LINE_STATE_DIR + ?? resolve(PROJECT_DIR, '.claude/channels/line') +loadEnvFile(join(STATE_DIR, '.env')) +loadEnvFile(join(PROJECT_DIR, '.env')) + +const CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN +const CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET + +if (!CHANNEL_ACCESS_TOKEN) { + console.error('LINE_CHANNEL_ACCESS_TOKEN not found in .env') + process.exit(1) +} +if (!CHANNEL_SECRET) { + console.error('LINE_CHANNEL_SECRET not found in .env') + process.exit(1) +} + +const PORT = parseInt(process.env.PORT ?? '3000', 10) +const CLAUDE_BIN = process.env.CLAUDE_BIN ?? 'claude' +const INBOX_DIR = join(STATE_DIR, 'inbox') +const LOG_DIR = join(STATE_DIR, 'logs') +mkdirSync(INBOX_DIR, { recursive: true }) +mkdirSync(LOG_DIR, { recursive: true }) + +// ── Logging to file + console ────────────────────────────── +const logFile = join(LOG_DIR, `broker-${new Date().toISOString().slice(0, 10)}.log`) + +function log(msg: string): void { + const ts = new Date().toISOString() + const line = `${ts} ${msg}\n` + process.stdout.write(`[broker] ${msg}\n`) + appendFileSync(logFile, line) +} + +function logError(msg: string): void { + const ts = new Date().toISOString() + const line = `${ts} ERROR ${msg}\n` + process.stderr.write(`[broker] ${msg}\n`) + appendFileSync(logFile, line) +} + +// ── Webhook signature verification ───────────────────────── +function verifySignature(body: string, signature: string): boolean { + const hash = createHmac('SHA256', CHANNEL_SECRET!) + .update(body) + .digest('base64') + return hash === signature +} + +// ── LINE API helpers ─────────────────────────────────────── +async function lineReply(replyToken: string, messages: Array<{ type: string; text: string }>): Promise { + try { + const res = await fetch('https://api.line.me/v2/bot/message/reply', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${CHANNEL_ACCESS_TOKEN}`, + }, + body: JSON.stringify({ replyToken, messages }), + }) + return res.ok + } catch { + return false + } +} + +async function linePush(userId: string, messages: Array<{ type: string; text: string }>): Promise { + try { + const res = await fetch('https://api.line.me/v2/bot/message/push', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${CHANNEL_ACCESS_TOKEN}`, + }, + body: JSON.stringify({ to: userId, messages }), + }) + if (!res.ok) { + const err = await res.text() + logError(` push failed: ${err}`) + } + return res.ok + } catch (e) { + logError(` push error: ${e}`) + return false + } +} + +async function downloadContent(messageId: string, fileName: string): Promise { + const res = await fetch(`https://api-data.line.me/v2/bot/message/${messageId}/content`, { + headers: { 'Authorization': `Bearer ${CHANNEL_ACCESS_TOKEN}` }, + }) + if (!res.ok) throw new Error(`download failed: ${res.status}`) + const buf = Buffer.from(await res.arrayBuffer()) + const path = join(INBOX_DIR, `${Date.now()}-${fileName}`) + writeFileSync(path, buf) + return path +} + +// ── Access control ───────────────────────────────────────── +type AccessConfig = { + allowFrom: string[] + groups: Record +} + +function loadAccess(): AccessConfig { + try { + const data = JSON.parse(readFileSync(join(STATE_DIR, 'access.json'), 'utf8')) + return { + allowFrom: data.allowFrom ?? [], + groups: data.groups ?? {}, + } + } catch { + return { allowFrom: [], groups: {} } + } +} + +// ── Run claude CLI ───────────────────────────────────────── +function runClaude(prompt: string): Promise { + return new Promise((resolve, reject) => { + const allowedTools = (process.env.BROKER_ALLOWED_TOOLS + ?? 'WebSearch,WebFetch,Bash(curl:*),Bash(python3:*),Read') + .split(',') + const systemPrompt = process.env.BROKER_SYSTEM_PROMPT + ?? 'You are a helpful assistant responding to messages from LINE chat. You have access to tools including WebSearch, Bash, and Read. Use them proactively when the user asks about real-time information (weather, news, prices, etc.) or needs computation. IMPORTANT formatting rules for LINE chat: 1) NEVER use markdown tables (| col | col |) — they are unreadable on mobile. Use bullet points or numbered lists instead. 2) Keep responses concise — prefer short paragraphs. 3) Use plain text formatting, no markdown headers (#). 4) Use emoji sparingly for visual structure.' + const args = [ + '-p', + '--output-format', 'text', + '--system-prompt', systemPrompt, + '--allowedTools', ...allowedTools, + '--', + prompt, + ] + + const child = spawn(CLAUDE_BIN, args, { + cwd: PROJECT_DIR, + env: { ...process.env, PATH: process.env.PATH }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let stdout = '' + let stderr = '' + child.stdout.on('data', (d: Buffer) => { stdout += d.toString() }) + child.stderr.on('data', (d: Buffer) => { stderr += d.toString() }) + child.on('close', (code) => { + if (code !== 0) { + logError(` claude exit ${code}: ${stderr.slice(0, 200)}`) + reject(new Error(`claude exited with code ${code}`)) + } else { + resolve(stdout.trim()) + } + }) + child.on('error', reject) + + // Timeout: 5 minutes + setTimeout(() => { + child.kill('SIGTERM') + reject(new Error('claude timed out after 5 minutes')) + }, 5 * 60 * 1000) + }) +} + +// ── Chunk text for LINE ───────────────────────────────────── +// Smaller chunks (2000 chars) for better readability on mobile. +// Prefer splitting on paragraph boundaries (double newline). +const CHUNK_LIMIT = parseInt(process.env.CHUNK_LIMIT ?? '2000', 10) + +function chunk(text: string, limit = CHUNK_LIMIT): string[] { + if (text.length <= limit) return [text] + const out: string[] = [] + let rest = text + while (rest.length > limit) { + // Prefer paragraph break, then line break, then space + const para = rest.lastIndexOf('\n\n', limit) + const line = rest.lastIndexOf('\n', limit) + const space = rest.lastIndexOf(' ', limit) + const cut = para > limit / 3 ? para + : line > limit / 3 ? line + : space > 0 ? space + : limit + out.push(rest.slice(0, cut).trimEnd()) + rest = rest.slice(cut).replace(/^\n+/, '') + } + if (rest.trim()) out.push(rest.trim()) + return out +} + +// ── Rate limiting & busy guard ───────────────────────────── +let processing = false +const BUSY_MSG = '⏳ Processing previous request, please wait...' +const RATE_LIMIT_MS = parseInt(process.env.RATE_LIMIT_MS ?? '5000', 10) +const lastMessageTime: Record = {} // per-user cooldown + +async function processMessageEvent(event: any): Promise { + const sourceType = event.source?.type // 'user' (DM), 'group', 'room' + const userId = event.source?.userId + const groupId = event.source?.groupId ?? event.source?.roomId + const replyToken = event.replyToken + const messageType = event.message?.type + const messageId = event.message?.id + const text = event.message?.text ?? '' + + if (!userId) return + + // Access control — check user allowlist and group allowlist + const access = loadAccess() + const allowList = access.allowFrom ?? [] + const groups = access.groups ?? {} + + if (sourceType === 'group' || sourceType === 'room') { + // Group message — check if group is opted-in + if (groupId && !groups[groupId]) { + return // group not opted-in, silently ignore + } + // If group has per-user restriction, check it + const groupPolicy = groups[groupId] + if (groupPolicy?.allowFrom?.length > 0 && !groupPolicy.allowFrom.includes(userId)) { + return + } + log(`group:${groupId} ${userId}: [${messageType}] ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`) + } else { + // DM — check user allowlist + if (allowList.length > 0 && !allowList.includes(userId)) { + log(`blocked: ${userId} not in allowlist`) + return + } + log(`${userId}: [${messageType}] ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`) + } + + // Busy guard — reject if already processing another message + if (processing) { + log(`busy — rejecting message from ${userId}`) + await lineReply(replyToken, [{ type: 'text', text: BUSY_MSG }]) + return + } + + // Rate limit — per-user cooldown + const now = Date.now() + const lastTime = lastMessageTime[userId] ?? 0 + if (now - lastTime < RATE_LIMIT_MS) { + const waitSec = Math.ceil((RATE_LIMIT_MS - (now - lastTime)) / 1000) + log(`rate limited: ${userId} (wait ${waitSec}s)`) + await lineReply(replyToken, [{ type: 'text', text: `⏳ Please wait ${waitSec}s before sending another message.` }]) + return + } + lastMessageTime[userId] = now + + // Download images/files + const imageFiles: string[] = [] + if (messageType === 'image' && messageId) { + try { + const path = await downloadContent(messageId, `${messageId}.jpg`) + imageFiles.push(path) + log(` downloaded: ${path}`) + } catch (e) { + logError(` download failed: ${e}`) + } + } else if (messageType === 'file' && messageId) { + const fileName = event.message?.fileName ?? `${messageId}.bin` + try { + const path = await downloadContent(messageId, fileName) + imageFiles.push(path) + log(` downloaded: ${path}`) + } catch (e) { + logError(` download failed: ${e}`) + } + } else if (messageType === 'sticker') { + log(` sticker: pkg=${event.message?.packageId} stk=${event.message?.stickerId}`) + // Skip stickers — no meaningful content to process + return + } else if (messageType !== 'text') { + log(` skipping unsupported type: ${messageType}`) + return + } + + // Build prompt + let prompt = text || '' + if (imageFiles.length > 0) { + const fileList = imageFiles.map(f => ` - ${f}`).join('\n') + prompt = prompt + ? `${prompt}\n\nAttached files (use Read tool to view):\n${fileList}` + : `Describe the attached file(s):\n${fileList}` + } + + if (!prompt) return + + try { + processing = true + const response = await runClaude(prompt) + + // Send response — try Reply API first (free), fall back to Push API + const chunks = chunk(response) + + // LINE Reply API allows up to 5 messages per reply + const replyMessages = chunks.slice(0, 5).map(c => ({ type: 'text' as const, text: c })) + const replied = await lineReply(replyToken, replyMessages) + + if (!replied) { + // replyToken expired or failed — use Push API + log(` reply failed, falling back to push`) + for (const c of chunks) { + await linePush(userId, [{ type: 'text', text: c }]) + } + } else if (chunks.length > 5) { + // Reply sent first 5, push the rest + for (const c of chunks.slice(5)) { + await linePush(userId, [{ type: 'text', text: c }]) + } + } + + log(` responded (${chunks.length} chunk(s))`) + } catch (e) { + logError(` claude error: ${e}`) + const errMsg = `Error: ${e instanceof Error ? e.message : String(e)}` + const pushed = await lineReply(replyToken, [{ type: 'text', text: errMsg }]) + if (!pushed) { + await linePush(userId, [{ type: 'text', text: errMsg }]) + } + } finally { + processing = false + } +} + +// ── HTTP server (webhook receiver) ───────────────────────── +const server = Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url) + + // Health check + if (url.pathname === '/health' || url.pathname === '/') { + return new Response('ok') + } + + // Webhook endpoint + if (url.pathname === '/webhook' && req.method === 'POST') { + const body = await req.text() + const signature = req.headers.get('x-line-signature') ?? '' + + // Verify signature + if (!verifySignature(body, signature)) { + logError(' invalid signature — rejecting webhook') + return new Response('invalid signature', { status: 403 }) + } + + // Parse events + let data: any + try { + data = JSON.parse(body) + } catch { + return new Response('invalid json', { status: 400 }) + } + + // Respond 200 immediately (LINE requires quick ack) + // Process events asynchronously + const events = data.events ?? [] + for (const event of events) { + if (event.type === 'message') { + // Process async — don't block the webhook response + processMessageEvent(event).catch(e => { + logError(` event processing error: ${e}`) + }) + } else if (event.type === 'follow') { + log(`new follower: ${event.source?.userId}`) + } else if (event.type === 'unfollow') { + log(`unfollowed: ${event.source?.userId}`) + } else if (event.type === 'join') { + log(`joined group: ${event.source?.groupId ?? event.source?.roomId} (type: ${event.source?.type})`) + } else if (event.type === 'leave') { + log(`left group: ${event.source?.groupId ?? event.source?.roomId}`) + } else if (event.type === 'memberJoined') { + const members = event.joined?.members?.map((m: any) => m.userId).join(', ') ?? '?' + log(`member joined group ${event.source?.groupId}: ${members}`) + } + } + + return new Response('ok') + } + + return new Response('not found', { status: 404 }) + }, +}) + +log(` LINE webhook server running on port ${PORT}`) +log(` webhook URL: http://localhost:${PORT}/webhook`) +log(` project: ${PROJECT_DIR}`) +log(` state: ${STATE_DIR}`) +log(` Set your LINE webhook to: https:///webhook`) diff --git a/external_plugins/slack-channel/broker.ts b/external_plugins/slack-channel/broker.ts index e88e103..a5539fc 100644 --- a/external_plugins/slack-channel/broker.ts +++ b/external_plugins/slack-channel/broker.ts @@ -10,7 +10,7 @@ * POLL_INTERVAL=3 bun run broker.ts # poll every 3s (default: 5) */ -import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs' +import { readFileSync, writeFileSync, mkdirSync, chmodSync, appendFileSync } from 'fs' import { join, resolve, dirname } from 'path' import { fileURLToPath } from 'url' import { spawn } from 'child_process' @@ -42,7 +42,26 @@ if (!BOT_TOKEN) { const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL ?? '5', 10) * 1000 const CLAUDE_BIN = process.env.CLAUDE_BIN ?? 'claude' const INBOX_DIR = join(STATE_DIR, 'inbox') +const LOG_DIR = join(STATE_DIR, 'logs') mkdirSync(INBOX_DIR, { recursive: true }) +mkdirSync(LOG_DIR, { recursive: true }) + +// ── Logging to file + console ────────────────────────────── +const logFile = join(LOG_DIR, `broker-${new Date().toISOString().slice(0, 10)}.log`) + +function log(msg: string): void { + const ts = new Date().toISOString() + const line = `${ts} ${msg}\n` + process.stdout.write(`[broker] ${msg}\n`) + appendFileSync(logFile, line) +} + +function logError(msg: string): void { + const ts = new Date().toISOString() + const line = `${ts} ERROR ${msg}\n` + process.stderr.write(`[broker] ${msg}\n`) + appendFileSync(logFile, line) +} // ── Slack API helpers ────────────────────────────────────── // POST with JSON body (chat.postMessage, reactions.add, etc.) @@ -96,7 +115,7 @@ async function slackUpload(channelId: string, filePath: string, threadTs?: strin // ── Bot identity ─────────────────────────────────────────── const auth = await slack('auth.test') const BOT_USER_ID = auth.user_id -console.log(`[broker] connected as ${auth.user} (${BOT_USER_ID}) on ${auth.team}`) +log(` connected as ${auth.user} (${BOT_USER_ID}) on ${auth.team}`) // ── State: track last seen timestamp per channel ─────────── const CURSOR_FILE = join(STATE_DIR, 'broker_cursors.json') @@ -137,7 +156,19 @@ async function downloadFile(url: string, name: string): Promise { // ── Run claude CLI ───────────────────────────────────────── function runClaude(prompt: string): Promise { return new Promise((resolve, reject) => { - const args = ['-p', '--output-format', 'text', prompt] + const allowedTools = (process.env.BROKER_ALLOWED_TOOLS + ?? 'WebSearch,WebFetch,Bash(curl:*),Bash(python3:*),Read') + .split(',') + const systemPrompt = process.env.BROKER_SYSTEM_PROMPT + ?? 'You are a helpful assistant responding to messages from Slack chat. You have access to tools including WebSearch, Bash, and Read. Use them proactively when the user asks about real-time information (weather, news, prices, etc.) or needs computation. Respond concisely and directly. Use Slack mrkdwn formatting (*bold*, _italic_, bullet lists). Avoid markdown tables — use bullet points instead.' + const args = [ + '-p', + '--output-format', 'text', + '--system-prompt', systemPrompt, + '--allowedTools', ...allowedTools, + '--', + prompt, + ] const child = spawn(CLAUDE_BIN, args, { cwd: PROJECT_DIR, @@ -151,7 +182,7 @@ function runClaude(prompt: string): Promise { child.stderr.on('data', (d: Buffer) => { stderr += d.toString() }) child.on('close', (code) => { if (code !== 0) { - console.error(`[broker] claude exit ${code}: ${stderr.slice(0, 200)}`) + logError(` claude exit ${code}: ${stderr.slice(0, 200)}`) reject(new Error(`claude exited with code ${code}`)) } else { resolve(stdout.trim()) @@ -182,15 +213,33 @@ function chunk(text: string, limit = 3900): string[] { return out } -// ── Process a single message ─────────────────────────────── +// ── Rate limiting & busy guard ───────────────────────────── let processing = false +const RATE_LIMIT_MS = parseInt(process.env.RATE_LIMIT_MS ?? '5000', 10) +const lastMessageTime: Record = {} +// ── Process a single message ─────────────────────────────── async function processMessage(channelId: string, msg: any): Promise { const text = msg.text ?? '' const userId = msg.user const ts = msg.ts - console.log(`[broker] ${userId}: ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`) + log(`${userId}: ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`) + + // Rate limit — per-user cooldown + const now = Date.now() + const lastTime = lastMessageTime[userId] ?? 0 + if (now - lastTime < RATE_LIMIT_MS) { + const waitSec = Math.ceil((RATE_LIMIT_MS - (now - lastTime)) / 1000) + log(`rate limited: ${userId} (wait ${waitSec}s)`) + await slack('chat.postMessage', { + channel: channelId, + text: `⏳ Please wait ${waitSec}s before sending another message.`, + thread_ts: ts, + }).catch(() => {}) + return + } + lastMessageTime[userId] = now // React with eyes to ack await slack('reactions.add', { @@ -207,9 +256,9 @@ async function processMessage(channelId: string, msg: any): Promise { try { const localPath = await downloadFile(f.url_private, f.name ?? f.id) imageFiles.push(localPath) - console.log(`[broker] downloaded: ${localPath}`) + log(` downloaded: ${localPath}`) } catch (e) { - console.error(`[broker] download failed: ${e}`) + logError(` download failed: ${e}`) } } } @@ -244,7 +293,7 @@ async function processMessage(channelId: string, msg: any): Promise { name: 'white_check_mark', }).catch(() => {}) } catch (e) { - console.error(`[broker] claude error: ${e}`) + logError(` claude error: ${e}`) await slack('chat.postMessage', { channel: channelId, text: `Error: ${e instanceof Error ? e.message : String(e)}`, @@ -261,9 +310,9 @@ async function processMessage(channelId: string, msg: any): Promise { } // ── Poll loop ────────────────────────────────────────────── -console.log(`[broker] polling every ${POLL_INTERVAL / 1000}s — DM the bot on Slack`) -console.log(`[broker] project: ${PROJECT_DIR}`) -console.log(`[broker] state: ${STATE_DIR}`) +log(` polling every ${POLL_INTERVAL / 1000}s — DM the bot on Slack`) +log(` project: ${PROJECT_DIR}`) +log(` state: ${STATE_DIR}`) const cursors = loadCursors() @@ -305,7 +354,7 @@ async function poll(): Promise { .filter((m: any) => !m.bot_id && !m.subtype && m.user && m.user !== BOT_USER_ID) .reverse() // oldest first if (messages.length > 0) { - console.log(`[broker] ${channelId}: ${messages.length} new message(s)`) + log(` ${channelId}: ${messages.length} new message(s)`) } for (const msg of messages) { @@ -330,7 +379,7 @@ async function poll(): Promise { } } } catch (e) { - console.error(`[broker] poll error: ${e}`) + logError(` poll error: ${e}`) } } diff --git a/start.sh b/start.sh index f8f1d1e..9153912 100644 --- a/start.sh +++ b/start.sh @@ -13,6 +13,7 @@ declare -A CHANNEL_PLUGINS=( # Broker channels (standalone polling, no --channels needed) declare -A BROKER_CHANNELS=( [slack]="external_plugins/slack-channel/broker.ts" + [line]="external_plugins/line-channel/broker.ts" ) CHANNELS=("${@:-telegram}") diff --git a/tests/images/flower_pyrostegia_venusta.jpg b/tests/images/flower_pyrostegia_venusta.jpg new file mode 100644 index 0000000..10b40f0 Binary files /dev/null and b/tests/images/flower_pyrostegia_venusta.jpg differ