From 962af8a48b6cc3e67febf52971dc972a2c6f7673 Mon Sep 17 00:00:00 2001
From: LeeJaeYeon <0814jyinjs@com2us.com>
Date: Thu, 12 Feb 2026 14:23:11 +0900
Subject: [PATCH 1/3] line support
---
.env.example | 9 +
GUIDE.md | 349 ++++++++++++++++---
README.md | 125 ++++++-
package-lock.json | 314 +++++++++++++++++-
package.json | 1 +
src/bot.ts | 291 ++++++++--------
src/commands/ask.ts | 144 +-------
src/commands/cross/askCross.ts | 160 +++++++++
src/commands/cross/execCross.ts | 55 +++
src/commands/cross/helpCross.ts | 50 +++
src/commands/cross/sessionCross.ts | 434 ++++++++++++++++++++++++
src/commands/cross/statusCross.ts | 47 +++
src/commands/exec.ts | 42 +--
src/commands/help.ts | 87 +----
src/commands/session.ts | 515 +----------------------------
src/commands/status.ts | 58 +---
src/commands/task.ts | 5 +-
src/config.ts | 19 ++
src/lineBot.ts | 244 ++++++++++++++
src/platform/context.ts | 9 +
src/platform/discordAdapter.ts | 165 +++++++++
src/platform/index.ts | 14 +
src/platform/lineAdapter.ts | 258 +++++++++++++++
src/platform/types.ts | 53 +++
src/sessions/claude.ts | 61 +++-
src/sessions/gemini.ts | 61 ++--
src/sessions/multiSession.ts | 7 +-
src/sessions/subprocess.ts | 19 +-
src/slashCommands/askSlash.ts | 17 +-
src/slashCommands/taskSlash.ts | 14 +-
src/types.ts | 8 +-
src/utils/security.ts | 24 +-
32 files changed, 2607 insertions(+), 1052 deletions(-)
create mode 100644 src/commands/cross/askCross.ts
create mode 100644 src/commands/cross/execCross.ts
create mode 100644 src/commands/cross/helpCross.ts
create mode 100644 src/commands/cross/sessionCross.ts
create mode 100644 src/commands/cross/statusCross.ts
create mode 100644 src/lineBot.ts
create mode 100644 src/platform/context.ts
create mode 100644 src/platform/discordAdapter.ts
create mode 100644 src/platform/index.ts
create mode 100644 src/platform/lineAdapter.ts
create mode 100644 src/platform/types.ts
diff --git a/.env.example b/.env.example
index c4bdb83..5fb1b20 100644
--- a/.env.example
+++ b/.env.example
@@ -27,3 +27,12 @@ COMMAND_TIMEOUT=30
# [Optional] AI CLI timeout in seconds (default: 300 = 5min)
AI_CLI_TIMEOUT=300
+
+# ============================================================
+# LINE Messaging API (Optional — omit to run Discord only)
+# ============================================================
+# Get these from LINE Developers Console: https://developers.line.biz/
+# LINE_CHANNEL_ACCESS_TOKEN=
+# LINE_CHANNEL_SECRET=
+# LINE_WEBHOOK_PORT=3000
+# ALLOWED_LINE_USER_IDS=
diff --git a/GUIDE.md b/GUIDE.md
index 05f4583..f03b468 100644
--- a/GUIDE.md
+++ b/GUIDE.md
@@ -1,31 +1,34 @@
-# AIDevelop — Discord AI CLI Gateway Bot
+# AIDevelop — Discord / LINE AI CLI Gateway Bot
-Discord 메시지를 통해 내 Windows PC에서 실행 중인 AI CLI 도구(Claude Code, Gemini CLI, OpenCode)에
+Discord 또는 LINE 메시지를 통해 내 Windows PC에서 실행 중인 AI CLI 도구(Claude Code, Gemini CLI, OpenCode)에
명령을 전달하고 응답을 받아오는 봇입니다.
```
-[Discord 메시지] → [내 PC의 봇] → [AI CLI 도구에 전달] → [응답을 Discord로 반환]
+[Discord / LINE 메시지] → [내 PC의 봇] → [AI CLI 도구에 전달] → [응답을 Discord / LINE으로 반환]
```
-**Claude Code**는 Agent SDK를 통해 직접 통신하며, AI가 질문할 때 Discord 버튼/셀렉트 메뉴로 응답할 수 있습니다.
+**Claude Code**는 Agent SDK를 통해 직접 통신하며, AI가 질문할 때 Discord 버튼/셀렉트 메뉴 또는 LINE Quick Reply로 응답할 수 있습니다.
**Gemini CLI / OpenCode**는 subprocess 방식으로 동작합니다.
+> Discord만, LINE만, 또는 둘 다 동시에 사용할 수 있습니다. `.env` 설정에 따라 자동으로 결정됩니다.
+
---
## 목차
1. [사전 준비](#1-사전-준비)
2. [Discord 봇 생성](#2-discord-봇-생성)
-3. [프로젝트 설치](#3-프로젝트-설치)
-4. [환경 설정 (.env)](#4-환경-설정-env)
-5. [봇 실행](#5-봇-실행)
-6. [명령어 사용법](#6-명령어-사용법)
-7. [세션 관리](#7-세션-관리)
-8. [TUI 대화형 응답 (Claude 전용)](#8-tui-대화형-응답-claude-전용)
-9. [프로젝트 구조](#9-프로젝트-구조)
-10. [동작 원리](#10-동작-원리)
-11. [보안](#11-보안)
-12. [FAQ / 문제 해결](#12-faq--문제-해결)
+3. [LINE 봇 생성 (선택)](#3-line-봇-생성-선택)
+4. [프로젝트 설치](#4-프로젝트-설치)
+5. [환경 설정 (.env)](#5-환경-설정-env)
+6. [봇 실행](#6-봇-실행)
+7. [명령어 사용법](#7-명령어-사용법)
+8. [세션 관리](#8-세션-관리)
+9. [TUI 대화형 응답 (Claude 전용)](#9-tui-대화형-응답-claude-전용)
+10. [프로젝트 구조](#10-프로젝트-구조)
+11. [동작 원리](#11-동작-원리)
+12. [보안](#12-보안)
+13. [FAQ / 문제 해결](#13-faq--문제-해결)
---
@@ -109,7 +112,155 @@ opencode --version
---
-## 3. 프로젝트 설치
+## 3. LINE 봇 생성 (선택)
+
+LINE에서도 AI를 제어하고 싶다면 이 섹션을 따라 설정하세요. Discord만 사용한다면 건너뛰어도 됩니다.
+
+### 3-1. LINE Official Account 생성
+
+1. [LINE Official Account Manager](https://manager.line.biz/) 접속 → LINE 계정으로 로그인
+2. **새 계정 만들기** 클릭
+3. 계정 정보 입력:
+ - **계정 이름**: 봇 이름 (예: `AIDevelop Bot`)
+ - **카테고리**: 아무거나 선택 (예: `IT서비스`)
+4. **생성** 클릭
+
+> LINE Developers Console에서 직접 Messaging API 채널을 만들 수 없게 변경되었습니다.
+> 반드시 Official Account를 먼저 만든 뒤 Messaging API를 활성화해야 합니다.
+
+### 3-2. Messaging API 활성화
+
+1. [LINE Official Account Manager](https://manager.line.biz/) → 생성한 계정 선택
+2. 왼쪽 메뉴 **설정** → **Messaging API** 탭
+3. **Messaging API 사용** 클릭
+4. **제공자(Provider)** 선택:
+ - 기존 제공자가 있으면 선택
+ - 없으면 **새 제공자 만들기** → 이름 입력 (예: 본인 이름)
+5. 동의 후 활성화 완료
+
+### 3-3. 채널 정보 확인 (LINE Developers Console)
+
+1. [LINE Developers Console](https://developers.line.biz/console/) 접속
+2. 제공자 선택 → Messaging API 채널 클릭
+
+#### Channel Secret 복사
+- **Basic settings** 탭 → `Channel secret` → 복사
+
+#### Channel Access Token 발급
+- **Messaging API** 탭 → 하단 `Channel access token (long-lived)` → **Issue** 클릭 → 복사
+
+#### 내 LINE User ID 확인
+- **Basic settings** 탭 → `Your user ID` → 복사 (U로 시작하는 33자 문자열)
+
+### 3-4. 응답 설정 변경
+
+[LINE Official Account Manager](https://manager.line.biz/) → 계정 선택:
+
+1. **설정** → **응답 설정** (또는 **Response settings**)
+2. 아래와 같이 변경:
+
+| 항목 | 설정 |
+|------|------|
+| **응답 메시지** (자동 응답) | **끄기** |
+| **Webhook** | **켜기** |
+
+> 자동 응답이 켜져 있으면 봇 대신 기본 자동 응답 메시지가 전송되므로 반드시 꺼야 합니다.
+
+### 3-5. 웹훅 URL 설정
+
+LINE 봇은 **웹훅** 방식으로 동작합니다. LINE 서버가 사용자 메시지를 봇 서버의 URL로 전달하므로,
+봇이 실행되는 PC가 외부에서 접근 가능해야 합니다.
+
+#### 방법 A: Cloudflare Tunnel (무료, 로컬 테스트/개인 사용 권장)
+
+별도 서버 없이 내 PC의 로컬 서버를 인터넷에 노출할 수 있습니다.
+
+```bash
+# 1. Cloudflare Tunnel 설치 (한 번만)
+winget install cloudflare.cloudflared
+
+# 2. 터널 실행 (봇과 별도 터미널에서)
+cloudflared tunnel --url http://localhost:3000
+```
+
+실행 후 아래와 같은 URL이 표시됩니다:
+```
++--------------------------------------------------------------------------------------------+
+| Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
+| https://some-random-words.trycloudflare.com |
++--------------------------------------------------------------------------------------------+
+```
+
+이 URL을 복사해 LINE Developers Console에 등록합니다.
+
+> Cloudflare Tunnel의 무료 Quick Tunnel은 실행할 때마다 URL이 변경됩니다.
+> 봇을 재시작하면 LINE Developers Console에서 웹훅 URL도 업데이트해야 합니다.
+> 고정 URL이 필요하면 Cloudflare 계정으로 Named Tunnel을 사용하세요.
+
+#### 방법 B: 클라우드 서버 (운영 환경)
+
+고정 IP/도메인이 있는 서버(AWS, GCP, VPS 등)에서 봇을 실행하면 URL이 변경되지 않습니다.
+
+```
+https://your-domain.com/webhook
+```
+
+nginx 리버스 프록시 예시:
+```nginx
+server {
+ listen 443 ssl;
+ server_name your-domain.com;
+
+ location /webhook {
+ proxy_pass http://localhost:3000;
+ proxy_http_version 1.1;
+ }
+}
+```
+
+### 3-6. LINE Developers Console에 웹훅 등록
+
+1. [LINE Developers Console](https://developers.line.biz/console/) → 채널 → **Messaging API** 탭
+2. **Webhook URL** 입력:
+ - Cloudflare Tunnel: `https://some-random-words.trycloudflare.com/webhook`
+ - 자체 서버: `https://your-domain.com/webhook`
+3. **Update** 클릭
+4. **Use webhook** → 켜기 (이미 켜져 있을 수 있음)
+5. **Verify** 클릭 → `Success` 확인
+
+> Verify는 봇이 실행 중일 때만 성공합니다. 봇을 먼저 실행한 후 Verify하세요.
+
+### 3-7. 테스트
+
+봇 실행 후 LINE에서 봇 계정에 메시지를 보내 테스트합니다:
+
+```
+!ask 안녕하세요
+!status
+!help
+```
+
+콘솔에 `[LINE] Message from User: ...` 로그가 보이면 정상 동작입니다.
+
+### LINE 봇 구동 방식 정리
+
+```
+[LINE 사용자] → [LINE 서버] → [웹훅 POST] → [봇 (localhost:3000)]
+ │
+ [AI CLI 도구 실행]
+ │
+ [pushMessage API]
+ │
+ [LINE 서버] → [LINE 사용자]
+```
+
+- 사용자 메시지 수신: LINE 서버 → 웹훅 → 봇
+- 봇 응답 전송: 봇 → LINE Push Message API → 사용자
+- AI 처리 시간이 길어도 결과가 도착하면 자동으로 전송됩니다
+
+---
+
+## 4. 프로젝트 설치
### 방법 A: setup.bat 사용 (간편)
@@ -137,29 +288,52 @@ notepad .env
---
-## 4. 환경 설정 (.env)
+## 5. 환경 설정 (.env)
`.env` 파일을 열어 아래 값들을 설정합니다:
```ini
-# [필수] Discord 봇 토큰 (2단계에서 복사한 값)
+# ============================================================
+# Discord 설정 (Discord 사용 시 필수)
+# ============================================================
+
+# Discord 봇 토큰 (2단계에서 복사한 값)
DISCORD_BOT_TOKEN=여기에_봇_토큰_붙여넣기
-# [필수] 허가된 Discord 유저 ID (17-19자리 숫자, 여러 명이면 쉼표로 구분)
+# 허가된 Discord 유저 ID (17-19자리 숫자, 여러 명이면 쉼표로 구분)
# ⚠️ 반드시 숫자 ID를 사용하세요! (유저네임 ❌)
-# ⚠️ ID는 외부에 절대 노출하지 마세요!
ALLOWED_USER_IDS=123456789012345678
-# [선택] Anthropic API 키 (claude login 사용 시 불필요)
+# ============================================================
+# LINE 설정 (LINE 사용 시 필수 — 생략하면 Discord만 실행)
+# ============================================================
+
+# LINE 채널 액세스 토큰 (3단계에서 복사한 값)
+# LINE_CHANNEL_ACCESS_TOKEN=
+
+# LINE 채널 시크릿 (3단계에서 복사한 값)
+# LINE_CHANNEL_SECRET=
+
+# LINE 웹훅 서버 포트 (기본: 3000)
+# LINE_WEBHOOK_PORT=3000
+
+# 허가된 LINE 유저 ID (U로 시작하는 문자열, 쉼표 구분)
+# ALLOWED_LINE_USER_IDS=
+
+# ============================================================
+# 공통 설정 (선택)
+# ============================================================
+
+# Anthropic API 키 (claude login 사용 시 불필요)
# ANTHROPIC_API_KEY=sk-ant-xxxxx
-# [선택] 명령어 접두사 (기본: !)
+# 명령어 접두사 (기본: !)
COMMAND_PREFIX=!
-# [선택] CMD 명령 타임아웃 - 초 (기본: 30)
+# CMD 명령 타임아웃 - 초 (기본: 30)
COMMAND_TIMEOUT=30
-# [선택] AI CLI 타임아웃 - 초 (기본: 300 = 5분)
+# AI CLI 타임아웃 - 초 (기본: 300 = 5분)
AI_CLI_TIMEOUT=300
```
@@ -167,19 +341,35 @@ AI_CLI_TIMEOUT=300
| 변수 | 필수 | 기본값 | 설명 |
|------|:----:|--------|------|
-| `DISCORD_BOT_TOKEN` | O | — | Discord 봇 토큰 |
-| `ALLOWED_USER_IDS` | O | — | 봇 사용 허가 유저 ID (17-19자리 숫자, 쉼표 구분) |
+| `DISCORD_BOT_TOKEN` | ⚡ | — | Discord 봇 토큰 (Discord 사용 시) |
+| `ALLOWED_USER_IDS` | ⚡ | — | Discord 유저 ID (17-19자리 숫자, 쉼표 구분) |
+| `LINE_CHANNEL_ACCESS_TOKEN` | ⚡ | — | LINE 채널 액세스 토큰 (LINE 사용 시) |
+| `LINE_CHANNEL_SECRET` | ⚡ | — | LINE 채널 시크릿 (LINE 사용 시) |
+| `LINE_WEBHOOK_PORT` | | `3000` | LINE 웹훅 서버 포트 |
+| `ALLOWED_LINE_USER_IDS` | ⚡ | — | LINE 유저 ID (U로 시작, 쉼표 구분) |
| `ANTHROPIC_API_KEY` | | — | Anthropic API 키 (`claude login` 사용 시 불필요) |
| `COMMAND_PREFIX` | | `!` | 명령어 접두사 |
| `COMMAND_TIMEOUT` | | `30` | `!exec` 명령 타임아웃 (초) |
| `AI_CLI_TIMEOUT` | | `300` | AI CLI 응답 타임아웃 (초) |
+> ⚡ = 해당 플랫폼 사용 시 필수. Discord만 사용하면 LINE 설정 불필요, LINE만 사용하면 Discord 설정 불필요.
+
+### 플랫폼 구동 모드
+
+`.env` 설정에 따라 봇이 자동으로 구동 모드를 결정합니다:
+
+| Discord 설정 | LINE 설정 | 구동 모드 |
+|:---:|:---:|:---|
+| O | X | Discord만 실행 |
+| X | O | LINE만 실행 |
+| O | O | Discord + LINE 동시 실행 |
+
> ⚠️ **보안 주의**: Discord ID는 17-19자리 **숫자**만 사용해야 합니다 (유저네임 ❌).
> ID가 외부에 노출되면 스팸, 피싱 등의 타겟이 될 수 있으니 **절대 공개하지 마세요!**
---
-## 5. 봇 실행
+## 6. 봇 실행
### 방법 A: exe 파일 실행 (권장)
@@ -267,7 +457,7 @@ npx tsx src/bot.ts
---
-## 6. 명령어 사용법
+## 7. 명령어 사용법
### 전체 명령어 목록
@@ -317,7 +507,7 @@ npx tsx src/bot.ts
---
-## 7. 세션 관리
+## 8. 세션 관리
AI와의 대화는 **세션** 단위로 관리됩니다.
@@ -353,10 +543,10 @@ AI와의 대화는 **세션** 단위로 관리됩니다.
---
-## 8. TUI 대화형 응답 (Claude 전용)
+## 9. TUI 대화형 응답 (Claude 전용)
Claude Code를 사용할 때, AI가 사용자에게 질문을 하면 (예: "어떤 방식으로 할까요?")
-Discord에서 **버튼** 또는 **셀렉트 메뉴**로 선택지가 표시됩니다.
+Discord에서는 **버튼** 또는 **셀렉트 메뉴**로, LINE에서는 **Quick Reply**로 선택지가 표시됩니다.
### 동작 흐름
@@ -383,16 +573,23 @@ Discord에서 **버튼** 또는 **셀렉트 메뉴**로 선택지가 표시됩
최종 결과를 Discord에 전송
```
+**Discord:**
- 선택지가 **4개 이하**: 버튼으로 표시
- 선택지가 **5개 이상**: 셀렉트 메뉴(드롭다운)로 표시
-- **60초 타임아웃**: 시간 내에 선택하지 않으면 첫 번째 옵션이 자동 선택됩니다
- 선택 후 버튼이 비활성화되고 어떤 옵션을 선택했는지 표시됩니다
+**LINE:**
+- **Quick Reply** 버튼으로 표시 (최대 13개)
+- 선택지를 탭하면 해당 텍스트가 자동 전송됩니다
+
+**공통:**
+- **60초 타임아웃**: 시간 내에 선택하지 않으면 첫 번째 옵션이 자동 선택됩니다
+
> Gemini CLI / OpenCode는 이 기능을 지원하지 않습니다 (subprocess 방식).
---
-## 9. 프로젝트 구조
+## 10. 프로젝트 구조
```
C:\Osgood\AIDevelop\
@@ -405,33 +602,51 @@ C:\Osgood\AIDevelop\
├── .gitignore
│
└── src/
- ├── bot.ts # 엔트리포인트 (봇 시작, CLI/폴더 선택, 커맨드 디스패치)
- ├── config.ts # 설정 (.env 로딩, CLI 도구 정의)
+ ├── bot.ts # 엔트리포인트 (Discord/LINE 조건부 시작)
+ ├── config.ts # 설정 (.env 로딩, CLI/LINE 도구 정의)
├── types.ts # 타입 정의 (PrefixCommand, BotClient, ISessionManager)
+ ├── lineBot.ts # LINE 웹훅 서버 (HTTP + 커맨드 라우팅)
│
- ├── commands/ # Discord 명령어 모듈
- │ ├── ask.ts # !ask — AI CLI에 메시지 전달
- │ ├── session.ts # !session — 세션 관리 (info/new/kill)
- │ ├── exec.ts # !exec — CMD 명령 실행
- │ ├── status.ts # !status — 시스템 정보
- │ └── help.ts # !help — 도움말
+ ├── platform/ # 플랫폼 추상화 레이어
+ │ ├── types.ts # PlatformAdapter, PlatformMessage 등 인터페이스
+ │ ├── context.ts # CrossPlatformContext
+ │ ├── discordAdapter.ts # Discord 어댑터 (버튼, Embed, 타이핑 등)
+ │ ├── lineAdapter.ts # LINE 어댑터 (Flex Message, Quick Reply 등)
+ │ └── index.ts # 재export
+ │
+ ├── commands/ # Discord 명령어 모듈 (thin wrapper)
+ │ ├── ask.ts # !ask → askCross.ts 위임
+ │ ├── session.ts # !session → sessionCross.ts 위임
+ │ ├── exec.ts # !exec → execCross.ts 위임
+ │ ├── status.ts # !status → statusCross.ts 위임
+ │ ├── help.ts # !help → helpCross.ts 위임
+ │ ├── task.ts # !task — 작업 큐 관리
+ │ ├── myid.ts # !myid — Discord ID 확인
+ │ ├── diff.ts # !diff — Git diff 시각화
+ │ └── cross/ # 크로스 플랫폼 커맨드 (비즈니스 로직)
+ │ ├── askCross.ts # AI 질의 (플랫폼 무관)
+ │ ├── sessionCross.ts # 세션 관리 (플랫폼 무관)
+ │ ├── execCross.ts # 셸 실행 (플랫폼 무관)
+ │ ├── statusCross.ts # 시스템 정보 (플랫폼 무관)
+ │ └── helpCross.ts # 도움말 (플랫폼 무관)
│
├── sessions/ # AI CLI 세션 관리
- │ ├── types.ts # ISessionManager 인터페이스
│ ├── claude.ts # Claude Agent SDK 세션 (TUI 대응)
- │ └── subprocess.ts # Gemini/OpenCode subprocess 세션
+ │ ├── gemini.ts # Gemini CLI 세션 (stream JSON)
+ │ ├── subprocess.ts # OpenCode subprocess 세션
+ │ └── multiSession.ts # 멀티세션 매니저
│
└── utils/ # 유틸리티
├── discordPrompt.ts # AskUserQuestion → Discord 버튼/셀렉트 메뉴
├── formatter.ts # Discord 출력 포맷 (2000자 제한 처리)
- ├── security.ts # 유저 화이트리스트, 명령어 블랙리스트
+ ├── security.ts # 유저 화이트리스트 (Discord + LINE)
├── subprocess.ts # 비동기 subprocess 래퍼 (!exec 용)
└── typing.ts # 타이핑 인디케이터 헬퍼
```
---
-## 10. 동작 원리
+## 11. 동작 원리
### Claude Code 흐름 (Agent SDK)
@@ -502,7 +717,7 @@ C:\Osgood\AIDevelop\
|---|---|---|---|
| **통신** | Agent SDK (`query()`) | subprocess | subprocess |
| **세션 재개** | O (`resume`) | X | X |
-| **TUI 질문 대응** | O (Discord 버튼) | X | X |
+| **TUI 질문 대응** | O (Discord 버튼 / LINE Quick Reply) | X | X |
| **권한 자동 승인** | `bypassPermissions` | `--yolo` 플래그 | — |
| **출력 형식** | SDK 메시지 스트림 | plain text | plain text |
@@ -518,13 +733,14 @@ C:\Osgood\AIDevelop\
---
-## 11. 보안
+## 12. 보안
### 3단계 보안 구조
```
-[1층] Discord 유저 화이트리스트
- └─ ALLOWED_USER_IDS에 등록된 사용자만 명령 가능
+[1층] 플랫폼별 유저 화이트리스트
+ ├─ Discord: ALLOWED_USER_IDS에 등록된 사용자만 명령 가능
+ └─ LINE: ALLOWED_LINE_USER_IDS에 등록된 사용자만 명령 가능 + 웹훅 서명 검증
[2층] 명령어 블랙리스트 (!exec 전용)
└─ format, diskpart, shutdown 등 위험 명령 차단
@@ -554,7 +770,7 @@ net user, net localgroup
---
-## 12. FAQ / 문제 해결
+## 13. FAQ / 문제 해결
### Q: exe 실행 시 "cli.js 파일을 찾을 수 없습니다" 에러
- `cli.js` 파일이 exe와 **같은 폴더**에 있어야 합니다
@@ -631,6 +847,41 @@ net user, net localgroup
- 현재 1개의 세션만 지원합니다 (모든 유저가 같은 대화를 공유)
- 여러 유저의 메시지가 같은 AI 대화에 섞일 수 있습니다
+### Q: LINE 봇이 메시지에 반응하지 않아요
+- `.env`에 `LINE_CHANNEL_ACCESS_TOKEN`, `LINE_CHANNEL_SECRET`이 올바른지 확인
+- `ALLOWED_LINE_USER_IDS`에 본인 LINE 유저 ID가 등록되어 있는지 확인
+- LINE Official Account Manager에서 **응답 메시지(자동 응답)가 꺼져 있는지** 확인
+- LINE Official Account Manager에서 **Webhook이 켜져 있는지** 확인
+- 봇 콘솔에 `[LINE] Webhook server listening on port 3000` 메시지가 있는지 확인
+
+### Q: LINE 웹훅 Verify가 실패해요
+- 봇이 실행 중인지 확인 (`npx tsx src/bot.ts`)
+- Cloudflare Tunnel을 사용하는 경우 터널도 실행 중인지 확인
+- 웹훅 URL이 `/webhook`으로 끝나는지 확인 (예: `https://xxx.trycloudflare.com/webhook`)
+- `LINE_WEBHOOK_PORT`와 Cloudflare Tunnel의 포트가 일치하는지 확인
+
+### Q: LINE에서 AI 응답이 너무 늦게 와요
+- LINE은 웹훅 방식이라 즉시 "처리 중..." 메시지를 보내고, AI 결과는 나중에 Push Message로 전송합니다
+- AI 처리 시간 자체를 줄이려면 `AI_CLI_TIMEOUT` 설정을 확인하세요
+
+### Q: LINE에서 이미지나 파일을 받을 수 있나요?
+- 긴 텍스트 결과는 자동으로 여러 메시지로 분할되어 전송됩니다
+- LINE은 Discord와 달리 파일 첨부가 제한적이므로, 긴 결과는 텍스트로 전송됩니다
+
+### Q: Discord와 LINE을 동시에 사용할 수 있나요?
+- 네. `.env`에 Discord 설정과 LINE 설정을 모두 입력하면 동시에 실행됩니다
+- 콘솔에 `[platform] Active: Discord + LINE`으로 표시됩니다
+- 세션은 플랫폼 간 공유됩니다 (같은 AI 세션에 Discord와 LINE에서 메시지를 보낼 수 있음)
+
+### Q: Cloudflare Tunnel URL이 매번 바뀌어요
+- 무료 Quick Tunnel은 실행할 때마다 URL이 변경됩니다
+- 고정 URL이 필요하면 Cloudflare 계정을 만들고 Named Tunnel을 사용하세요
+- 또는 고정 IP가 있는 서버에서 봇을 실행하세요
+
+### Q: exe 배포 시 LINE도 바로 사용할 수 있나요?
+- 네. exe 사용자도 `.env`에 LINE 설정만 추가하면 LINE 봇이 활성화됩니다
+- LINE 웹훅 URL 설정과 Cloudflare Tunnel (또는 서버)은 별도로 필요합니다
+
---
# 개발자 가이드
diff --git a/README.md b/README.md
index cc79020..62cdbdc 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
### 잠든 사이에도 AI가 코딩한다
-Discord에서 PC의 AI CLI 도구를 원격 제어하세요
+Discord / LINE에서 PC의 AI CLI 도구를 원격 제어하세요
[](../../releases/latest)
[](LICENSE)
@@ -39,13 +39,13 @@ Discord에서 PC의 AI CLI 도구를 원격 제어하세요
-**Discord로 어디서든 AI와 코딩하세요** 🚀
+**Discord / LINE으로 어디서든 AI와 코딩하세요** 🚀
[⬆ 맨 위로](#ai-cli-gateway-bot)
diff --git a/package-lock.json b/package-lock.json
index 56f072f..0ff96b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.1",
+ "@line/bot-sdk": "^10.6.0",
"diff2html": "^3.4.56",
"discord.js": "^14.16.3",
"dotenv": "^16.4.7",
@@ -1024,6 +1025,36 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@line/bot-sdk": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/@line/bot-sdk/-/bot-sdk-10.6.0.tgz",
+ "integrity": "sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/node": "^24.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "axios": "^1.7.4"
+ }
+ },
+ "node_modules/@line/bot-sdk/node_modules/@types/node": {
+ "version": "24.10.13",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
+ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@line/bot-sdk/node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1809,6 +1840,13 @@
"node": ">=4"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
@@ -1819,6 +1857,18 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/axios": {
+ "version": "1.13.5",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
+ "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/b4a": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
@@ -2038,6 +2088,20 @@
"node": ">=8"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2150,6 +2214,19 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -2259,6 +2336,16 @@
"node": ">= 14"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2273,7 +2360,8 @@
"version": "0.0.1566079",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz",
"integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==",
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "peer": true
},
"node_modules/diff": {
"version": "8.0.3",
@@ -2361,6 +2449,21 @@
"url": "https://dotenvx.com"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -2394,6 +2497,26 @@
"is-arrayish": "^0.2.1"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -2401,6 +2524,35 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -2642,6 +2794,44 @@
"node": ">=8"
}
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/from2": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz",
@@ -2695,7 +2885,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -2710,6 +2900,45 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -2793,6 +3022,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2820,11 +3062,40 @@
"node": ">=8"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -3135,6 +3406,16 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3172,6 +3453,29 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@ -3493,6 +3797,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -4353,6 +4658,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -4392,6 +4698,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4438,6 +4745,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
diff --git a/package.json b/package.json
index d6ac34a..63a52c2 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.1",
+ "@line/bot-sdk": "^10.6.0",
"diff2html": "^3.4.56",
"discord.js": "^14.16.3",
"dotenv": "^16.4.7",
diff --git a/src/bot.ts b/src/bot.ts
index 85a0f94..f00f9c6 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -13,7 +13,9 @@ import {
AUDIT_LOG_DIR, AUDIT_LOG_ENABLED,
RATE_LIMIT_MAX, RATE_LIMIT_WINDOW,
APPLICATION_ID, SLASH_COMMAND_GUILD_ID,
+ LINE_CHANNEL_ACCESS_TOKEN, LINE_CHANNEL_SECRET,
} from "./config.js";
+import { LineBotServer } from "./lineBot.js";
import type { BotClient, PrefixCommand, CommandContext } from "./types.js";
import type { ISessionManager } from "./sessions/types.js";
import { ClaudeSessionManager } from "./sessions/claude.js";
@@ -288,34 +290,23 @@ export function createSession(cliName: string, cwd: string): ISessionManager {
async function main(): Promise
{
await setupEnv();
- if (!DISCORD_BOT_TOKEN) {
- console.error("DISCORD_BOT_TOKEN is not set. Check your .env file.");
+ const hasDiscord = !!DISCORD_BOT_TOKEN;
+ const hasLine = !!(LINE_CHANNEL_ACCESS_TOKEN && LINE_CHANNEL_SECRET);
+
+ if (!hasDiscord && !hasLine) {
+ console.error("No platform configured. Set DISCORD_BOT_TOKEN and/or LINE_CHANNEL_ACCESS_TOKEN + LINE_CHANNEL_SECRET in .env.");
process.exit(1);
}
- if (ALLOWED_USER_IDS.size === 0) {
+ if (hasDiscord && ALLOWED_USER_IDS.size === 0) {
console.warn();
console.warn(" [WARNING] ALLOWED_USER_IDS is empty!");
- console.warn(" All commands will be denied until user IDs are configured in .env.");
+ console.warn(" All Discord commands will be denied until user IDs are configured in .env.");
console.warn();
}
const { cliName, workingDir } = await startupSetup();
- const client = new Client({
- intents: [
- GatewayIntentBits.Guilds,
- GatewayIntentBits.GuildMessages,
- GatewayIntentBits.MessageContent,
- ],
- }) as BotClient;
-
- client.commands = new Collection();
- client.aliases = new Collection();
- client.slashCommands = buildSlashCollection();
- client.selectedCli = cliName;
- client.workingDir = workingDir;
-
// Initialize audit logger
if (AUDIT_LOG_ENABLED) {
initAuditLogger(path.resolve(workingDir, AUDIT_LOG_DIR));
@@ -340,131 +331,152 @@ async function main(): Promise {
console.log(` [persist] Restored ${restored} session(s) from disk`);
}
- // Load commands
- loadCommands(client);
-
- // ── Events ──
+ // ── Discord setup (conditional) ──
+ let client: BotClient | null = null;
+ let lineBot: LineBotServer | null = null;
+
+ if (hasDiscord) {
+ client = new Client({
+ intents: [
+ GatewayIntentBits.Guilds,
+ GatewayIntentBits.GuildMessages,
+ GatewayIntentBits.MessageContent,
+ ],
+ }) as BotClient;
+
+ client.commands = new Collection();
+ client.aliases = new Collection();
+ client.slashCommands = buildSlashCollection();
+ client.selectedCli = cliName;
+ client.workingDir = workingDir;
+
+ // Load commands
+ loadCommands(client);
+
+ // ── Discord Events ──
+
+ client.on(Events.ClientReady, async () => {
+ const tool = CLI_TOOLS[cliName];
+ const folder = path.basename(workingDir);
+ console.log(` [Discord] Logged in as ${client!.user?.tag}`);
+ console.log(` [Discord] Active CLI: ${tool.name} | CWD: ${workingDir}`);
+ client!.user?.setActivity(`${tool.name} @ ${folder}`, {
+ type: ActivityType.Listening,
+ });
- client.on(Events.ClientReady, async () => {
- const tool = CLI_TOOLS[cliName];
- const folder = path.basename(workingDir);
- console.log(` Logged in as ${client.user?.tag}`);
- console.log(` Active CLI: ${tool.name} | CWD: ${workingDir}`);
- client.user?.setActivity(`${tool.name} @ ${folder}`, {
- type: ActivityType.Listening,
+ // Register slash commands if APPLICATION_ID is set
+ if (APPLICATION_ID) {
+ try {
+ await registerSlashCommands(
+ APPLICATION_ID,
+ DISCORD_BOT_TOKEN,
+ SLASH_COMMAND_GUILD_ID || undefined,
+ );
+ } catch (err: any) {
+ console.error(` [slash] Failed to register: ${err.message}`);
+ }
+ }
});
- // Register slash commands if APPLICATION_ID is set
- if (APPLICATION_ID) {
- try {
- await registerSlashCommands(
- APPLICATION_ID,
- DISCORD_BOT_TOKEN,
- SLASH_COMMAND_GUILD_ID || undefined,
- );
- } catch (err: any) {
- console.error(` [slash] Failed to register: ${err.message}`);
+ client.on(Events.MessageCreate, async (message) => {
+ if (message.author.bot) return;
+ if (!message.content.startsWith(COMMAND_PREFIX)) return;
+
+ const args = message.content.slice(COMMAND_PREFIX.length).trim().split(/\s+/);
+ const cmdName = args.shift()?.toLowerCase();
+ if (!cmdName) return;
+
+ const cmd =
+ client!.commands.get(cmdName) ??
+ client!.commands.get(client!.aliases.get(cmdName) ?? "");
+ if (!cmd) return;
+
+ // Rate limiting
+ const rateLimiter = getRateLimiter();
+ if (rateLimiter) {
+ const { allowed, retryAfterMs } = rateLimiter.tryConsume(message.author.id);
+ if (!allowed) {
+ const secs = Math.ceil(retryAfterMs / 1000);
+ audit(AuditEvent.RATE_LIMITED, message.author.id, {
+ command: cmdName,
+ success: false,
+ });
+ await message.reply(`Rate limited. Try again in ${secs}s.`).catch(() => {});
+ return;
+ }
}
- }
- });
- client.on(Events.MessageCreate, async (message) => {
- if (message.author.bot) return;
- if (!message.content.startsWith(COMMAND_PREFIX)) return;
-
- const args = message.content.slice(COMMAND_PREFIX.length).trim().split(/\s+/);
- const cmdName = args.shift()?.toLowerCase();
- if (!cmdName) return;
-
- const cmd =
- client.commands.get(cmdName) ??
- client.commands.get(client.aliases.get(cmdName) ?? "");
- if (!cmd) return;
-
- // Rate limiting
- const rateLimiter = getRateLimiter();
- if (rateLimiter) {
- const { allowed, retryAfterMs } = rateLimiter.tryConsume(message.author.id);
- if (!allowed) {
- const secs = Math.ceil(retryAfterMs / 1000);
- audit(AuditEvent.RATE_LIMITED, message.author.id, {
+ // Audit: command executed
+ audit(AuditEvent.COMMAND_EXECUTED, message.author.id, { command: cmdName });
+
+ const ctx: CommandContext = { message, args, client: client! };
+
+ try {
+ await cmd.execute(ctx);
+ } catch (err: any) {
+ console.error(`[error] Command ${cmdName}:`, err);
+ audit(AuditEvent.COMMAND_ERROR, message.author.id, {
command: cmdName,
success: false,
+ details: { error: String(err.message ?? err) },
});
- await message.reply(`Rate limited. Try again in ${secs}s.`).catch(() => {});
- return;
+ const safeMsg = sanitizeOutput(String(err.message ?? err));
+ await message.reply(`An error occurred: \`${safeMsg}\``).catch(() => {});
}
- }
-
- // Audit: command executed
- audit(AuditEvent.COMMAND_EXECUTED, message.author.id, { command: cmdName });
+ });
- const ctx: CommandContext = { message, args, client };
+ // ── Slash command handler ──
+ client.on(Events.InteractionCreate, async (interaction) => {
+ if (!interaction.isChatInputCommand()) return;
+
+ const slashCmd = client!.slashCommands.get(interaction.commandName) as SlashCommand | undefined;
+ if (!slashCmd) return;
+
+ // Rate limiting for slash commands
+ const rateLimiter = getRateLimiter();
+ if (rateLimiter) {
+ const { allowed, retryAfterMs } = rateLimiter.tryConsume(interaction.user.id);
+ if (!allowed) {
+ const secs = Math.ceil(retryAfterMs / 1000);
+ audit(AuditEvent.RATE_LIMITED, interaction.user.id, {
+ command: interaction.commandName,
+ success: false,
+ });
+ await interaction.reply({
+ content: `Rate limited. Try again in ${secs}s.`,
+ ephemeral: true,
+ }).catch(() => {});
+ return;
+ }
+ }
- try {
- await cmd.execute(ctx);
- } catch (err: any) {
- console.error(`[error] Command ${cmdName}:`, err);
- audit(AuditEvent.COMMAND_ERROR, message.author.id, {
- command: cmdName,
- success: false,
- details: { error: String(err.message ?? err) },
+ audit(AuditEvent.COMMAND_EXECUTED, interaction.user.id, {
+ command: `/${interaction.commandName}`,
});
- const safeMsg = sanitizeOutput(String(err.message ?? err));
- await message.reply(`An error occurred: \`${safeMsg}\``).catch(() => {});
- }
- });
- // ── Slash command handler ──
- client.on(Events.InteractionCreate, async (interaction) => {
- if (!interaction.isChatInputCommand()) return;
-
- const slashCmd = client.slashCommands.get(interaction.commandName) as SlashCommand | undefined;
- if (!slashCmd) return;
-
- // Rate limiting for slash commands
- const rateLimiter = getRateLimiter();
- if (rateLimiter) {
- const { allowed, retryAfterMs } = rateLimiter.tryConsume(interaction.user.id);
- if (!allowed) {
- const secs = Math.ceil(retryAfterMs / 1000);
- audit(AuditEvent.RATE_LIMITED, interaction.user.id, {
- command: interaction.commandName,
+ try {
+ await slashCmd.execute(interaction, client!);
+ } catch (err: any) {
+ console.error(`[error] Slash /${interaction.commandName}:`, err);
+ audit(AuditEvent.COMMAND_ERROR, interaction.user.id, {
+ command: `/${interaction.commandName}`,
success: false,
+ details: { error: String(err.message ?? err) },
});
- await interaction.reply({
- content: `Rate limited. Try again in ${secs}s.`,
- ephemeral: true,
- }).catch(() => {});
- return;
+ const content = `An error occurred: \`${String(err.message ?? err).slice(0, 100)}\``;
+ if (interaction.replied || interaction.deferred) {
+ await interaction.editReply(content).catch(() => {});
+ } else {
+ await interaction.reply({ content, ephemeral: true }).catch(() => {});
+ }
}
- }
-
- audit(AuditEvent.COMMAND_EXECUTED, interaction.user.id, {
- command: `/${interaction.commandName}`,
});
-
- try {
- await slashCmd.execute(interaction, client);
- } catch (err: any) {
- console.error(`[error] Slash /${interaction.commandName}:`, err);
- audit(AuditEvent.COMMAND_ERROR, interaction.user.id, {
- command: `/${interaction.commandName}`,
- success: false,
- details: { error: String(err.message ?? err) },
- });
- const content = `An error occurred: \`${String(err.message ?? err).slice(0, 100)}\``;
- if (interaction.replied || interaction.deferred) {
- await interaction.editReply(content).catch(() => {});
- } else {
- await interaction.reply({ content, ephemeral: true }).catch(() => {});
- }
- }
- });
+ }
// Graceful shutdown - cleanup all sessions
const shutdown = async () => {
console.log("\n Shutting down...");
+ if (lineBot) lineBot.stop();
const multiSessionMgr = getMultiSessionManager();
if (multiSessionMgr) {
multiSessionMgr.persistToDisk();
@@ -473,22 +485,41 @@ async function main(): Promise {
const logger = getAuditLogger();
if (logger) await logger.shutdown();
await closeBrowser(); // Close puppeteer browser
- client.destroy();
+ if (client) client.destroy();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Pre-initialize Puppeteer (Chromium download) — non-blocking
- try {
- await initializePuppeteer();
- console.log(" [puppeteer] Chromium ready");
- } catch (err: any) {
- console.warn(` [puppeteer] Chromium init failed: ${err.message}`);
- console.warn(" [puppeteer] !diff will fall back to text mode");
+ if (hasDiscord) {
+ try {
+ await initializePuppeteer();
+ console.log(" [puppeteer] Chromium ready");
+ } catch (err: any) {
+ console.warn(` [puppeteer] Chromium init failed: ${err.message}`);
+ console.warn(" [puppeteer] !diff will fall back to text mode");
+ }
+ }
+
+ // ── Start platforms ──
+
+ // Start LINE webhook server if configured
+ if (hasLine) {
+ lineBot = new LineBotServer(cliName, workingDir);
+ await lineBot.start();
+ }
+
+ // Start Discord if configured
+ if (hasDiscord && client) {
+ await client.login(DISCORD_BOT_TOKEN);
}
- await client.login(DISCORD_BOT_TOKEN);
+ // Log platform status
+ const platforms: string[] = [];
+ if (hasDiscord) platforms.push("Discord");
+ if (hasLine) platforms.push("LINE");
+ console.log(` [platform] Active: ${platforms.join(" + ")}`);
}
main().catch((err) => {
diff --git a/src/commands/ask.ts b/src/commands/ask.ts
index fc2ecc8..ace8594 100644
--- a/src/commands/ask.ts
+++ b/src/commands/ask.ts
@@ -1,42 +1,6 @@
-import path from "node:path";
import type { PrefixCommand, CommandContext } from "../types.js";
-import { CLI_TOOLS } from "../config.js";
-import { isAllowedUser } from "../utils/security.js";
-import { sendResult } from "../utils/formatter.js";
-import { withTyping } from "../utils/typing.js";
-import { getMultiSessionManager } from "../sessions/multiSession.js";
-import { checkPromptInjection } from "../utils/promptGuard.js";
-import { audit, AuditEvent } from "../utils/auditLog.js";
-
-/**
- * 명령어 인자에서 세션 이름과 메시지 분리
- * !a work "메시지" → { sessionName: "work", message: "메시지" }
- * !a "메시지" → { sessionName: null, message: "메시지" }
- */
-function parseAskArgs(args: string[]): { sessionName: string | null; message: string } {
- if (args.length === 0) {
- return { sessionName: null, message: "" };
- }
-
- const firstArg = args[0];
-
- // 따옴표로 시작하면 전체가 메시지
- if (firstArg.startsWith('"') || firstArg.startsWith("'")) {
- return { sessionName: null, message: args.join(" ") };
- }
-
- // 첫 인자가 존재하는 세션 이름인지 확인
- const multiSession = getMultiSessionManager();
- if (multiSession?.hasSession(firstArg)) {
- return {
- sessionName: firstArg,
- message: args.slice(1).join(" "),
- };
- }
-
- // 아니면 전체가 메시지
- return { sessionName: null, message: args.join(" ") };
-}
+import { discordToPlatformMessage, getDiscordAdapter } from "../platform/discordAdapter.js";
+import { executeAsk } from "./cross/askCross.js";
const askCommand: PrefixCommand = {
name: "ask",
@@ -44,102 +8,14 @@ const askCommand: PrefixCommand = {
description: "Send a message to the AI CLI. Usage: !a [session] ",
async execute(ctx: CommandContext): Promise {
- if (!isAllowedUser(ctx.message.author.id)) {
- await ctx.message.reply("You are not authorized to use this bot.");
- return;
- }
-
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const { sessionName, message: msg } = parseAskArgs(ctx.args);
-
- if (!msg) {
- await ctx.message.reply(
- "Usage: `!ask [session] `\nExample: `!a hello` or `!a work \"analyze this code\"`",
- );
- return;
- }
-
- // Prompt injection warning (non-blocking)
- const injectionCheck = checkPromptInjection(msg);
- if (injectionCheck.detected) {
- audit(AuditEvent.INJECTION_WARNING, ctx.message.author.id, {
- command: "ask",
- details: { warnings: injectionCheck.warnings },
- });
- await ctx.message.reply(
- `**[Security Warning]** Suspicious prompt pattern detected: ${injectionCheck.warnings.join(", ")}. Proceeding with caution.`,
- );
- }
-
- // 세션 조회 (없으면 default lazy 생성)
- const targetSessionName = sessionName ?? multiSession.getActiveSessionName();
- let namedSession = multiSession.getSession(targetSessionName);
-
- // default 세션이 없으면 자동 생성
- if (!namedSession && targetSessionName === "default") {
- try {
- namedSession = multiSession.createSession("default", ctx.client.selectedCli);
- } catch (err: any) {
- await ctx.message.reply(`Failed to create default session: ${err.message}`);
- return;
- }
- }
-
- if (!namedSession) {
- await ctx.message.reply(
- `Session '${targetSessionName}' not found. Create with: \`!session create ${targetSessionName} \``,
- );
- return;
- }
-
- if (namedSession.manager.isBusy) {
- await ctx.message.reply(
- `Session '${targetSessionName}' is already processing. Use \`!session kill ${targetSessionName}\` to cancel.`,
- );
- return;
- }
-
- const tool = CLI_TOOLS[namedSession.cliName];
- const folder = path.basename(ctx.client.workingDir);
-
- try {
- // 진행 메시지 전송
- const progressMsg = await ctx.message.reply(
- `\u{23F3} **${tool.name}** \uC791\uC5C5 \uC2DC\uC791...`,
- );
- let lastEditTime = 0;
- const THROTTLE_MS = 2000;
-
- const onProgress = (status: string) => {
- const now = Date.now();
- if (now - lastEditTime < THROTTLE_MS) return;
- lastEditTime = now;
- progressMsg
- .edit(`\u{23F3} **${tool.name}** \uC791\uC5C5 \uC911...\n${status}`)
- .catch(() => {});
- };
-
- const result = await withTyping(ctx.message, () =>
- multiSession.sendMessage(sessionName, msg, ctx.message, onProgress),
- );
-
- // 진행 메시지 삭제
- await progressMsg.delete().catch(() => {});
-
- const prefix =
- sessionName || multiSession.listSessions().length > 1
- ? `**${tool.name}** @ \`${folder}\` [${namedSession.name}]`
- : `**${tool.name}** @ \`${folder}\``;
-
- await sendResult(ctx.message, result, { prefix });
- } catch (err: any) {
- await ctx.message.reply(`Error: ${err.message}`);
- }
+ const platformMsg = discordToPlatformMessage(ctx.message);
+ await executeAsk({
+ message: platformMsg,
+ args: ctx.args,
+ adapter: getDiscordAdapter(),
+ selectedCli: ctx.client.selectedCli,
+ workingDir: ctx.client.workingDir,
+ });
},
};
diff --git a/src/commands/cross/askCross.ts b/src/commands/cross/askCross.ts
new file mode 100644
index 0000000..01cb002
--- /dev/null
+++ b/src/commands/cross/askCross.ts
@@ -0,0 +1,160 @@
+import path from "node:path";
+import type { CrossPlatformContext } from "../../platform/context.js";
+import { CLI_TOOLS } from "../../config.js";
+import { getMultiSessionManager } from "../../sessions/multiSession.js";
+import { checkPromptInjection } from "../../utils/promptGuard.js";
+import { audit, AuditEvent } from "../../utils/auditLog.js";
+
+/**
+ * Parse args to extract optional session name and message.
+ * !a work "message" → { sessionName: "work", message: "message" }
+ * !a "message" → { sessionName: null, message: "message" }
+ */
+function parseAskArgs(args: string[]): { sessionName: string | null; message: string } {
+ if (args.length === 0) {
+ return { sessionName: null, message: "" };
+ }
+
+ const firstArg = args[0];
+
+ if (firstArg.startsWith('"') || firstArg.startsWith("'")) {
+ return { sessionName: null, message: args.join(" ") };
+ }
+
+ const multiSession = getMultiSessionManager();
+ if (multiSession?.hasSession(firstArg)) {
+ return {
+ sessionName: firstArg,
+ message: args.slice(1).join(" "),
+ };
+ }
+
+ return { sessionName: null, message: args.join(" ") };
+}
+
+export async function executeAsk(ctx: CrossPlatformContext): Promise {
+ if (!ctx.adapter.isAuthorized(ctx.message.userId)) {
+ await ctx.adapter.reply(ctx.message, "You are not authorized to use this bot.");
+ return;
+ }
+
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const { sessionName, message: msg } = parseAskArgs(ctx.args);
+
+ if (!msg) {
+ await ctx.adapter.reply(
+ ctx.message,
+ "Usage: !ask [session] \nExample: !a hello or !a work \"analyze this code\"",
+ );
+ return;
+ }
+
+ // Prompt injection warning (non-blocking)
+ const injectionCheck = checkPromptInjection(msg);
+ if (injectionCheck.detected) {
+ audit(AuditEvent.INJECTION_WARNING, ctx.message.userId, {
+ command: "ask",
+ details: { warnings: injectionCheck.warnings },
+ });
+ await ctx.adapter.reply(
+ ctx.message,
+ `[Security Warning] Suspicious prompt pattern detected: ${injectionCheck.warnings.join(", ")}. Proceeding with caution.`,
+ );
+ }
+
+ // Get or lazy-create session
+ const targetSessionName = sessionName ?? multiSession.getActiveSessionName();
+ let namedSession = multiSession.getSession(targetSessionName);
+
+ if (!namedSession && targetSessionName === "default") {
+ try {
+ namedSession = multiSession.createSession("default", ctx.selectedCli);
+ } catch (err: any) {
+ await ctx.adapter.reply(ctx.message, `Failed to create default session: ${err.message}`);
+ return;
+ }
+ }
+
+ if (!namedSession) {
+ await ctx.adapter.reply(
+ ctx.message,
+ `Session '${targetSessionName}' not found. Create with: !session create ${targetSessionName} `,
+ );
+ return;
+ }
+
+ if (namedSession.manager.isBusy) {
+ await ctx.adapter.reply(
+ ctx.message,
+ `Session '${targetSessionName}' is already processing. Use !session kill ${targetSessionName} to cancel.`,
+ );
+ return;
+ }
+
+ const tool = CLI_TOOLS[namedSession.cliName];
+ const folder = path.basename(ctx.workingDir);
+
+ try {
+ // Send progress message
+ const progressHandle = await ctx.adapter.sendProgress(
+ ctx.message,
+ `\u{23F3} ${tool.name} 작업 시작...`,
+ );
+ let lastEditTime = 0;
+ const THROTTLE_MS = 2000;
+
+ const onProgress = (status: string) => {
+ const now = Date.now();
+ if (now - lastEditTime < THROTTLE_MS) return;
+ lastEditTime = now;
+ progressHandle.update(`\u{23F3} ${tool.name} 작업 중...\n${status}`).catch(() => {});
+ };
+
+ // Show typing indicator
+ const typing = ctx.adapter.showTyping(ctx.message);
+
+ try {
+ const result = await multiSession.sendMessage(
+ sessionName,
+ msg,
+ ctx.message,
+ ctx.adapter,
+ onProgress,
+ );
+
+ // Delete progress message
+ await progressHandle.delete();
+
+ const prefix =
+ sessionName || multiSession.listSessions().length > 1
+ ? `${tool.name} @ ${folder} [${namedSession.name}]`
+ : `${tool.name} @ ${folder}`;
+
+ // Send result with file fallback for long output
+ const fullResult = `${prefix}\n\`\`\`\n${result}\n\`\`\``;
+
+ if (fullResult.length <= ctx.adapter.maxMessageLength) {
+ await ctx.adapter.reply(ctx.message, fullResult);
+ } else {
+ // Send preview + file attachment
+ const maxPreview = ctx.adapter.maxMessageLength - 200;
+ const preview = result.slice(0, maxPreview);
+ const previewText = `${prefix}\n\`\`\`\n${preview}\n\`\`\`\n(truncated — full output attached)`;
+
+ await ctx.adapter.replyWithFile(ctx.message, previewText, {
+ name: "output.txt",
+ content: Buffer.from(result, "utf-8"),
+ });
+ }
+ } finally {
+ typing.stop();
+ }
+ } catch (err: any) {
+ await ctx.adapter.reply(ctx.message, `Error: ${err.message}`);
+ }
+}
diff --git a/src/commands/cross/execCross.ts b/src/commands/cross/execCross.ts
new file mode 100644
index 0000000..87ecc7a
--- /dev/null
+++ b/src/commands/cross/execCross.ts
@@ -0,0 +1,55 @@
+import type { CrossPlatformContext } from "../../platform/context.js";
+import { COMMAND_TIMEOUT } from "../../config.js";
+import { isCommandBlocked } from "../../utils/security.js";
+import { runCommand } from "../../utils/subprocess.js";
+import { formatOutput } from "../../utils/formatter.js";
+import { audit, AuditEvent } from "../../utils/auditLog.js";
+
+export async function executeExec(ctx: CrossPlatformContext): Promise {
+ if (!ctx.adapter.isAuthorized(ctx.message.userId)) {
+ await ctx.adapter.reply(ctx.message, "You are not authorized to use this bot.");
+ return;
+ }
+
+ const command = ctx.args.join(" ");
+ if (!command) {
+ await ctx.adapter.reply(ctx.message, "Usage: !exec ");
+ return;
+ }
+
+ if (isCommandBlocked(command)) {
+ audit(AuditEvent.COMMAND_BLOCKED, ctx.message.userId, {
+ command: `exec ${command}`,
+ success: false,
+ });
+ await ctx.adapter.reply(ctx.message, "This command is blocked for safety reasons.");
+ return;
+ }
+
+ const typing = ctx.adapter.showTyping(ctx.message);
+ try {
+ const { code, stdout, stderr } = await runCommand(command, {
+ timeout: COMMAND_TIMEOUT * 1000,
+ });
+
+ const result = formatOutput(stdout, stderr, code);
+ const fullResult = `CMD\n\`\`\`\n${result}\n\`\`\``;
+
+ if (fullResult.length <= ctx.adapter.maxMessageLength) {
+ await ctx.adapter.reply(ctx.message, fullResult);
+ } else {
+ const maxPreview = ctx.adapter.maxMessageLength - 200;
+ const preview = result.slice(0, maxPreview);
+ await ctx.adapter.replyWithFile(
+ ctx.message,
+ `CMD\n\`\`\`\n${preview}\n\`\`\`\n(truncated — full output attached)`,
+ {
+ name: "output.txt",
+ content: Buffer.from(result, "utf-8"),
+ },
+ );
+ }
+ } finally {
+ typing.stop();
+ }
+}
diff --git a/src/commands/cross/helpCross.ts b/src/commands/cross/helpCross.ts
new file mode 100644
index 0000000..0461ac5
--- /dev/null
+++ b/src/commands/cross/helpCross.ts
@@ -0,0 +1,50 @@
+import type { CrossPlatformContext } from "../../platform/context.js";
+import { COMMAND_PREFIX, CLI_TOOLS } from "../../config.js";
+
+export async function executeHelp(ctx: CrossPlatformContext): Promise {
+ if (!ctx.adapter.isAuthorized(ctx.message.userId)) {
+ await ctx.adapter.reply(ctx.message, "You are not authorized to use this bot.");
+ return;
+ }
+
+ const p = COMMAND_PREFIX;
+ const tool = CLI_TOOLS[ctx.selectedCli];
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: "AI CLI Gateway Bot",
+ description: `Currently using ${tool.name}.`,
+ color: 0x5865F2,
+ fields: [
+ {
+ name: "AI CLI",
+ value: [
+ `${p}ask [session] — Send message (alias: ${p}a)`,
+ `${p}session create [cli] [cwd] — Create session`,
+ `${p}session list — List all sessions (alias: ${p}s ls)`,
+ `${p}session switch — Switch session (alias: ${p}s sw)`,
+ `${p}session info [name] — Show session info (alias: ${p}s)`,
+ `${p}session new [name] — Reset session`,
+ `${p}session kill [name] — Kill session process`,
+ `${p}session delete — Delete session`,
+ `${p}session stats [name] — Show token stats`,
+ `${p}session history [name] [count] — Show history`,
+ ].join("\n"),
+ inline: false,
+ },
+ {
+ name: "CMD Execution",
+ value: `${p}exec — Run a CMD command (aliases: ${p}run, ${p}cmd)`,
+ inline: false,
+ },
+ {
+ name: "System",
+ value: [
+ `${p}status — Show system info`,
+ `${p}help — Show this message`,
+ ].join("\n"),
+ inline: false,
+ },
+ ],
+ footer: "Only authorized users can use this bot.",
+ });
+}
diff --git a/src/commands/cross/sessionCross.ts b/src/commands/cross/sessionCross.ts
new file mode 100644
index 0000000..6376907
--- /dev/null
+++ b/src/commands/cross/sessionCross.ts
@@ -0,0 +1,434 @@
+import type { CrossPlatformContext } from "../../platform/context.js";
+import { CLI_TOOLS } from "../../config.js";
+import { getMultiSessionManager } from "../../sessions/multiSession.js";
+import { audit, AuditEvent } from "../../utils/auditLog.js";
+
+export async function executeSession(ctx: CrossPlatformContext): Promise {
+ if (!ctx.adapter.isAuthorized(ctx.message.userId)) {
+ await ctx.adapter.reply(ctx.message, "You are not authorized to use this bot.");
+ return;
+ }
+
+ const sub = ctx.args[0]?.toLowerCase() ?? "info";
+
+ switch (sub) {
+ case "create":
+ case "c":
+ return handleCreate(ctx);
+ case "list":
+ case "ls":
+ return handleList(ctx);
+ case "delete":
+ case "del":
+ case "rm":
+ return handleDelete(ctx);
+ case "new":
+ return handleNew(ctx);
+ case "kill":
+ case "stop":
+ return handleKill(ctx);
+ case "switch":
+ case "sw":
+ return handleSwitch(ctx);
+ case "stats":
+ case "stat":
+ return handleStats(ctx);
+ case "history":
+ case "hist":
+ case "h":
+ return handleHistory(ctx);
+ case "cwd":
+ case "dir":
+ return handleCwd(ctx);
+ case "info":
+ default:
+ return handleInfo(ctx);
+ }
+}
+
+async function handleCreate(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const name = ctx.args[1];
+ if (!name) {
+ await ctx.adapter.reply(
+ ctx.message,
+ "Usage: !session create [cli] [cwd]\nExample: !session create work claude",
+ );
+ return;
+ }
+
+ let cliName = ctx.selectedCli;
+ let cwd: string | undefined;
+
+ const arg2 = ctx.args[2];
+ if (arg2) {
+ if (CLI_TOOLS[arg2.toLowerCase()]) {
+ cliName = arg2.toLowerCase();
+ if (ctx.args[3]) {
+ cwd = ctx.args.slice(3).join(" ");
+ }
+ } else {
+ cwd = ctx.args.slice(2).join(" ");
+ }
+ }
+
+ try {
+ const session = multiSession.createSession(name, cliName, cwd);
+ const tool = CLI_TOOLS[cliName];
+
+ audit(AuditEvent.SESSION_CREATED, ctx.message.userId, {
+ sessionName: name,
+ details: { cli: cliName, cwd: session.cwd },
+ });
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: "Session Created",
+ color: 0x57F287,
+ fields: [
+ { name: "Name", value: session.name, inline: true },
+ { name: "CLI", value: tool.name, inline: true },
+ { name: "Working Directory", value: session.cwd, inline: false },
+ ],
+ footer: `Use: !a ${name} "message" or !session switch ${name}`,
+ });
+ } catch (err: any) {
+ await ctx.adapter.reply(ctx.message, `Failed to create session: ${err.message}`);
+ }
+}
+
+async function handleList(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const sessions = multiSession.listSessions();
+ const activeSessionName = multiSession.getActiveSessionName();
+
+ if (sessions.length === 0) {
+ await ctx.adapter.reply(ctx.message, "No sessions. Use !session create [cli] to create one.");
+ return;
+ }
+
+ const sessionList = sessions
+ .map((s) => {
+ const tool = CLI_TOOLS[s.cliName];
+ const info = s.manager.getInfo();
+ const isActive = s.name === activeSessionName;
+ const status = info.isBusy ? "Processing" : info.sessionId ? "Active" : "New";
+ return `${isActive ? ">" : " "} ${s.name}${isActive ? " (active)" : ""} — ${tool.name} | ${status} | ${info.messageCount} msgs`;
+ })
+ .join("\n");
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: "Sessions",
+ description: sessionList,
+ color: 0x5865F2,
+ footer: `${sessions.length} session(s) | Active: ${activeSessionName}`,
+ });
+}
+
+async function handleDelete(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const name = ctx.args[1];
+ if (!name) {
+ await ctx.adapter.reply(ctx.message, "Usage: !session delete ");
+ return;
+ }
+
+ const session = multiSession.getSession(name);
+ if (!session) {
+ await ctx.adapter.reply(ctx.message, `Session '${name}' not found.`);
+ return;
+ }
+
+ const deleted = await multiSession.deleteSession(name);
+ if (deleted) {
+ audit(AuditEvent.SESSION_DELETED, ctx.message.userId, { sessionName: name });
+ await ctx.adapter.reply(ctx.message, `Session '${name}' deleted.`);
+ } else {
+ await ctx.adapter.reply(ctx.message, `Failed to delete session '${name}'.`);
+ }
+}
+
+async function handleNew(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const name = ctx.args[1] ?? multiSession.getActiveSessionName();
+ const session = multiSession.getSession(name);
+
+ if (!session) {
+ await ctx.adapter.reply(ctx.message, `Session '${name}' not found.`);
+ return;
+ }
+
+ await session.manager.newSession();
+ audit(AuditEvent.SESSION_RESET, ctx.message.userId, { sessionName: name });
+ const tool = CLI_TOOLS[session.cliName];
+ await ctx.adapter.reply(
+ ctx.message,
+ `Session '${name}' reset. Next message starts a new ${tool.name} conversation.`,
+ );
+}
+
+async function handleKill(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const name = ctx.args[1] ?? multiSession.getActiveSessionName();
+ const session = multiSession.getSession(name);
+
+ if (!session) {
+ await ctx.adapter.reply(ctx.message, `Session '${name}' not found.`);
+ return;
+ }
+
+ const killed = await session.manager.kill();
+ const tool = CLI_TOOLS[session.cliName];
+
+ if (killed) {
+ await ctx.adapter.reply(ctx.message, `${tool.name} process in session '${name}' killed.`);
+ } else {
+ await ctx.adapter.reply(ctx.message, `No CLI process is running in session '${name}'.`);
+ }
+}
+
+async function handleSwitch(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const targetName = ctx.args[1];
+
+ if (!targetName) {
+ return handleList(ctx);
+ }
+
+ const session = multiSession.getSession(targetName);
+ if (!session) {
+ await ctx.adapter.reply(
+ ctx.message,
+ `Session '${targetName}' not found. Create with: !session create ${targetName} `,
+ );
+ return;
+ }
+
+ const currentActive = multiSession.getActiveSessionName();
+ if (targetName === currentActive) {
+ await ctx.adapter.reply(ctx.message, `Already using session '${targetName}'.`);
+ return;
+ }
+
+ multiSession.setActiveSession(targetName);
+ audit(AuditEvent.SESSION_SWITCHED, ctx.message.userId, {
+ sessionName: targetName,
+ details: { from: currentActive },
+ });
+
+ const tool = CLI_TOOLS[session.cliName];
+ const info = session.manager.getInfo();
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: "Session Switched",
+ description: `Active session: ${targetName}`,
+ color: 0x57F287,
+ fields: [
+ { name: "CLI", value: tool.name, inline: true },
+ { name: "Messages", value: String(info.messageCount), inline: true },
+ ],
+ footer: info.sessionId ? "Previous conversation context preserved." : undefined,
+ });
+}
+
+async function handleStats(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const name = ctx.args[1] ?? multiSession.getActiveSessionName();
+ const session = multiSession.getSession(name);
+
+ if (!session) {
+ await ctx.adapter.reply(ctx.message, `Session '${name}' not found.`);
+ return;
+ }
+
+ const stats = session.manager.getStats();
+ const info = session.manager.getInfo();
+ const tool = CLI_TOOLS[session.cliName];
+ const isActive = name === multiSession.getActiveSessionName();
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: `Stats: ${name}${isActive ? " (active)" : ""}`,
+ color: 0xFEE75C,
+ fields: [
+ { name: "CLI Tool", value: tool.name, inline: true },
+ { name: "Messages", value: String(info.messageCount), inline: true },
+ { name: "History Entries", value: String(stats.history.length), inline: true },
+ { name: "Input Tokens", value: stats.totalInputTokens.toLocaleString(), inline: true },
+ { name: "Output Tokens", value: stats.totalOutputTokens.toLocaleString(), inline: true },
+ { name: "Total Tokens", value: stats.totalTokens.toLocaleString(), inline: true },
+ ],
+ footer: "Token counts are estimates (~4 chars = 1 token)",
+ });
+}
+
+async function handleHistory(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ let name: string;
+ let count = 10;
+
+ const arg1 = ctx.args[1];
+ const arg2 = ctx.args[2];
+
+ if (arg1 && !isNaN(Number(arg1))) {
+ name = multiSession.getActiveSessionName();
+ count = parseInt(arg1, 10);
+ } else if (arg1) {
+ name = arg1;
+ if (arg2 && !isNaN(Number(arg2))) {
+ count = parseInt(arg2, 10);
+ }
+ } else {
+ name = multiSession.getActiveSessionName();
+ }
+
+ const session = multiSession.getSession(name);
+
+ if (!session) {
+ await ctx.adapter.reply(ctx.message, `Session '${name}' not found.`);
+ return;
+ }
+
+ const history = session.manager.getHistory(count);
+ const isActive = name === multiSession.getActiveSessionName();
+
+ if (history.length === 0) {
+ await ctx.adapter.reply(ctx.message, `Session '${name}' has no conversation history yet.`);
+ return;
+ }
+
+ const historyLines = history.map((entry) => {
+ const role = entry.role === "user" ? "User" : "AI";
+ const time = new Date(entry.timestamp).toLocaleTimeString();
+ const tokens = entry.tokens ? ` (${entry.tokens} tok)` : "";
+ const content = entry.content.length > 100 ? entry.content.slice(0, 100) + "..." : entry.content;
+ return `[${role}] ${time}${tokens}\n${content}`;
+ });
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: `History: ${name}${isActive ? " (active)" : ""}`,
+ description: historyLines.join("\n\n"),
+ color: 0x5865F2,
+ footer: `Showing last ${history.length} entries`,
+ });
+}
+
+async function handleInfo(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const name = ctx.args[1] ?? multiSession.getActiveSessionName();
+ const session = multiSession.getSession(name);
+
+ if (!session) {
+ if (!ctx.args[1]) {
+ return handleList(ctx);
+ }
+ await ctx.adapter.reply(ctx.message, `Session '${name}' not found.`);
+ return;
+ }
+
+ const info = session.manager.getInfo();
+ const elapsed = info.startedAt ? Math.floor((Date.now() - info.startedAt) / 1000) : 0;
+ const mins = Math.floor(elapsed / 60);
+ const secs = elapsed % 60;
+
+ let status: string;
+ if (info.isBusy) status = "Processing...";
+ else if (info.sessionId) status = "Active";
+ else status = "New";
+
+ const isActive = name === multiSession.getActiveSessionName();
+
+ const fields = [
+ { name: "CLI Tool", value: info.toolName, inline: true },
+ { name: "Status", value: status, inline: true },
+ { name: "Messages", value: String(info.messageCount), inline: true },
+ { name: "Duration", value: info.startedAt ? `${mins}m ${secs}s` : "—", inline: true },
+ { name: "Working Directory", value: info.cwd, inline: false },
+ ];
+
+ if (info.sessionId) {
+ fields.push({
+ name: "Session ID",
+ value: `${info.sessionId.slice(0, 16)}...`,
+ inline: false,
+ });
+ }
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: `Session: ${name}${isActive ? " (active)" : ""}`,
+ color: isActive ? 0x57F287 : 0x5865F2,
+ fields,
+ });
+}
+
+async function handleCwd(ctx: CrossPlatformContext): Promise {
+ const multiSession = getMultiSessionManager();
+ if (!multiSession) {
+ await ctx.adapter.reply(ctx.message, "Session manager not initialized.");
+ return;
+ }
+
+ const name = ctx.args[1] ?? multiSession.getActiveSessionName();
+ const session = multiSession.getSession(name);
+
+ if (!session) {
+ await ctx.adapter.reply(ctx.message, `Session '${name}' not found.`);
+ return;
+ }
+
+ const isActive = name === multiSession.getActiveSessionName();
+ const tool = CLI_TOOLS[session.cliName];
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: `Working Directory: ${name}${isActive ? " (active)" : ""}`,
+ color: 0x5865F2,
+ fields: [
+ { name: "CLI", value: tool.name, inline: true },
+ { name: "Path", value: session.cwd, inline: false },
+ ],
+ footer: "To change cwd, create a new session with: !session create ",
+ });
+}
diff --git a/src/commands/cross/statusCross.ts b/src/commands/cross/statusCross.ts
new file mode 100644
index 0000000..351b924
--- /dev/null
+++ b/src/commands/cross/statusCross.ts
@@ -0,0 +1,47 @@
+import os from "node:os";
+import type { CrossPlatformContext } from "../../platform/context.js";
+
+export async function executeStatus(ctx: CrossPlatformContext): Promise {
+ if (!ctx.adapter.isAuthorized(ctx.message.userId)) {
+ await ctx.adapter.reply(ctx.message, "You are not authorized to use this bot.");
+ return;
+ }
+
+ const cpus = os.cpus();
+ const cpuUsage = cpus.length > 0
+ ? Math.round(
+ cpus.reduce((sum, c) => {
+ const total = Object.values(c.times).reduce((a, b) => a + b, 0);
+ return sum + ((total - c.times.idle) / total) * 100;
+ }, 0) / cpus.length,
+ )
+ : 0;
+
+ const totalMem = os.totalmem();
+ const freeMem = os.freemem();
+ const usedMem = totalMem - freeMem;
+ const memPercent = Math.round((usedMem / totalMem) * 100);
+
+ const uptimeSecs = os.uptime();
+ const hours = Math.floor(uptimeSecs / 3600);
+ const mins = Math.floor((uptimeSecs % 3600) / 60);
+ const secs = Math.floor(uptimeSecs % 60);
+
+ const gb = (bytes: number): string => Math.floor(bytes / 1073741824).toString();
+
+ await ctx.adapter.replyRich(ctx.message, {
+ title: "System Status",
+ color: 0x2ECC71,
+ fields: [
+ { name: "OS", value: `${os.type()} ${os.release()}`, inline: true },
+ { name: "CPU", value: `${cpuUsage}%`, inline: true },
+ {
+ name: "Memory",
+ value: `${memPercent}% (${gb(usedMem)}/${gb(totalMem)} GB)`,
+ inline: true,
+ },
+ { name: "Uptime", value: `${hours}h ${mins}m ${secs}s`, inline: true },
+ { name: "Node.js", value: process.version, inline: true },
+ ],
+ });
+}
diff --git a/src/commands/exec.ts b/src/commands/exec.ts
index 73317b2..07b4727 100644
--- a/src/commands/exec.ts
+++ b/src/commands/exec.ts
@@ -1,10 +1,6 @@
import type { PrefixCommand, CommandContext } from "../types.js";
-import { COMMAND_TIMEOUT } from "../config.js";
-import { isAllowedUser, isCommandBlocked } from "../utils/security.js";
-import { runCommand } from "../utils/subprocess.js";
-import { formatOutput, sendResult } from "../utils/formatter.js";
-import { withTyping } from "../utils/typing.js";
-import { audit, AuditEvent } from "../utils/auditLog.js";
+import { discordToPlatformMessage, getDiscordAdapter } from "../platform/discordAdapter.js";
+import { executeExec } from "./cross/execCross.js";
const execCommand: PrefixCommand = {
name: "exec",
@@ -12,32 +8,14 @@ const execCommand: PrefixCommand = {
description: "Execute a CMD command.",
async execute(ctx: CommandContext): Promise {
- if (!isAllowedUser(ctx.message.author.id)) {
- await ctx.message.reply("You are not authorized to use this bot.");
- return;
- }
-
- const command = ctx.args.join(" ");
- if (!command) {
- await ctx.message.reply("Usage: `!exec `");
- return;
- }
-
- if (isCommandBlocked(command)) {
- audit(AuditEvent.COMMAND_BLOCKED, ctx.message.author.id, {
- command: `exec ${command}`,
- success: false,
- });
- await ctx.message.reply("This command is blocked for safety reasons.");
- return;
- }
-
- const { code, stdout, stderr } = await withTyping(ctx.message, () =>
- runCommand(command, { timeout: COMMAND_TIMEOUT * 1000 }),
- );
-
- const result = formatOutput(stdout, stderr, code);
- await sendResult(ctx.message, result, { prefix: "**CMD**" });
+ const platformMsg = discordToPlatformMessage(ctx.message);
+ await executeExec({
+ message: platformMsg,
+ args: ctx.args,
+ adapter: getDiscordAdapter(),
+ selectedCli: ctx.client.selectedCli,
+ workingDir: ctx.client.workingDir,
+ });
},
};
diff --git a/src/commands/help.ts b/src/commands/help.ts
index d41e1b4..77d74f7 100644
--- a/src/commands/help.ts
+++ b/src/commands/help.ts
@@ -1,7 +1,6 @@
-import { EmbedBuilder } from "discord.js";
import type { PrefixCommand, CommandContext } from "../types.js";
-import { COMMAND_PREFIX, CLI_TOOLS } from "../config.js";
-import { isAllowedUser } from "../utils/security.js";
+import { discordToPlatformMessage, getDiscordAdapter } from "../platform/discordAdapter.js";
+import { executeHelp } from "./cross/helpCross.js";
const helpCommand: PrefixCommand = {
name: "help",
@@ -9,80 +8,14 @@ const helpCommand: PrefixCommand = {
description: "Show all available commands.",
async execute(ctx: CommandContext): Promise {
- if (!isAllowedUser(ctx.message.author.id)) {
- await ctx.message.reply("You are not authorized to use this bot.");
- return;
- }
-
- const p = COMMAND_PREFIX;
- const tool = CLI_TOOLS[ctx.client.selectedCli];
-
- const embed = new EmbedBuilder()
- .setTitle("AI CLI Gateway Bot")
- .setDescription(`Currently using **${tool.name}**.`)
- .setColor(0x5865F2)
- .addFields(
- {
- name: "AI CLI",
- value: [
- `\`${p}ask [session] \` — Send message (alias: \`${p}a\`)`,
- `\`${p}session create [cli] [cwd]\` — Create session with optional working directory`,
- `\`${p}session list\` — List all sessions (alias: \`${p}s ls\`)`,
- `\`${p}session switch \` — Switch active session (alias: \`${p}s sw\`)`,
- `\`${p}session info [name]\` — Show session info (alias: \`${p}s\`)`,
- `\`${p}session cwd [name]\` — Show session working directory (alias: \`${p}s dir\`)`,
- `\`${p}session new [name]\` — Reset session conversation`,
- `\`${p}session kill [name]\` — Kill session process (alias: \`${p}s stop\`)`,
- `\`${p}session delete \` — Delete session (alias: \`${p}s rm\`)`,
- `\`${p}session stats [name]\` — Show token usage stats (alias: \`${p}s stat\`)`,
- `\`${p}session history [name] [count]\` — Show conversation history (alias: \`${p}s h\`)`,
- ].join("\n"),
- inline: false,
- },
- {
- name: "Task Queue",
- value: [
- `\`${p}task add \` — Add a scheduled task (alias: \`${p}t a\`)`,
- `\`${p}task list\` — List all tasks (alias: \`${p}t ls\`)`,
- `\`${p}task run\` — Run all pending tasks sequentially (alias: \`${p}t r\`)`,
- `\`${p}task remove \` — Remove a task (alias: \`${p}t rm\`)`,
- `\`${p}task clear\` — Clear all pending tasks (alias: \`${p}t c\`)`,
- `\`${p}task stop\` — Stop running tasks (alias: \`${p}t s\`)`,
- ].join("\n"),
- inline: false,
- },
- {
- name: "Git Tools",
- value: [
- `\`${p}diff\` — Show git diff as image (aliases: \`${p}d\`, \`${p}changes\`)`,
- `\`${p}diff --staged\` — Show staged changes only`,
- `\`${p}diff \` — Show diff for specific file`,
- ].join("\n"),
- inline: false,
- },
- {
- name: "CMD Execution",
- value: `\`${p}exec \` — Run a CMD command (aliases: \`${p}run\`, \`${p}cmd\`)`,
- inline: false,
- },
- {
- name: "System",
- value: [
- `\`${p}status\` — Show system info (alias: \`${p}sysinfo\`)`,
- `\`${p}myid\` — Show your Discord user ID (alias: \`${p}id\`)`,
- `\`${p}help\` — Show this message`,
- ].join("\n"),
- inline: false,
- },
- )
- .addFields({
- name: "Slash Commands",
- value: "Slash commands (`/ask`, `/session`, `/exec`, `/task`, `/status`, `/help`) are also available if configured.",
- inline: false,
- })
- .setFooter({ text: "Only authorized users can use this bot." });
-
- await ctx.message.reply({ embeds: [embed] });
+ const platformMsg = discordToPlatformMessage(ctx.message);
+ await executeHelp({
+ message: platformMsg,
+ args: ctx.args,
+ adapter: getDiscordAdapter(),
+ selectedCli: ctx.client.selectedCli,
+ workingDir: ctx.client.workingDir,
+ });
},
};
diff --git a/src/commands/session.ts b/src/commands/session.ts
index 587f2ff..b18ee9e 100644
--- a/src/commands/session.ts
+++ b/src/commands/session.ts
@@ -1,10 +1,6 @@
-import path from "node:path";
-import { EmbedBuilder, ActivityType } from "discord.js";
import type { PrefixCommand, CommandContext } from "../types.js";
-import { CLI_TOOLS } from "../config.js";
-import { isAllowedUser } from "../utils/security.js";
-import { getMultiSessionManager } from "../sessions/multiSession.js";
-import { audit, AuditEvent } from "../utils/auditLog.js";
+import { discordToPlatformMessage, getDiscordAdapter } from "../platform/discordAdapter.js";
+import { executeSession } from "./cross/sessionCross.js";
const sessionCommand: PrefixCommand = {
name: "session",
@@ -12,506 +8,15 @@ const sessionCommand: PrefixCommand = {
description: "Session management (info / create / list / delete / new / kill / switch / stats / history / cwd).",
async execute(ctx: CommandContext): Promise {
- if (!isAllowedUser(ctx.message.author.id)) {
- await ctx.message.reply("You are not authorized to use this bot.");
- return;
- }
-
- const sub = ctx.args[0]?.toLowerCase() ?? "info";
-
- switch (sub) {
- case "create":
- case "c":
- return handleCreate(ctx);
- case "list":
- case "ls":
- return handleList(ctx);
- case "delete":
- case "del":
- case "rm":
- return handleDelete(ctx);
- case "new":
- return handleNew(ctx);
- case "kill":
- case "stop":
- return handleKill(ctx);
- case "switch":
- case "sw":
- return handleSwitch(ctx);
- case "stats":
- case "stat":
- return handleStats(ctx);
- case "history":
- case "hist":
- case "h":
- return handleHistory(ctx);
- case "cwd":
- case "dir":
- return handleCwd(ctx);
- case "info":
- default:
- return handleInfo(ctx);
- }
+ const platformMsg = discordToPlatformMessage(ctx.message);
+ await executeSession({
+ message: platformMsg,
+ args: ctx.args,
+ adapter: getDiscordAdapter(),
+ selectedCli: ctx.client.selectedCli,
+ workingDir: ctx.client.workingDir,
+ });
},
};
-/**
- * !session create [cli] [cwd]
- * 새 세션 생성 (선택적으로 작업 디렉터리 지정 가능)
- */
-async function handleCreate(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const name = ctx.args[1];
-
- if (!name) {
- await ctx.message.reply(
- "Usage: `!session create [cli] [cwd]`\n" +
- "Example: `!session create work claude`\n" +
- "Example: `!session create hsmr claude D:\\SubDev\\HSMR`"
- );
- return;
- }
-
- // Parse CLI name and optional cwd
- // If args[2] looks like a path, treat it as cwd and use default CLI
- // Otherwise treat it as CLI name and args[3+] as cwd
- let cliName = ctx.client.selectedCli;
- let cwd: string | undefined;
-
- const arg2 = ctx.args[2];
- if (arg2) {
- // Check if arg2 is a known CLI or a path
- if (CLI_TOOLS[arg2.toLowerCase()]) {
- cliName = arg2.toLowerCase();
- // Join remaining args as cwd (in case path has spaces)
- if (ctx.args[3]) {
- cwd = ctx.args.slice(3).join(" ");
- }
- } else {
- // arg2 is not a CLI, treat arg2 onwards as cwd
- cwd = ctx.args.slice(2).join(" ");
- }
- }
-
- try {
- const session = multiSession.createSession(name, cliName, cwd);
- const tool = CLI_TOOLS[cliName];
-
- audit(AuditEvent.SESSION_CREATED, ctx.message.author.id, {
- sessionName: name,
- details: { cli: cliName, cwd: session.cwd },
- });
-
- const embed = new EmbedBuilder()
- .setTitle("Session Created")
- .setColor(0x57F287)
- .addFields(
- { name: "Name", value: `\`${session.name}\``, inline: true },
- { name: "CLI", value: tool.name, inline: true },
- { name: "Working Directory", value: `\`${session.cwd}\``, inline: false },
- )
- .setFooter({ text: `Use: !a ${name} "message" or !session switch ${name}` });
-
- await ctx.message.reply({ embeds: [embed] });
- } catch (err: any) {
- await ctx.message.reply(`Failed to create session: ${err.message}`);
- }
-}
-
-/**
- * !session list
- * 모든 세션 목록 표시
- */
-async function handleList(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const sessions = multiSession.listSessions();
- const activeSessionName = multiSession.getActiveSessionName();
-
- if (sessions.length === 0) {
- await ctx.message.reply("No sessions. Use `!session create [cli]` to create one.");
- return;
- }
-
- const sessionList = sessions
- .map((s) => {
- const tool = CLI_TOOLS[s.cliName];
- const info = s.manager.getInfo();
- const isActive = s.name === activeSessionName;
- const status = info.isBusy ? "Processing" : info.sessionId ? "Active" : "New";
- return `${isActive ? "**" : ""}\`${s.name}\`${isActive ? " (active)**" : ""} — ${tool.name} | ${status} | ${info.messageCount} msgs`;
- })
- .join("\n");
-
- const embed = new EmbedBuilder()
- .setTitle("Sessions")
- .setDescription(sessionList)
- .setColor(0x5865F2)
- .setFooter({ text: `${sessions.length} session(s) | Active: ${activeSessionName}` });
-
- await ctx.message.reply({ embeds: [embed] });
-}
-
-/**
- * !session delete
- * 세션 삭제
- */
-async function handleDelete(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const name = ctx.args[1];
- if (!name) {
- await ctx.message.reply("Usage: `!session delete `");
- return;
- }
-
- const session = multiSession.getSession(name);
- if (!session) {
- await ctx.message.reply(`Session '${name}' not found.`);
- return;
- }
-
- const deleted = await multiSession.deleteSession(name);
- if (deleted) {
- audit(AuditEvent.SESSION_DELETED, ctx.message.author.id, { sessionName: name });
- await ctx.message.reply(`Session '${name}' deleted.`);
- } else {
- await ctx.message.reply(`Failed to delete session '${name}'.`);
- }
-}
-
-/**
- * !session new [name]
- * 세션 초기화 (대화 리셋)
- */
-async function handleNew(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const name = ctx.args[1] ?? multiSession.getActiveSessionName();
- const session = multiSession.getSession(name);
-
- if (!session) {
- await ctx.message.reply(`Session '${name}' not found.`);
- return;
- }
-
- await session.manager.newSession();
- audit(AuditEvent.SESSION_RESET, ctx.message.author.id, { sessionName: name });
- const tool = CLI_TOOLS[session.cliName];
- await ctx.message.reply(
- `Session '${name}' reset. Next message starts a new **${tool.name}** conversation.`,
- );
-}
-
-/**
- * !session kill [name]
- * 세션 프로세스 종료
- */
-async function handleKill(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const name = ctx.args[1] ?? multiSession.getActiveSessionName();
- const session = multiSession.getSession(name);
-
- if (!session) {
- await ctx.message.reply(`Session '${name}' not found.`);
- return;
- }
-
- const killed = await session.manager.kill();
- const tool = CLI_TOOLS[session.cliName];
-
- if (killed) {
- await ctx.message.reply(`**${tool.name}** process in session '${name}' killed.`);
- } else {
- await ctx.message.reply(`No CLI process is running in session '${name}'.`);
- }
-}
-
-/**
- * !session switch
- * 활성 세션 변경
- */
-async function handleSwitch(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const targetName = ctx.args[1];
-
- // 인자 없으면 세션 목록 표시
- if (!targetName) {
- return handleList(ctx);
- }
-
- const session = multiSession.getSession(targetName);
- if (!session) {
- await ctx.message.reply(
- `Session '${targetName}' not found. Create with: \`!session create ${targetName} \``,
- );
- return;
- }
-
- const currentActive = multiSession.getActiveSessionName();
- if (targetName === currentActive) {
- await ctx.message.reply(`Already using session '${targetName}'.`);
- return;
- }
-
- multiSession.setActiveSession(targetName);
- audit(AuditEvent.SESSION_SWITCHED, ctx.message.author.id, {
- sessionName: targetName,
- details: { from: currentActive },
- });
-
- // Update bot activity
- const tool = CLI_TOOLS[session.cliName];
- const folder = path.basename(ctx.client.workingDir);
- ctx.client.user?.setActivity(`${tool.name} @ ${folder} [${targetName}]`, {
- type: ActivityType.Listening,
- });
-
- // Update client selectedCli
- ctx.client.selectedCli = session.cliName;
-
- const info = session.manager.getInfo();
- const embed = new EmbedBuilder()
- .setTitle("Session Switched")
- .setDescription(`Active session: **${targetName}**`)
- .setColor(0x57F287)
- .addFields(
- { name: "CLI", value: tool.name, inline: true },
- { name: "Messages", value: String(info.messageCount), inline: true },
- );
-
- if (info.sessionId) {
- embed.setFooter({ text: "Previous conversation context preserved." });
- }
-
- await ctx.message.reply({ embeds: [embed] });
-}
-
-/**
- * !session stats [name]
- * 세션 토큰 통계 표시
- */
-async function handleStats(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const name = ctx.args[1] ?? multiSession.getActiveSessionName();
- const session = multiSession.getSession(name);
-
- if (!session) {
- await ctx.message.reply(`Session '${name}' not found.`);
- return;
- }
-
- const stats = session.manager.getStats();
- const info = session.manager.getInfo();
- const tool = CLI_TOOLS[session.cliName];
- const isActive = name === multiSession.getActiveSessionName();
-
- const embed = new EmbedBuilder()
- .setTitle(`Stats: ${name}${isActive ? " (active)" : ""}`)
- .setColor(0xFEE75C)
- .addFields(
- { name: "CLI Tool", value: tool.name, inline: true },
- { name: "Messages", value: String(info.messageCount), inline: true },
- { name: "History Entries", value: String(stats.history.length), inline: true },
- { name: "Input Tokens", value: stats.totalInputTokens.toLocaleString(), inline: true },
- { name: "Output Tokens", value: stats.totalOutputTokens.toLocaleString(), inline: true },
- { name: "Total Tokens", value: stats.totalTokens.toLocaleString(), inline: true },
- )
- .setFooter({ text: "Token counts are estimates (~4 chars = 1 token)" });
-
- await ctx.message.reply({ embeds: [embed] });
-}
-
-/**
- * !session history [name] [count]
- * 세션 대화 히스토리 표시
- */
-async function handleHistory(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- // 첫 번째 인자가 숫자면 count로, 아니면 세션 이름으로 해석
- let name: string;
- let count = 10;
-
- const arg1 = ctx.args[1];
- const arg2 = ctx.args[2];
-
- if (arg1 && !isNaN(Number(arg1))) {
- // !session history 5
- name = multiSession.getActiveSessionName();
- count = parseInt(arg1, 10);
- } else if (arg1) {
- // !session history work [5]
- name = arg1;
- if (arg2 && !isNaN(Number(arg2))) {
- count = parseInt(arg2, 10);
- }
- } else {
- name = multiSession.getActiveSessionName();
- }
-
- const session = multiSession.getSession(name);
-
- if (!session) {
- await ctx.message.reply(`Session '${name}' not found.`);
- return;
- }
-
- const history = session.manager.getHistory(count);
- const isActive = name === multiSession.getActiveSessionName();
-
- if (history.length === 0) {
- await ctx.message.reply(`Session '${name}' has no conversation history yet.`);
- return;
- }
-
- const historyLines = history.map((entry, idx) => {
- const role = entry.role === "user" ? "👤" : "🤖";
- const time = new Date(entry.timestamp).toLocaleTimeString();
- const tokens = entry.tokens ? ` (${entry.tokens} tok)` : "";
- const content = entry.content.length > 100 ? entry.content.slice(0, 100) + "..." : entry.content;
- return `${role} \`${time}\`${tokens}\n${content}`;
- });
-
- const embed = new EmbedBuilder()
- .setTitle(`History: ${name}${isActive ? " (active)" : ""}`)
- .setDescription(historyLines.join("\n\n"))
- .setColor(0x5865F2)
- .setFooter({ text: `Showing last ${history.length} entries` });
-
- await ctx.message.reply({ embeds: [embed] });
-}
-
-/**
- * !session info [name]
- * 세션 정보 표시
- */
-async function handleInfo(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- // 인자로 세션 이름이 있으면 해당 세션, 없으면 활성 세션
- const name = ctx.args[1] ?? multiSession.getActiveSessionName();
- const session = multiSession.getSession(name);
-
- if (!session) {
- // 세션이 없으면 목록 표시
- if (!ctx.args[1]) {
- return handleList(ctx);
- }
- await ctx.message.reply(`Session '${name}' not found.`);
- return;
- }
-
- const info = session.manager.getInfo();
- const elapsed = info.startedAt ? Math.floor((Date.now() - info.startedAt) / 1000) : 0;
- const mins = Math.floor(elapsed / 60);
- const secs = elapsed % 60;
-
- let status: string;
- if (info.isBusy) status = "Processing...";
- else if (info.sessionId) status = "Active";
- else status = "New";
-
- const isActive = name === multiSession.getActiveSessionName();
-
- const embed = new EmbedBuilder()
- .setTitle(`Session: ${name}${isActive ? " (active)" : ""}`)
- .setColor(isActive ? 0x57F287 : 0x5865F2)
- .addFields(
- { name: "CLI Tool", value: info.toolName, inline: true },
- { name: "Status", value: status, inline: true },
- { name: "Messages", value: String(info.messageCount), inline: true },
- {
- name: "Duration",
- value: info.startedAt ? `${mins}m ${secs}s` : "—",
- inline: true,
- },
- { name: "Working Directory", value: `\`${info.cwd}\``, inline: false },
- );
-
- if (info.sessionId) {
- embed.addFields({
- name: "Session ID",
- value: `\`${info.sessionId.slice(0, 16)}...\``,
- inline: false,
- });
- }
-
- await ctx.message.reply({ embeds: [embed] });
-}
-
-/**
- * !session cwd [name]
- * 세션의 작업 디렉터리 확인
- */
-async function handleCwd(ctx: CommandContext): Promise {
- const multiSession = getMultiSessionManager();
- if (!multiSession) {
- await ctx.message.reply("Session manager not initialized.");
- return;
- }
-
- const name = ctx.args[1] ?? multiSession.getActiveSessionName();
- const session = multiSession.getSession(name);
-
- if (!session) {
- await ctx.message.reply(`Session '${name}' not found.`);
- return;
- }
-
- const isActive = name === multiSession.getActiveSessionName();
- const tool = CLI_TOOLS[session.cliName];
-
- const embed = new EmbedBuilder()
- .setTitle(`Working Directory: ${name}${isActive ? " (active)" : ""}`)
- .setColor(0x5865F2)
- .addFields(
- { name: "CLI", value: tool.name, inline: true },
- { name: "Path", value: `\`${session.cwd}\``, inline: false },
- )
- .setFooter({ text: "To change cwd, create a new session with: !session create " });
-
- await ctx.message.reply({ embeds: [embed] });
-}
-
export default sessionCommand;
diff --git a/src/commands/status.ts b/src/commands/status.ts
index d5d3f30..520937f 100644
--- a/src/commands/status.ts
+++ b/src/commands/status.ts
@@ -1,7 +1,6 @@
-import os from "node:os";
-import { EmbedBuilder } from "discord.js";
import type { PrefixCommand, CommandContext } from "../types.js";
-import { isAllowedUser } from "../utils/security.js";
+import { discordToPlatformMessage, getDiscordAdapter } from "../platform/discordAdapter.js";
+import { executeStatus } from "./cross/statusCross.js";
const statusCommand: PrefixCommand = {
name: "status",
@@ -9,52 +8,15 @@ const statusCommand: PrefixCommand = {
description: "Show system status.",
async execute(ctx: CommandContext): Promise {
- if (!isAllowedUser(ctx.message.author.id)) {
- await ctx.message.reply("You are not authorized to use this bot.");
- return;
- }
-
- const cpus = os.cpus();
- const cpuUsage = cpus.length > 0
- ? Math.round(
- cpus.reduce((sum, c) => {
- const total = Object.values(c.times).reduce((a, b) => a + b, 0);
- return sum + ((total - c.times.idle) / total) * 100;
- }, 0) / cpus.length,
- )
- : 0;
-
- const totalMem = os.totalmem();
- const freeMem = os.freemem();
- const usedMem = totalMem - freeMem;
- const memPercent = Math.round((usedMem / totalMem) * 100);
-
- const uptimeSecs = os.uptime();
- const hours = Math.floor(uptimeSecs / 3600);
- const mins = Math.floor((uptimeSecs % 3600) / 60);
- const secs = Math.floor(uptimeSecs % 60);
-
- const embed = new EmbedBuilder()
- .setTitle("System Status")
- .setColor(0x2ECC71)
- .addFields(
- { name: "OS", value: `${os.type()} ${os.release()}`, inline: true },
- { name: "CPU", value: `${cpuUsage}%`, inline: true },
- {
- name: "Memory",
- value: `${memPercent}% (${gb(usedMem)}/${gb(totalMem)} GB)`,
- inline: true,
- },
- { name: "Uptime", value: `${hours}h ${mins}m ${secs}s`, inline: true },
- { name: "Node.js", value: process.version, inline: true },
- );
-
- await ctx.message.reply({ embeds: [embed] });
+ const platformMsg = discordToPlatformMessage(ctx.message);
+ await executeStatus({
+ message: platformMsg,
+ args: ctx.args,
+ adapter: getDiscordAdapter(),
+ selectedCli: ctx.client.selectedCli,
+ workingDir: ctx.client.workingDir,
+ });
},
};
-function gb(bytes: number): string {
- return Math.floor(bytes / 1073741824).toString();
-}
-
export default statusCommand;
diff --git a/src/commands/task.ts b/src/commands/task.ts
index 0893743..95cd450 100644
--- a/src/commands/task.ts
+++ b/src/commands/task.ts
@@ -1,6 +1,7 @@
import type { TextChannel } from "discord.js";
import type { PrefixCommand, CommandContext } from "../types.js";
import { isAllowedUser } from "../utils/security.js";
+import { discordToPlatformMessage, getDiscordAdapter } from "../platform/discordAdapter.js";
import {
addTask,
removeTask,
@@ -196,8 +197,10 @@ async function handleRun(ctx: CommandContext): Promise {
try {
// 기본 세션으로 메시지 전송
+ const platformMsg = discordToPlatformMessage(ctx.message);
+ const adapter = getDiscordAdapter();
const result = await withTyping(ctx.message, () =>
- multiSession.sendMessage(null, task.content, ctx.message),
+ multiSession.sendMessage(null, task.content, platformMsg, adapter),
);
await updateTaskStatus(ctx.client.workingDir, task.id, "completed", result);
diff --git a/src/config.ts b/src/config.ts
index 84fa7d4..fe4e26a 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -85,6 +85,12 @@ export function reloadConfig(): void {
);
APPLICATION_ID = process.env.APPLICATION_ID ?? "";
SLASH_COMMAND_GUILD_ID = process.env.SLASH_COMMAND_GUILD_ID ?? "";
+
+ // LINE
+ LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN ?? "";
+ LINE_CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET ?? "";
+ LINE_WEBHOOK_PORT = parseInt(process.env.LINE_WEBHOOK_PORT ?? "3000", 10);
+ ALLOWED_LINE_USER_IDS = parseLineUserIds(process.env.ALLOWED_LINE_USER_IDS ?? "");
}
// CLI tool definitions
@@ -169,3 +175,16 @@ export let SLASH_COMMAND_GUILD_ID = process.env.SLASH_COMMAND_GUILD_ID ?? "";
// Discord message limit
export const DISCORD_MAX_LENGTH = 2000;
+
+// LINE Messaging API
+function parseLineUserIds(raw: string): Set {
+ return new Set(
+ raw.split(",").map((id) => id.trim()).filter(Boolean),
+ );
+}
+
+export let LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN ?? "";
+export let LINE_CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET ?? "";
+export let LINE_WEBHOOK_PORT = parseInt(process.env.LINE_WEBHOOK_PORT ?? "3000", 10);
+export let ALLOWED_LINE_USER_IDS = parseLineUserIds(process.env.ALLOWED_LINE_USER_IDS ?? "");
+export const LINE_MAX_LENGTH = 5000;
diff --git a/src/lineBot.ts b/src/lineBot.ts
new file mode 100644
index 0000000..33f6a05
--- /dev/null
+++ b/src/lineBot.ts
@@ -0,0 +1,244 @@
+import http from "node:http";
+import crypto from "node:crypto";
+import { messagingApi } from "@line/bot-sdk";
+import {
+ LINE_CHANNEL_ACCESS_TOKEN,
+ LINE_CHANNEL_SECRET,
+ LINE_WEBHOOK_PORT,
+ COMMAND_PREFIX,
+} from "./config.js";
+import { LineAdapter } from "./platform/lineAdapter.js";
+import type { PlatformMessage } from "./platform/types.js";
+import type { CrossPlatformContext } from "./platform/context.js";
+import { executeAsk } from "./commands/cross/askCross.js";
+import { executeSession } from "./commands/cross/sessionCross.js";
+import { executeExec } from "./commands/cross/execCross.js";
+import { executeStatus } from "./commands/cross/statusCross.js";
+import { executeHelp } from "./commands/cross/helpCross.js";
+import { audit, AuditEvent } from "./utils/auditLog.js";
+import { getRateLimiter } from "./utils/rateLimiter.js";
+
+interface LineTextEvent {
+ type: "message";
+ replyToken: string;
+ source: {
+ type: string;
+ userId?: string;
+ groupId?: string;
+ roomId?: string;
+ };
+ message: {
+ type: string;
+ id: string;
+ text?: string;
+ };
+}
+
+/** Command routing table (name → handler) */
+const COMMAND_MAP: Record Promise> = {
+ ask: executeAsk,
+ a: executeAsk,
+ session: executeSession,
+ s: executeSession,
+ exec: executeExec,
+ run: executeExec,
+ cmd: executeExec,
+ status: executeStatus,
+ sysinfo: executeStatus,
+ help: executeHelp,
+};
+
+export class LineBotServer {
+ private selectedCli: string;
+ private workingDir: string;
+ private server: http.Server | null = null;
+ private lineClient: messagingApi.MessagingApiClient;
+ private adapter: LineAdapter;
+
+ constructor(selectedCli: string, workingDir: string) {
+ this.selectedCli = selectedCli;
+ this.workingDir = workingDir;
+ this.lineClient = new messagingApi.MessagingApiClient({
+ channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN,
+ });
+ this.adapter = new LineAdapter(this.lineClient);
+ }
+
+ async start(): Promise {
+ this.server = http.createServer((req, res) => {
+ this.handleRequest(req, res);
+ });
+
+ return new Promise((resolve) => {
+ this.server!.listen(LINE_WEBHOOK_PORT, () => {
+ console.log(` [LINE] Webhook server listening on port ${LINE_WEBHOOK_PORT}`);
+ resolve();
+ });
+ });
+ }
+
+ stop(): void {
+ if (this.server) {
+ this.server.close();
+ this.server = null;
+ console.log(" [LINE] Webhook server stopped");
+ }
+ }
+
+ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
+ // Only accept POST /webhook
+ if (req.method !== "POST" || (req.url !== "/webhook" && req.url !== "/")) {
+ res.writeHead(404);
+ res.end("Not Found");
+ return;
+ }
+
+ const chunks: Buffer[] = [];
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
+ req.on("end", () => {
+ const body = Buffer.concat(chunks);
+ const signature = req.headers["x-line-signature"] as string | undefined;
+
+ if (!signature || !this.validateSignature(body, signature)) {
+ console.warn(" [LINE] Invalid signature — rejecting request");
+ res.writeHead(403);
+ res.end("Forbidden");
+ return;
+ }
+
+ // Respond 200 immediately (LINE requires quick response)
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ status: "ok" }));
+
+ // Process events asynchronously
+ try {
+ const payload = JSON.parse(body.toString("utf-8"));
+ if (payload.events && Array.isArray(payload.events)) {
+ for (const event of payload.events) {
+ this.processEvent(event).catch((err) => {
+ console.error(" [LINE] Event processing error:", err);
+ });
+ }
+ }
+ } catch (err) {
+ console.error(" [LINE] Failed to parse webhook body:", err);
+ }
+ });
+ }
+
+ private validateSignature(body: Buffer, signature: string): boolean {
+ const hmac = crypto.createHmac("SHA256", LINE_CHANNEL_SECRET);
+ hmac.update(body);
+ const expected = hmac.digest("base64");
+ return crypto.timingSafeEqual(
+ Buffer.from(expected, "utf-8"),
+ Buffer.from(signature, "utf-8"),
+ );
+ }
+
+ private async processEvent(event: LineTextEvent): Promise {
+ // Only handle text messages
+ if (event.type !== "message" || event.message.type !== "text") return;
+
+ const text = event.message.text ?? "";
+ const userId = event.source.userId;
+ if (!userId) return;
+
+ // Check if this is a pending response (for askQuestion)
+ if (this.adapter.handlePendingResponse(userId, text)) {
+ return;
+ }
+
+ // Only process command-prefixed messages
+ if (!text.startsWith(COMMAND_PREFIX)) return;
+
+ const args = text.slice(COMMAND_PREFIX.length).trim().split(/\s+/);
+ const cmdName = args.shift()?.toLowerCase();
+ if (!cmdName) return;
+
+ // Determine the channelId (userId for 1:1, groupId/roomId for groups)
+ const channelId = event.source.groupId ?? event.source.roomId ?? userId;
+
+ // Build platform message
+ const platformMsg: PlatformMessage = {
+ platform: "line",
+ userId,
+ displayName: userId, // LINE doesn't provide display name in webhook
+ channelId,
+ content: text,
+ raw: event,
+ };
+
+ // Authorization check
+ if (!this.adapter.isAuthorized(userId)) {
+ // Use replyToken for unauthorized response (quick, saves push quota)
+ await this.adapter.replyWithToken(event.replyToken, "You are not authorized to use this bot.");
+ return;
+ }
+
+ // Rate limiting
+ const rateLimiter = getRateLimiter();
+ if (rateLimiter) {
+ const { allowed, retryAfterMs } = rateLimiter.tryConsume(userId);
+ if (!allowed) {
+ const secs = Math.ceil(retryAfterMs / 1000);
+ audit(AuditEvent.RATE_LIMITED, userId, {
+ command: cmdName,
+ success: false,
+ });
+ await this.adapter.replyWithToken(
+ event.replyToken,
+ `Rate limited. Try again in ${secs}s.`,
+ );
+ return;
+ }
+ }
+
+ // Use replyToken for immediate "processing..." acknowledgment
+ await this.adapter.replyWithToken(
+ event.replyToken,
+ `\u{23F3} Processing ${COMMAND_PREFIX}${cmdName}...`,
+ ).catch(() => {});
+
+ // Audit
+ audit(AuditEvent.COMMAND_EXECUTED, userId, { command: cmdName });
+
+ // Route to command handler
+ await this.routeCommand(cmdName, {
+ message: platformMsg,
+ args,
+ adapter: this.adapter,
+ selectedCli: this.selectedCli,
+ workingDir: this.workingDir,
+ });
+ }
+
+ private async routeCommand(
+ cmdName: string,
+ ctx: CrossPlatformContext,
+ ): Promise {
+ const handler = COMMAND_MAP[cmdName];
+ if (!handler) {
+ await this.adapter.reply(
+ ctx.message,
+ `Unknown command: ${COMMAND_PREFIX}${cmdName}\nUse ${COMMAND_PREFIX}help to see available commands.`,
+ );
+ return;
+ }
+
+ try {
+ await handler(ctx);
+ } catch (err: any) {
+ console.error(` [LINE] Command ${cmdName} error:`, err);
+ audit(AuditEvent.COMMAND_ERROR, ctx.message.userId, {
+ command: cmdName,
+ success: false,
+ details: { error: String(err.message ?? err) },
+ });
+ await this.adapter.reply(
+ ctx.message,
+ `An error occurred: ${String(err.message ?? err).slice(0, 200)}`,
+ ).catch(() => {});
+ }
+ }
+}
diff --git a/src/platform/context.ts b/src/platform/context.ts
new file mode 100644
index 0000000..3eab23d
--- /dev/null
+++ b/src/platform/context.ts
@@ -0,0 +1,9 @@
+import type { PlatformMessage, PlatformAdapter } from "./types.js";
+
+export interface CrossPlatformContext {
+ message: PlatformMessage;
+ args: string[];
+ adapter: PlatformAdapter;
+ selectedCli: string;
+ workingDir: string;
+}
diff --git a/src/platform/discordAdapter.ts b/src/platform/discordAdapter.ts
new file mode 100644
index 0000000..164baa9
--- /dev/null
+++ b/src/platform/discordAdapter.ts
@@ -0,0 +1,165 @@
+import {
+ AttachmentBuilder,
+ EmbedBuilder,
+ type Message,
+ type TextChannel,
+} from "discord.js";
+import type {
+ PlatformAdapter,
+ PlatformMessage,
+ RichMessage,
+ FileAttachment,
+ InteractiveQuestion,
+ ProgressHandle,
+} from "./types.js";
+import { DISCORD_MAX_LENGTH } from "../config.js";
+import { isAllowedUser } from "../utils/security.js";
+import { handleAskUserQuestion } from "../utils/discordPrompt.js";
+
+/** Convert a Discord Message to a PlatformMessage */
+export function discordToPlatformMessage(msg: Message): PlatformMessage {
+ return {
+ platform: "discord",
+ userId: msg.author.id,
+ displayName: msg.author.displayName ?? msg.author.username,
+ channelId: msg.channelId,
+ content: msg.content,
+ raw: msg,
+ };
+}
+
+class DiscordAdapter implements PlatformAdapter {
+ readonly platform = "discord" as const;
+ readonly maxMessageLength = DISCORD_MAX_LENGTH;
+
+ async reply(msg: PlatformMessage, text: string): Promise {
+ const discordMsg = msg.raw as Message;
+
+ if (text.length <= DISCORD_MAX_LENGTH) {
+ await discordMsg.reply(text);
+ return;
+ }
+
+ // Truncate + file attachment for long messages
+ const preview = text.slice(0, DISCORD_MAX_LENGTH - 100);
+ const file = new AttachmentBuilder(Buffer.from(text, "utf-8"), {
+ name: "output.txt",
+ });
+ await discordMsg.reply({
+ content: preview + "\n*(truncated — full output attached)*",
+ files: [file],
+ });
+ }
+
+ async replyRich(msg: PlatformMessage, rich: RichMessage): Promise {
+ const discordMsg = msg.raw as Message;
+ const embed = new EmbedBuilder();
+
+ if (rich.title) embed.setTitle(rich.title);
+ if (rich.description) embed.setDescription(rich.description);
+ if (rich.color !== undefined) embed.setColor(rich.color);
+ if (rich.footer) embed.setFooter({ text: rich.footer });
+ if (rich.fields) {
+ embed.addFields(
+ rich.fields.map((f) => ({
+ name: f.name,
+ value: f.value,
+ inline: f.inline ?? false,
+ })),
+ );
+ }
+
+ await discordMsg.reply({ embeds: [embed] });
+ }
+
+ async replyWithFile(
+ msg: PlatformMessage,
+ text: string,
+ file: FileAttachment,
+ ): Promise {
+ const discordMsg = msg.raw as Message;
+ const attachment = new AttachmentBuilder(file.content, {
+ name: file.name,
+ });
+
+ const content = text.length > DISCORD_MAX_LENGTH
+ ? text.slice(0, DISCORD_MAX_LENGTH - 60) + "\n*(truncated)*"
+ : text;
+
+ await discordMsg.reply({ content, files: [attachment] });
+ }
+
+ showTyping(msg: PlatformMessage): { stop: () => void } {
+ const discordMsg = msg.raw as Message;
+ const channel = discordMsg.channel;
+
+ const doTyping = () => {
+ if ("sendTyping" in channel && typeof channel.sendTyping === "function") {
+ (channel.sendTyping as () => Promise)().catch(() => {});
+ }
+ };
+
+ doTyping();
+ const interval = setInterval(doTyping, 8_000);
+
+ return {
+ stop: () => clearInterval(interval),
+ };
+ }
+
+ async askQuestion(
+ msg: PlatformMessage,
+ question: InteractiveQuestion,
+ ): Promise {
+ const discordMsg = msg.raw as Message;
+ const result = await handleAskUserQuestion(
+ {
+ questions: [
+ {
+ question: question.question,
+ header: question.header,
+ options: question.options,
+ multiSelect: question.multiSelect,
+ },
+ ],
+ },
+ discordMsg.channel as TextChannel,
+ msg.userId,
+ );
+
+ // Return the first answer
+ const answers = result.answers ?? {};
+ return Object.values(answers)[0] ?? question.options[0]?.label ?? "";
+ }
+
+ async sendProgress(
+ msg: PlatformMessage,
+ text: string,
+ ): Promise {
+ const discordMsg = msg.raw as Message;
+ const progressMsg = await discordMsg.reply(text);
+
+ return {
+ update: async (newText: string) => {
+ await progressMsg.edit(newText).catch(() => {});
+ },
+ delete: async () => {
+ await progressMsg.delete().catch(() => {});
+ },
+ };
+ }
+
+ isAuthorized(userId: string): boolean {
+ return isAllowedUser(userId);
+ }
+}
+
+// Singleton instance
+let discordAdapterInstance: DiscordAdapter | null = null;
+
+export function getDiscordAdapter(): PlatformAdapter {
+ if (!discordAdapterInstance) {
+ discordAdapterInstance = new DiscordAdapter();
+ }
+ return discordAdapterInstance;
+}
diff --git a/src/platform/index.ts b/src/platform/index.ts
new file mode 100644
index 0000000..e8737f0
--- /dev/null
+++ b/src/platform/index.ts
@@ -0,0 +1,14 @@
+export type {
+ PlatformType,
+ PlatformMessage,
+ RichMessage,
+ InteractiveQuestion,
+ FileAttachment,
+ ProgressHandle,
+ PlatformAdapter,
+} from "./types.js";
+
+export type { CrossPlatformContext } from "./context.js";
+
+export { discordToPlatformMessage, getDiscordAdapter } from "./discordAdapter.js";
+export { LineAdapter } from "./lineAdapter.js";
diff --git a/src/platform/lineAdapter.ts b/src/platform/lineAdapter.ts
new file mode 100644
index 0000000..d549d58
--- /dev/null
+++ b/src/platform/lineAdapter.ts
@@ -0,0 +1,258 @@
+import type { messagingApi } from "@line/bot-sdk";
+import type {
+ PlatformAdapter,
+ PlatformMessage,
+ RichMessage,
+ FileAttachment,
+ InteractiveQuestion,
+ ProgressHandle,
+} from "./types.js";
+import { isAllowedLineUser } from "../utils/security.js";
+
+const LINE_MAX_LENGTH = 5000;
+
+/** Convert a RichMessage to a LINE Flex Message bubble */
+function richToFlexMessage(rich: RichMessage): messagingApi.FlexMessage {
+ const bodyContents: messagingApi.FlexComponent[] = [];
+
+ if (rich.title) {
+ bodyContents.push({
+ type: "text",
+ text: rich.title,
+ weight: "bold",
+ size: "lg",
+ wrap: true,
+ });
+ bodyContents.push({ type: "separator", margin: "md" });
+ }
+
+ if (rich.description) {
+ bodyContents.push({
+ type: "text",
+ text: rich.description,
+ wrap: true,
+ size: "sm",
+ margin: "md",
+ });
+ }
+
+ if (rich.fields && rich.fields.length > 0) {
+ bodyContents.push({ type: "separator", margin: "md" });
+ for (const field of rich.fields) {
+ bodyContents.push({
+ type: "box",
+ layout: "vertical",
+ margin: "sm",
+ contents: [
+ {
+ type: "text",
+ text: field.name,
+ size: "xs",
+ color: "#8C8C8C",
+ wrap: true,
+ },
+ {
+ type: "text",
+ text: field.value,
+ size: "sm",
+ wrap: true,
+ },
+ ],
+ });
+ }
+ }
+
+ const bubble: messagingApi.FlexBubble = {
+ type: "bubble",
+ body: {
+ type: "box",
+ layout: "vertical",
+ contents: bodyContents.length > 0
+ ? bodyContents
+ : [{ type: "text", text: "(empty)", wrap: true }],
+ },
+ };
+
+ if (rich.footer) {
+ bubble.footer = {
+ type: "box",
+ layout: "vertical",
+ contents: [
+ {
+ type: "text",
+ text: rich.footer,
+ size: "xxs",
+ color: "#8C8C8C",
+ wrap: true,
+ },
+ ],
+ };
+ }
+
+ return {
+ type: "flex",
+ altText: rich.title ?? rich.description ?? "Message",
+ contents: bubble,
+ };
+}
+
+/** Truncate text to LINE message length limit */
+function truncateForLine(text: string): string {
+ if (text.length <= LINE_MAX_LENGTH) return text;
+ return text.slice(0, LINE_MAX_LENGTH - 30) + "\n...(truncated)";
+}
+
+export class LineAdapter implements PlatformAdapter {
+ readonly platform = "line" as const;
+ readonly maxMessageLength = LINE_MAX_LENGTH;
+
+ private client: messagingApi.MessagingApiClient;
+ private pendingResponses = new Map<
+ string,
+ { resolve: (value: string) => void; timeout: ReturnType }
+ >();
+
+ constructor(client: messagingApi.MessagingApiClient) {
+ this.client = client;
+ }
+
+ async reply(msg: PlatformMessage, text: string): Promise {
+ const truncated = truncateForLine(text);
+ await this.client.pushMessage({
+ to: msg.channelId,
+ messages: [{ type: "text", text: truncated }],
+ });
+ }
+
+ async replyRich(msg: PlatformMessage, rich: RichMessage): Promise {
+ const flexMessage = richToFlexMessage(rich);
+ await this.client.pushMessage({
+ to: msg.channelId,
+ messages: [flexMessage],
+ });
+ }
+
+ async replyWithFile(
+ msg: PlatformMessage,
+ text: string,
+ _file: FileAttachment,
+ ): Promise {
+ // LINE doesn't support arbitrary file uploads via Messaging API.
+ // Send the text content inline instead.
+ const truncated = truncateForLine(text);
+ await this.client.pushMessage({
+ to: msg.channelId,
+ messages: [{ type: "text", text: truncated }],
+ });
+ }
+
+ showTyping(_msg: PlatformMessage): { stop: () => void } {
+ // LINE doesn't have a typing indicator API
+ return { stop: () => {} };
+ }
+
+ async askQuestion(
+ msg: PlatformMessage,
+ question: InteractiveQuestion,
+ ): Promise {
+ // Use Quick Reply for up to 13 options
+ const quickReplyItems: messagingApi.QuickReplyItem[] = question.options
+ .slice(0, 13)
+ .map((opt) => ({
+ type: "action" as const,
+ action: {
+ type: "message" as const,
+ label: opt.label.slice(0, 20),
+ text: opt.label,
+ },
+ }));
+
+ const questionText = question.header
+ ? `[${question.header}]\n${question.question}`
+ : question.question;
+
+ await this.client.pushMessage({
+ to: msg.channelId,
+ messages: [
+ {
+ type: "text",
+ text: truncateForLine(questionText),
+ quickReply: { items: quickReplyItems },
+ },
+ ],
+ });
+
+ // Wait for user response
+ return this.waitForUserResponse(msg.userId, 60_000);
+ }
+
+ async sendProgress(
+ msg: PlatformMessage,
+ text: string,
+ ): Promise {
+ // LINE messages cannot be edited, so we send the initial message
+ // and subsequent updates as new messages.
+ await this.client.pushMessage({
+ to: msg.channelId,
+ messages: [{ type: "text", text: truncateForLine(text) }],
+ });
+
+ return {
+ update: async (newText: string) => {
+ // Send a new message (LINE doesn't support message editing)
+ await this.client.pushMessage({
+ to: msg.channelId,
+ messages: [{ type: "text", text: truncateForLine(newText) }],
+ }).catch(() => {});
+ },
+ delete: async () => {
+ // LINE doesn't support message deletion via API — no-op
+ },
+ };
+ }
+
+ isAuthorized(userId: string): boolean {
+ return isAllowedLineUser(userId);
+ }
+
+ /**
+ * Wait for a user response via the pending response queue.
+ * Called by askQuestion() — the webhook handler feeds responses via handlePendingResponse().
+ */
+ waitForUserResponse(userId: string, timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ this.pendingResponses.delete(userId);
+ resolve("(timeout)");
+ }, timeoutMs);
+
+ this.pendingResponses.set(userId, { resolve, timeout });
+ });
+ }
+
+ /**
+ * Called by the webhook handler when a user sends a message
+ * while we're waiting for a response to askQuestion().
+ * Returns true if the message was consumed as a pending response.
+ */
+ handlePendingResponse(userId: string, text: string): boolean {
+ const pending = this.pendingResponses.get(userId);
+ if (!pending) return false;
+
+ clearTimeout(pending.timeout);
+ this.pendingResponses.delete(userId);
+ pending.resolve(text);
+ return true;
+ }
+
+ /**
+ * Reply using replyToken (immediate response, one-time use).
+ * Used for the initial "processing..." acknowledgment.
+ */
+ async replyWithToken(replyToken: string, text: string): Promise {
+ await this.client.replyMessage({
+ replyToken,
+ messages: [{ type: "text", text: truncateForLine(text) }],
+ });
+ }
+}
diff --git a/src/platform/types.ts b/src/platform/types.ts
new file mode 100644
index 0000000..968ae8d
--- /dev/null
+++ b/src/platform/types.ts
@@ -0,0 +1,53 @@
+export type PlatformType = "discord" | "line";
+
+/** Platform-agnostic incoming message */
+export interface PlatformMessage {
+ platform: PlatformType;
+ userId: string;
+ displayName: string;
+ channelId: string;
+ content: string;
+ raw: unknown; // Discord Message | LINE WebhookEvent
+}
+
+/** Rich message abstraction (Discord Embed / LINE Flex Message) */
+export interface RichMessage {
+ title?: string;
+ description?: string;
+ color?: number;
+ fields?: { name: string; value: string; inline?: boolean }[];
+ footer?: string;
+}
+
+/** Interactive question (Claude AskUserQuestion bridge) */
+export interface InteractiveQuestion {
+ question: string;
+ header?: string;
+ options: { label: string; description?: string }[];
+ multiSelect?: boolean;
+}
+
+/** File attachment abstraction */
+export interface FileAttachment {
+ name: string;
+ content: Buffer;
+}
+
+/** Editable progress message handle */
+export interface ProgressHandle {
+ update(text: string): Promise;
+ delete(): Promise;
+}
+
+/** Platform adapter — each platform implements this */
+export interface PlatformAdapter {
+ readonly platform: PlatformType;
+ readonly maxMessageLength: number;
+ reply(msg: PlatformMessage, text: string): Promise;
+ replyRich(msg: PlatformMessage, rich: RichMessage): Promise;
+ replyWithFile(msg: PlatformMessage, text: string, file: FileAttachment): Promise;
+ showTyping(msg: PlatformMessage): { stop: () => void };
+ askQuestion(msg: PlatformMessage, question: InteractiveQuestion): Promise;
+ sendProgress(msg: PlatformMessage, text: string): Promise;
+ isAuthorized(userId: string): boolean;
+}
diff --git a/src/sessions/claude.ts b/src/sessions/claude.ts
index 8b925f7..b4944d3 100644
--- a/src/sessions/claude.ts
+++ b/src/sessions/claude.ts
@@ -2,10 +2,9 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
import path from "node:path";
import fs from "node:fs";
import { spawn } from "node:child_process";
-import type { Message, TextChannel } from "discord.js";
import type { ISessionManager, SessionInfo, SessionStats, HistoryEntry } from "./types.js";
import type { CliTool } from "../types.js";
-import { handleAskUserQuestion } from "../utils/discordPrompt.js";
+import type { PlatformMessage, PlatformAdapter, InteractiveQuestion } from "../platform/types.js";
import { withRetry } from "../utils/retry.js";
import { RETRY_MAX_ATTEMPTS, RETRY_BASE_DELAY_MS } from "../config.js";
import { audit, AuditEvent } from "../utils/auditLog.js";
@@ -115,13 +114,18 @@ export class ClaudeSessionManager implements ISessionManager {
return this.busy;
}
- async sendMessage(message: string, discordMessage: Message, onProgress?: (status: string) => void): Promise {
+ async sendMessage(
+ message: string,
+ platformMessage: PlatformMessage,
+ adapter: PlatformAdapter,
+ onProgress?: (status: string) => void,
+ ): Promise {
this.busy = true;
this.abortController = new AbortController();
try {
const resultText = await withRetry(
- () => this._executeQuery(message, discordMessage, onProgress),
+ () => this._executeQuery(message, platformMessage, adapter, onProgress),
{
maxRetries: RETRY_MAX_ATTEMPTS,
baseDelayMs: RETRY_BASE_DELAY_MS,
@@ -129,15 +133,14 @@ export class ClaudeSessionManager implements ISessionManager {
backoffMultiplier: 2,
},
async (attempt, error, delayMs) => {
- audit(AuditEvent.RETRY_ATTEMPTED, discordMessage.author.id, {
+ audit(AuditEvent.RETRY_ATTEMPTED, platformMessage.userId, {
details: { attempt, error: error.message, delayMs },
});
const secs = Math.ceil(delayMs / 1000);
- if ("send" in discordMessage.channel) {
- await discordMessage.channel.send(
- `Retry ${attempt}/${RETRY_MAX_ATTEMPTS} in ${secs}s... (${error.message})`,
- ).catch(() => {});
- }
+ await adapter.reply(
+ platformMessage,
+ `Retry ${attempt}/${RETRY_MAX_ATTEMPTS} in ${secs}s... (${error.message})`,
+ ).catch(() => {});
},
);
@@ -162,7 +165,12 @@ export class ClaudeSessionManager implements ISessionManager {
}
}
- private async _executeQuery(message: string, discordMessage: Message, onProgress?: (status: string) => void): Promise {
+ private async _executeQuery(
+ message: string,
+ platformMessage: PlatformMessage,
+ adapter: PlatformAdapter,
+ onProgress?: (status: string) => void,
+ ): Promise {
const cliPath = resolveClaudeCodePath();
const nodePath = await resolveNodeExecutable();
console.log(` [Claude] cliPath: ${cliPath ?? "(SDK default)"}`);
@@ -208,12 +216,31 @@ export class ClaudeSessionManager implements ISessionManager {
input: Record,
) => {
if (toolName === "AskUserQuestion") {
- const result = await handleAskUserQuestion(
- input as any,
- discordMessage.channel as TextChannel,
- discordMessage.author.id,
- );
- return { behavior: "allow" as const, updatedInput: result };
+ const questions = (input as any).questions as Array<{
+ question: string;
+ header?: string;
+ options: { label: string; description?: string }[];
+ multiSelect?: boolean;
+ }> | undefined;
+
+ if (questions && questions.length > 0) {
+ const answers: Record = {};
+ for (const q of questions) {
+ const iq: InteractiveQuestion = {
+ question: q.question,
+ header: q.header,
+ options: q.options,
+ multiSelect: q.multiSelect,
+ };
+ const answer = await adapter.askQuestion(platformMessage, iq);
+ answers[q.question] = answer;
+ }
+ return {
+ behavior: "allow" as const,
+ updatedInput: { questions, answers },
+ };
+ }
+ return { behavior: "allow" as const, updatedInput: input };
}
return { behavior: "allow" as const, updatedInput: input };
},
diff --git a/src/sessions/gemini.ts b/src/sessions/gemini.ts
index 0159362..f97c642 100644
--- a/src/sessions/gemini.ts
+++ b/src/sessions/gemini.ts
@@ -1,8 +1,7 @@
import { spawn, type ChildProcess } from "node:child_process";
-import type { Message, TextChannel } from "discord.js";
import type { ISessionManager, SessionInfo, SessionStats, HistoryEntry } from "./types.js";
import type { CliTool } from "../types.js";
-import { handleAskUserQuestion } from "../utils/discordPrompt.js";
+import type { PlatformMessage, PlatformAdapter } from "../platform/types.js";
import { buildSafeShellCmd } from "../utils/shellEscape.js";
import { sanitizeOutput } from "../utils/sanitizeOutput.js";
import { withRetry } from "../utils/retry.js";
@@ -68,12 +67,16 @@ export class GeminiSessionManager implements ISessionManager {
return this.proc !== null && this.proc.exitCode === null;
}
- async sendMessage(message: string, discordMessage: Message): Promise {
+ async sendMessage(
+ message: string,
+ platformMessage: PlatformMessage,
+ adapter: PlatformAdapter,
+ ): Promise {
this.lastMessage = message;
try {
const resultText = await withRetry(
- () => this._executeQuery(message, discordMessage),
+ () => this._executeQuery(message, platformMessage, adapter),
{
maxRetries: RETRY_MAX_ATTEMPTS,
baseDelayMs: RETRY_BASE_DELAY_MS,
@@ -81,15 +84,14 @@ export class GeminiSessionManager implements ISessionManager {
backoffMultiplier: 2,
},
async (attempt, error, delayMs) => {
- audit(AuditEvent.RETRY_ATTEMPTED, discordMessage.author.id, {
+ audit(AuditEvent.RETRY_ATTEMPTED, platformMessage.userId, {
details: { attempt, error: error.message, delayMs },
});
const secs = Math.ceil(delayMs / 1000);
- if ("send" in discordMessage.channel) {
- await discordMessage.channel.send(
- `Retry ${attempt}/${RETRY_MAX_ATTEMPTS} in ${secs}s... (${error.message})`,
- ).catch(() => {});
- }
+ await adapter.reply(
+ platformMessage,
+ `Retry ${attempt}/${RETRY_MAX_ATTEMPTS} in ${secs}s... (${error.message})`,
+ ).catch(() => {});
},
);
@@ -111,7 +113,11 @@ export class GeminiSessionManager implements ISessionManager {
}
}
- private _executeQuery(message: string, discordMessage: Message): Promise {
+ private _executeQuery(
+ message: string,
+ platformMessage: PlatformMessage,
+ adapter: PlatformAdapter,
+ ): Promise {
const cmd = this.buildCommand(message);
const shellCmd = buildSafeShellCmd(cmd);
@@ -161,7 +167,8 @@ export class GeminiSessionManager implements ISessionManager {
await this.handleToolUse(
event.tool_name,
event.tool_input,
- discordMessage,
+ platformMessage,
+ adapter,
);
}
break;
@@ -344,30 +351,24 @@ export class GeminiSessionManager implements ISessionManager {
/**
* Handle Gemini tool_use events that require user interaction.
- * Maps to Discord UI via handleAskUserQuestion.
+ * Uses the platform adapter to ask questions.
*/
private async handleToolUse(
toolName: string,
input: Record,
- discordMessage: Message,
+ platformMessage: PlatformMessage,
+ adapter: PlatformAdapter,
): Promise {
- // Gemini's ask_followup tool structure (hypothetical - adjust based on actual format)
if (toolName === "ask_followup" && input.question) {
- const questions = [
- {
- question: String(input.question),
- header: "Gemini asks",
- options: Array.isArray(input.options)
- ? (input.options as string[]).map((opt) => ({ label: opt }))
- : [{ label: "Yes" }, { label: "No" }],
- },
- ];
-
- await handleAskUserQuestion(
- { questions },
- discordMessage.channel as TextChannel,
- discordMessage.author.id,
- );
+ const options = Array.isArray(input.options)
+ ? (input.options as string[]).map((opt) => ({ label: opt }))
+ : [{ label: "Yes" }, { label: "No" }];
+
+ await adapter.askQuestion(platformMessage, {
+ question: String(input.question),
+ header: "Gemini asks",
+ options,
+ });
}
}
}
diff --git a/src/sessions/multiSession.ts b/src/sessions/multiSession.ts
index b116c4d..e279e72 100644
--- a/src/sessions/multiSession.ts
+++ b/src/sessions/multiSession.ts
@@ -1,6 +1,6 @@
-import type { Message } from "discord.js";
import type { ISessionManager } from "./types.js";
import type { NamedSession } from "../types.js";
+import type { PlatformMessage, PlatformAdapter } from "../platform/types.js";
import { CLI_TOOLS } from "../config.js";
import { createSession } from "../bot.js";
import { wrapWithSecurityContext } from "../utils/promptGuard.js";
@@ -172,7 +172,8 @@ export class MultiSessionManager {
async sendMessage(
sessionName: string | null,
message: string,
- discordMessage: Message,
+ platformMessage: PlatformMessage,
+ adapter: PlatformAdapter,
onProgress?: (status: string) => void,
): Promise {
const targetName = sessionName ?? this.activeSessionName;
@@ -199,7 +200,7 @@ export class MultiSessionManager {
// Use session-specific cwd for security context
const sessionCwd = session.cwd;
const wrappedMessage = wrapWithSecurityContext(message, sessionCwd);
- const result = await session.manager.sendMessage(wrappedMessage, discordMessage, onProgress);
+ const result = await session.manager.sendMessage(wrappedMessage, platformMessage, adapter, onProgress);
this.schedulePersist();
return result;
}
diff --git a/src/sessions/subprocess.ts b/src/sessions/subprocess.ts
index 8707842..fbff1ab 100644
--- a/src/sessions/subprocess.ts
+++ b/src/sessions/subprocess.ts
@@ -1,7 +1,7 @@
import { spawn, type ChildProcess } from "node:child_process";
-import type { Message } from "discord.js";
import type { ISessionManager, SessionInfo, SessionStats, HistoryEntry } from "./types.js";
import type { CliTool } from "../types.js";
+import type { PlatformMessage, PlatformAdapter } from "../platform/types.js";
import { buildSafeShellCmd } from "../utils/shellEscape.js";
import { sanitizeOutput } from "../utils/sanitizeOutput.js";
import { withRetry } from "../utils/retry.js";
@@ -53,7 +53,11 @@ export class SubprocessSessionManager implements ISessionManager {
return this.proc !== null && this.proc.exitCode === null;
}
- async sendMessage(message: string, _discordMessage: Message): Promise {
+ async sendMessage(
+ message: string,
+ platformMessage: PlatformMessage,
+ adapter: PlatformAdapter,
+ ): Promise {
this.lastMessage = message;
try {
@@ -66,15 +70,14 @@ export class SubprocessSessionManager implements ISessionManager {
backoffMultiplier: 2,
},
async (attempt, error, delayMs) => {
- audit(AuditEvent.RETRY_ATTEMPTED, _discordMessage.author.id, {
+ audit(AuditEvent.RETRY_ATTEMPTED, platformMessage.userId, {
details: { attempt, error: error.message, delayMs },
});
const secs = Math.ceil(delayMs / 1000);
- if ("send" in _discordMessage.channel) {
- await _discordMessage.channel.send(
- `Retry ${attempt}/${RETRY_MAX_ATTEMPTS} in ${secs}s... (${error.message})`,
- ).catch(() => {});
- }
+ await adapter.reply(
+ platformMessage,
+ `Retry ${attempt}/${RETRY_MAX_ATTEMPTS} in ${secs}s... (${error.message})`,
+ ).catch(() => {});
},
);
diff --git a/src/slashCommands/askSlash.ts b/src/slashCommands/askSlash.ts
index f7b91da..e75e891 100644
--- a/src/slashCommands/askSlash.ts
+++ b/src/slashCommands/askSlash.ts
@@ -1,4 +1,4 @@
-import { SlashCommandBuilder, type ChatInputCommandInteraction, type Message } from "discord.js";
+import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js";
import path from "node:path";
import type { BotClient } from "../types.js";
import { CLI_TOOLS } from "../config.js";
@@ -7,6 +7,8 @@ import { getMultiSessionManager } from "../sessions/multiSession.js";
import { checkPromptInjection } from "../utils/promptGuard.js";
import { audit, AuditEvent } from "../utils/auditLog.js";
import type { SlashCommand } from "./index.js";
+import type { PlatformMessage } from "../platform/types.js";
+import { getDiscordAdapter } from "../platform/discordAdapter.js";
const data = new SlashCommandBuilder()
.setName("ask")
@@ -67,9 +69,16 @@ async function execute(interaction: ChatInputCommandInteraction, client: BotClie
await interaction.deferReply();
try {
- // Create a proxy object for the session manager (which expects a Message)
- const proxyMessage = { channel: interaction.channel, author: { id: interaction.user.id } } as unknown as Message;
- const result = await multiSession.sendMessage(sessionName, msg, proxyMessage);
+ const platformMsg: PlatformMessage = {
+ platform: "discord",
+ userId: interaction.user.id,
+ displayName: interaction.user.displayName ?? interaction.user.username,
+ channelId: interaction.channelId,
+ content: msg,
+ raw: interaction,
+ };
+ const adapter = getDiscordAdapter();
+ const result = await multiSession.sendMessage(sessionName, msg, platformMsg, adapter);
const tool = CLI_TOOLS[namedSession.cliName];
const folder = path.basename(client.workingDir);
diff --git a/src/slashCommands/taskSlash.ts b/src/slashCommands/taskSlash.ts
index 60c122d..bf4177c 100644
--- a/src/slashCommands/taskSlash.ts
+++ b/src/slashCommands/taskSlash.ts
@@ -13,6 +13,8 @@ import {
import { getMultiSessionManager } from "../sessions/multiSession.js";
import { checkPromptInjection } from "../utils/promptGuard.js";
import type { SlashCommand } from "./index.js";
+import type { PlatformMessage } from "../platform/types.js";
+import { getDiscordAdapter } from "../platform/discordAdapter.js";
const data = new SlashCommandBuilder()
.setName("task")
@@ -129,10 +131,20 @@ async function execute(interaction: ChatInputCommandInteraction, client: BotClie
const task = pending[i];
await updateTaskStatus(client.workingDir, task.id, "running");
try {
+ const platformMsg: PlatformMessage = {
+ platform: "discord",
+ userId: interaction.user.id,
+ displayName: interaction.user.displayName ?? interaction.user.username,
+ channelId: interaction.channelId,
+ content: task.content,
+ raw: interaction,
+ };
+ const adapter = getDiscordAdapter();
const result = await multiSession.sendMessage(
null,
task.content,
- interaction as unknown as any,
+ platformMsg,
+ adapter,
);
await updateTaskStatus(client.workingDir, task.id, "completed", result);
completed++;
diff --git a/src/types.ts b/src/types.ts
index 941733a..efc0c22 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,4 +1,5 @@
import { Client, Collection, Message, TextBasedChannel } from "discord.js";
+import type { PlatformMessage, PlatformAdapter } from "./platform/types.js";
// --- Command system (discord.py Cog 대응) ---
@@ -97,7 +98,12 @@ export interface PersistedSessionState {
export interface ISessionManager {
readonly isBusy: boolean;
- sendMessage(message: string, discordMessage: Message, onProgress?: (status: string) => void): Promise;
+ sendMessage(
+ message: string,
+ platformMessage: PlatformMessage,
+ adapter: PlatformAdapter,
+ onProgress?: (status: string) => void,
+ ): Promise;
kill(): Promise;
newSession(): Promise;
getInfo(): SessionInfo;
diff --git a/src/utils/security.ts b/src/utils/security.ts
index 331e143..30075fb 100644
--- a/src/utils/security.ts
+++ b/src/utils/security.ts
@@ -1,4 +1,5 @@
-import { ALLOWED_USER_IDS, BLOCKED_COMMANDS } from "../config.js";
+import { ALLOWED_USER_IDS, ALLOWED_LINE_USER_IDS, BLOCKED_COMMANDS } from "../config.js";
+import type { PlatformType } from "../platform/types.js";
/** Check if a Discord user is whitelisted. Empty whitelist = deny all. */
export function isAllowedUser(userId: string): boolean {
@@ -9,6 +10,27 @@ export function isAllowedUser(userId: string): boolean {
return ALLOWED_USER_IDS.has(userId);
}
+/** Check if a LINE user is whitelisted. Empty whitelist = deny all. */
+export function isAllowedLineUser(userId: string): boolean {
+ if (ALLOWED_LINE_USER_IDS.size === 0) {
+ console.warn("[security] ALLOWED_LINE_USER_IDS is empty — all LINE access denied. Set user IDs in .env.");
+ return false;
+ }
+ return ALLOWED_LINE_USER_IDS.has(userId);
+}
+
+/** Check authorization based on platform type. */
+export function isAuthorizedOnPlatform(platform: PlatformType, userId: string): boolean {
+ switch (platform) {
+ case "discord":
+ return isAllowedUser(userId);
+ case "line":
+ return isAllowedLineUser(userId);
+ default:
+ return false;
+ }
+}
+
/**
* Dangerous executable patterns that should be blocked even when
* they appear in the middle of a command (e.g. piped or chained).
From 35e96e322ee8ed6ebcf83f5ef245e15e2c515691 Mon Sep 17 00:00:00 2001
From: LeeJaeYeon <0814jyinjs@com2us.com>
Date: Thu, 12 Feb 2026 14:26:31 +0900
Subject: [PATCH 2/3] readme eng
---
README_EN.md | 125 +++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 117 insertions(+), 8 deletions(-)
diff --git a/README_EN.md b/README_EN.md
index a6eb81d..42d328f 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -4,7 +4,7 @@
### Your AI Codes While You Sleep
-Control AI CLI tools on your PC remotely via Discord
+Control AI CLI tools on your PC remotely via Discord / LINE
[](../../releases/latest)
[](LICENSE)
@@ -39,13 +39,13 @@ Control AI CLI tools on your PC remotely via Discord
### 🤖 Remote AI Control
-Control Claude Code, Gemini CLI, OpenCode remotely via Discord messages
+Control Claude Code, Gemini CLI, OpenCode remotely via Discord / LINE messages
|
### 💬 Interactive Responses
-When Claude asks questions, respond instantly with Discord buttons/dropdowns
+When Claude asks questions, respond instantly with Discord buttons or LINE Quick Reply
|
@@ -75,6 +75,20 @@ Create, switch, and manage multiple named AI sessions simultaneously
### 📊 Git Diff Visualization
Render git diffs as PNG images viewable directly in Discord
+
+
+
+|
+
+### 📱 Multi-Platform
+Supports Discord and LINE simultaneously. Use one or both platforms
+
+ |
+
+
+### 🔌 Easy Deployment
+Connect LINE webhook without a dedicated server using Cloudflare Tunnel
+
|
@@ -159,7 +173,7 @@ Type `!ask review my code` in Discord!
| **Gemini CLI** | Stream JSON | ✅ | ✅ |
| **OpenCode** | subprocess | ❌ | ❌ |
-> **Claude Code & Gemini CLI** support interactive responses. When AI asks for choices, you can respond with Discord buttons/dropdowns! (4 or fewer options → buttons, 5+ → dropdown menu)
+> **Claude Code & Gemini CLI** support interactive responses. When AI asks for choices, respond with Discord buttons/dropdowns or LINE Quick Reply!
---
@@ -222,12 +236,18 @@ npx tsx src/bot.ts
| Variable | Required | Default | Description |
|:---------|:--------:|:-------:|:------------|
-| `DISCORD_BOT_TOKEN` | ✅ | — | Discord bot token |
-| `ALLOWED_USER_IDS` | ✅ | — | Allowed user IDs (comma-separated) |
+| `DISCORD_BOT_TOKEN` | ⚡ | — | Discord bot token (required for Discord) |
+| `ALLOWED_USER_IDS` | ⚡ | — | Allowed Discord user IDs (comma-separated) |
| `ANTHROPIC_API_KEY` | ❌ | — | API key (not needed with `claude login`) |
| `COMMAND_PREFIX` | ❌ | `!` | Command prefix |
| `COMMAND_TIMEOUT` | ❌ | `30` | CMD timeout (seconds, range: 5–120) |
| `AI_CLI_TIMEOUT` | ❌ | `300` | AI timeout (seconds, range: 30–1800) |
+| `LINE_CHANNEL_ACCESS_TOKEN` | ⚡ | — | LINE channel access token (required for LINE) |
+| `LINE_CHANNEL_SECRET` | ⚡ | — | LINE channel secret (required for LINE) |
+| `LINE_WEBHOOK_PORT` | ❌ | `3000` | LINE webhook server port |
+| `ALLOWED_LINE_USER_IDS` | ⚡ | — | Allowed LINE user IDs (comma-separated) |
+
+> ⚡ = Required for the respective platform. If you only use Discord, LINE settings are not needed, and vice versa.
### Multiple Users
@@ -280,6 +300,92 @@ See [SECURITY.md](SECURITY.md) for detailed security policies.
---
+## 💚 Setting Up LINE Bot
+
+
+Expand detailed guide
+
+LINE support currently requires manual installation only.
+
+### Step 1: Create LINE Official Account
+
+1. Go to [LINE Official Account Manager](https://manager.line.biz/) → Log in
+2. **Create new account** → Enter account name (e.g., `AIDevelop Bot`)
+3. Select category → Complete creation
+
+### Step 2: Enable Messaging API
+
+1. Select the created account → **Settings** → **Messaging API** tab
+2. Click **Enable Messaging API** → Select a provider (or create new one)
+3. Verify the channel at [LINE Developers Console](https://developers.line.biz/console/)
+
+### Step 3: Copy Token & Secret
+
+Go to [LINE Developers Console](https://developers.line.biz/console/) → Select channel:
+
+1. **Basic settings** tab → Copy `Channel secret`
+2. **Messaging API** tab → `Channel access token` → Click **Issue** → Copy token
+
+### Step 4: Configure .env
+
+Add the following to your existing `.env` file:
+
+```env
+LINE_CHANNEL_ACCESS_TOKEN=your_access_token
+LINE_CHANNEL_SECRET=your_channel_secret
+LINE_WEBHOOK_PORT=3000
+ALLOWED_LINE_USER_IDS=your_line_user_id
+```
+
+> LINE user ID can be found at [LINE Developers Console](https://developers.line.biz/console/) → Basic settings → `Your user ID` (string starting with U)
+
+### Step 5: Set Up Webhook URL
+
+LINE operates via webhooks, so your bot server must be accessible from the internet.
+
+**Local testing (Cloudflare Tunnel — free)**:
+```bash
+# 1. Install Cloudflare Tunnel (one time)
+winget install cloudflare.cloudflared
+
+# 2. Run tunnel (in a separate terminal while bot is running)
+cloudflared tunnel --url http://localhost:3000
+```
+
+Copy the URL shown after running (e.g., `https://xxxx-xxxx.trycloudflare.com`).
+
+**Production**: Run the bot on a server with a fixed domain, or use a reverse proxy (nginx, etc.).
+
+### Step 6: Register LINE Webhook
+
+1. [LINE Developers Console](https://developers.line.biz/console/) → Channel → **Messaging API** tab
+2. **Webhook URL** → Enter `https://your-url/webhook` → **Update**
+3. **Use webhook** → Enable
+4. Click **Verify** → Confirm `Success`
+
+### Step 7: Disable Auto-Response
+
+1. [LINE Official Account Manager](https://manager.line.biz/) → Select account
+2. **Response settings**
+3. **Response messages** → **Off**
+4. **Webhook** → **On**
+
+> If auto-response is enabled, LINE will send default responses instead of your bot's replies.
+
+### Step 8: Run & Test
+
+```bash
+npx tsx src/bot.ts
+```
+
+If the console shows `[platform] Active: Discord + LINE` (or `LINE only`), it's working!
+
+Send `!ask hello` to the bot in LINE to test.
+
+
+
+---
+
## 🛠️ Troubleshooting
@@ -291,7 +397,10 @@ See [SECURITY.md](SECURITY.md) for detailed security policies.
| `Node.js is not installed` | Install [Node.js v18+](https://nodejs.org/) |
| Claude auth error | Run `claude login` or set API key |
| `You are not authorized` | Check ID with `!myid` → Add to `.env` |
-| Bot not responding | Enable **Message Content Intent** |
+| Bot not responding (Discord) | Enable **Message Content Intent** |
+| LINE bot not responding | 1) Check webhook URL ends with `/webhook` 2) Enable **Use webhook** 3) Disable **Response messages** |
+| LINE webhook Verify fails | Ensure bot is running. If using Cloudflare Tunnel, tunnel must also be running |
+| No response from LINE | Check `ALLOWED_LINE_USER_IDS` includes your ID |
@@ -377,7 +486,7 @@ Can't decide who to support? Spin the wheel!
-**Code with AI from anywhere via Discord** 🚀
+**Code with AI from anywhere via Discord / LINE** 🚀
[⬆ Back to Top](#ai-cli-gateway-bot)
From b0c1f9c707a74e25cae48b9424e67b28cdb95f76 Mon Sep 17 00:00:00 2001
From: LeeJaeYeon <0814jyinjs@com2us.com>
Date: Thu, 12 Feb 2026 14:34:26 +0900
Subject: [PATCH 3/3] fix copilot issue
---
src/lineBot.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/lineBot.ts b/src/lineBot.ts
index 33f6a05..84fad59 100644
--- a/src/lineBot.ts
+++ b/src/lineBot.ts
@@ -229,7 +229,7 @@ export class LineBotServer {
try {
await handler(ctx);
} catch (err: any) {
- console.error(` [LINE] Command ${cmdName} error:`, err);
+ console.error(" [LINE] Command %s error:", cmdName, err);
audit(AuditEvent.COMMAND_ERROR, ctx.message.userId, {
command: cmdName,
success: false,