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 도구를 원격 제어하세요 [![GitHub release](https://img.shields.io/github/v/release/OsgoodYZ/osgoodAI?style=flat-square)](../../releases/latest) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) @@ -39,13 +39,13 @@ Discord에서 PC의 AI CLI 도구를 원격 제어하세요 ### 🤖 AI 원격 제어 -Discord 메시지로 Claude Code, Gemini CLI, OpenCode를 원격으로 조작 +Discord / LINE 메시지로 Claude Code, Gemini CLI, OpenCode를 원격으로 조작 ### 💬 인터랙티브 응답 -Claude가 물어보면 Discord 버튼/드롭다운으로 바로 응답 +Claude가 물어보면 Discord 버튼 / LINE Quick Reply로 바로 응답 @@ -75,6 +75,20 @@ exe 파일 더블클릭으로 바로 시작 (Node.js 자동 다운로드) ### 📊 Git Diff 시각화 git diff를 PNG 이미지로 렌더링하여 Discord에서 바로 확인 + + + + + +### 📱 멀티 플랫폼 +Discord와 LINE을 동시에 지원. 플랫폼 하나만 또는 둘 다 사용 가능 + + + + +### 🔌 쉬운 배포 +Cloudflare Tunnel 등으로 별도 서버 없이 LINE 웹훅 연동 가능 + @@ -159,7 +173,7 @@ Discord에서 `!ask 코드 리뷰해줘` 입력! | **Gemini CLI** | Stream JSON | ✅ | ✅ | | **OpenCode** | subprocess | ❌ | ❌ | -> **Claude Code & Gemini CLI**는 AI가 선택지를 물어보면 Discord 버튼/드롭다운으로 응답할 수 있어요! (4개 이하 → 버튼, 5개 이상 → 드롭다운 메뉴) +> **Claude Code & Gemini CLI**는 AI가 선택지를 물어보면 Discord 버튼/드롭다운 또는 LINE Quick Reply로 응답할 수 있어요! --- @@ -222,13 +236,19 @@ npx tsx src/bot.ts | 변수 | 필수 | 기본값 | 설명 | |:-----|:----:|:------:|:-----| -| `DISCORD_BOT_TOKEN` | ✅ | — | Discord 봇 토큰 | -| `ALLOWED_USER_IDS` | ✅ | — | 허용할 유저 ID (17-19자리 숫자, 쉼표 구분) | +| `DISCORD_BOT_TOKEN` | ⚡ | — | Discord 봇 토큰 (Discord 사용 시 필수) | +| `ALLOWED_USER_IDS` | ⚡ | — | 허용할 Discord 유저 ID (쉼표 구분) | | `ANTHROPIC_API_KEY` | ❌ | — | API 키 (`claude login` 시 불필요) | | `COMMAND_PREFIX` | ❌ | `!` | 명령어 접두사 | | `COMMAND_TIMEOUT` | ❌ | `30` | CMD 타임아웃 (초, 범위: 5~120) | | `AI_CLI_TIMEOUT` | ❌ | `300` | AI 타임아웃 (초, 범위: 30~1800) | +| `LINE_CHANNEL_ACCESS_TOKEN` | ⚡ | — | LINE 채널 액세스 토큰 (LINE 사용 시 필수) | +| `LINE_CHANNEL_SECRET` | ⚡ | — | LINE 채널 시크릿 (LINE 사용 시 필수) | +| `LINE_WEBHOOK_PORT` | ❌ | `3000` | LINE 웹훅 서버 포트 | +| `ALLOWED_LINE_USER_IDS` | ⚡ | — | 허용할 LINE 유저 ID (쉼표 구분) | +> ⚡ = 해당 플랫폼 사용 시 필수. Discord만 사용하면 LINE 설정은 불필요하고, LINE만 사용하면 Discord 설정은 불필요합니다. +> > ⚠️ **보안 주의**: Discord ID는 17-19자리 **숫자**입니다 (유저네임 ❌). ID는 외부에 노출하지 마세요! ### 여러 유저 허용 @@ -282,6 +302,92 @@ ALLOWED_USER_IDS=111111111111111111,222222222222222222 --- +## 💚 LINE 봇 만들기 + +
+상세 가이드 펼치기 + +현재 LINE의 경우 수동 설치 방법만을 지원합니다. + +### Step 1: LINE Official Account 생성 + +1. [LINE Official Account Manager](https://manager.line.biz/) 접속 → 로그인 +2. **새 계정 만들기** → 계정 이름 입력 (예: `AIDevelop Bot`) +3. 카테고리 선택 → 계정 생성 완료 + +### Step 2: Messaging API 활성화 + +1. 생성된 계정의 **설정** → **Messaging API** 탭 +2. **Messaging API 사용** 클릭 → LINE Developers 제공자 선택 (없으면 새로 생성) +3. 활성화 완료 후 [LINE Developers Console](https://developers.line.biz/console/) 에서 채널 확인 + +### Step 3: 토큰 & 시크릿 복사 + +[LINE Developers Console](https://developers.line.biz/console/) → 채널 선택: + +1. **Basic settings** 탭 → `Channel secret` 복사 +2. **Messaging API** 탭 → `Channel access token` → **Issue** 클릭 → 토큰 복사 + +### Step 4: .env 설정 + +이미 생성된 .env 파일을 열어 아래 내용을 추가 + +```env +LINE_CHANNEL_ACCESS_TOKEN=발급받은_액세스_토큰 +LINE_CHANNEL_SECRET=발급받은_채널_시크릿 +LINE_WEBHOOK_PORT=3000 +ALLOWED_LINE_USER_IDS=내_LINE_유저_ID +``` + +> LINE 유저 ID는 [LINE Developers Console](https://developers.line.biz/console/) → Basic settings → `Your user ID`에서 확인 (U로 시작하는 문자열) + +### Step 5: 웹훅 URL 설정 + +LINE은 웹훅 방식으로 동작하므로, 봇 서버가 외부에서 접근 가능해야 합니다. + +**로컬 테스트 (Cloudflare Tunnel 무료)**: +```bash +# 1. Cloudflare Tunnel 설치 (한 번만) +winget install cloudflare.cloudflared + +# 2. 터널 실행 (봇 실행 중에 별도 터미널에서) +cloudflared tunnel --url http://localhost:3000 +``` + +터널 실행 후 표시되는 URL (예: `https://xxxx-xxxx.trycloudflare.com`)을 복사합니다. + +**운영 환경**: 고정 도메인이 있는 서버에서 봇을 실행하거나, 리버스 프록시(nginx 등)를 사용하세요. + +### Step 6: LINE 웹훅 등록 + +1. [LINE Developers Console](https://developers.line.biz/console/) → 채널 → **Messaging API** 탭 +2. **Webhook URL** → `https://xxxx-xxxx.trycloudflare.com/webhook` 입력 → **Update** +3. **Use webhook** → 켜기 +4. **Verify** 클릭 → `Success` 확인 + +### Step 7: 자동 응답 끄기 + +1. [LINE Official Account Manager](https://manager.line.biz/) → 계정 선택 +2. **응답 설정** (또는 **Settings** → **Response settings**) +3. **응답 메시지** → **끄기** +4. **Webhook** → **켜기** + +> 자동 응답이 켜져 있으면 봇 응답 대신 기본 자동 응답이 전송됩니다. + +### Step 8: 봇 실행 & 테스트 + +```bash +npx tsx src/bot.ts +``` + +콘솔에 `[platform] Active: Discord + LINE` (또는 `LINE only`)이 표시되면 성공! + +LINE에서 봇에게 `!ask 안녕` 메시지를 보내 테스트하세요. + +
+ +--- + ## 🛠️ 문제 해결
@@ -293,7 +399,10 @@ ALLOWED_USER_IDS=111111111111111111,222222222222222222 | `Node.js가 설치되어 있지 않습니다` | [Node.js v18+](https://nodejs.org/) 설치 | | Claude 인증 에러 | `claude login` 실행 또는 API 키 설정 | | `You are not authorized` | `!myid`로 ID 확인 → `.env`에 추가 | -| 봇이 반응 없음 | **Message Content Intent** 활성화 확인 | +| 봇이 반응 없음 (Discord) | **Message Content Intent** 활성화 확인 | +| LINE 봇이 반응 없음 | 1) 웹훅 URL이 `/webhook`으로 끝나는지 확인 2) **Use webhook** 켜기 3) **응답 메시지** 끄기 | +| LINE 웹훅 Verify 실패 | 봇이 실행 중인지 확인. Cloudflare Tunnel 사용 시 터널도 실행 중이어야 함 | +| LINE에서 결과가 안 와요 | `ALLOWED_LINE_USER_IDS`에 본인 ID가 등록되어 있는지 확인 |
@@ -379,7 +488,7 @@ dist/
-**Discord로 어디서든 AI와 코딩하세요** 🚀 +**Discord / LINE으로 어디서든 AI와 코딩하세요** 🚀 [⬆ 맨 위로](#ai-cli-gateway-bot) 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 [![GitHub release](https://img.shields.io/github/v/release/OsgoodYZ/osgoodAI?style=flat-square)](../../releases/latest) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](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) 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..84fad59 --- /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 %s error:", cmdName, 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).