From 94caf1c4da94676392d4a27958c725fcdd077055 Mon Sep 17 00:00:00 2001 From: kim Date: Wed, 8 Jul 2026 00:41:54 +0900 Subject: [PATCH] =?UTF-8?q?v3.4.0:=20=EC=84=9C=EB=B2=84=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20Android=20=EC=97=90=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20(=EC=BD=94=EB=93=9C=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EB=82=B4=EC=9E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker(budtmo/docker-android) + noVNC로 코드 에디터 하단 패널에 실제 조작 가능한 Android 에뮬레이터 화면을 띄운다. 세션 생명주기(생성/유휴 타임아웃/정리)는 기존 Arduino QEMU 라우트 패턴을 따르고, noVNC 화면은 Stirling-PDF 프록시와 같은 방식으로 게이트웨이를 통해 리버스 프록시한다. - src/gateway/routes-android.ts: Docker 세션 관리 + noVNC HTTP/WS 프록시 - web-ui/android-emulator.html: 부팅 폴링 + noVNC 임베드 페이지 - code.js/code.html: "🤖 Android" 버튼, 기존 에뮬레이터 패널 재사용 Co-Authored-By: Claude Sonnet 5 --- package.json | 2 +- src/gateway/kakao-notify.ts | 64 +++++++++ src/gateway/routes-android.ts | 255 ++++++++++++++++++++++++++++++++++ src/gateway/server-v2.ts | 58 ++++++-- src/tools/kakao.ts | 64 ++++++++- web-ui/android-emulator.html | 104 ++++++++++++++ web-ui/code.html | 2 +- web-ui/code.js | 22 +++ web-ui/index.html | 4 +- 9 files changed, 556 insertions(+), 19 deletions(-) create mode 100644 src/gateway/routes-android.ts create mode 100644 web-ui/android-emulator.html diff --git a/package.json b/package.json index 33059ca..7a23076 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallclaw", - "version": "3.3.0", + "version": "3.4.0", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "main": "dist/index.js", "bin": { diff --git a/src/gateway/kakao-notify.ts b/src/gateway/kakao-notify.ts index 21c70fb..b5ac62b 100644 --- a/src/gateway/kakao-notify.ts +++ b/src/gateway/kakao-notify.ts @@ -15,9 +15,36 @@ * channels.kakao.refreshToken — OAuth 리프레시 토큰 (OAuth 후 자동 저장) */ +import crypto from 'crypto'; + const KAKAO_TOKEN_URL = 'https://kauth.kakao.com/oauth/token'; const KAKAO_MEMO_URL = 'https://kapi.kakao.com/v2/api/talk/memo/default/send'; +// ── 이미지 전송용 임시 공개 링크 토큰 ───────────────────────────────────────── +// 카카오 서버가 image_url을 직접 fetch하므로, 워크스페이스 파일을 인증 없이 +// 짧은 시간만 노출하는 1회성 토큰 URL이 필요하다 (/api/kakao/img/:token). +interface KakaoImageTokenEntry { absPath: string; expiresAt: number; } +const kakaoImageTokens = new Map(); +const KAKAO_IMAGE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10분 + +export function registerKakaoImageToken(absPath: string): string { + const token = crypto.randomBytes(16).toString('hex'); + kakaoImageTokens.set(token, { absPath, expiresAt: Date.now() + KAKAO_IMAGE_TOKEN_TTL_MS }); + return token; +} + +export function resolveKakaoImageToken(token: string): string | null { + const entry = kakaoImageTokens.get(token); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { kakaoImageTokens.delete(token); return null; } + return entry.absPath; +} + +setInterval(() => { + const now = Date.now(); + for (const [k, v] of kakaoImageTokens) if (now > v.expiresAt) kakaoImageTokens.delete(k); +}, 5 * 60 * 1000).unref?.(); + export interface KakaoNotifyConfig { appKey: string; refreshToken: string; @@ -64,6 +91,43 @@ export class KakaoNotify { } } + /** 카카오톡 "나에게 보내기" — 이미지(피드 템플릿). image_url은 카카오 서버가 직접 fetch 가능한 공개 URL이어야 함. */ + async sendImage(imageUrl: string, opts?: { title?: string; description?: string }): Promise { + if (!this.enabled) return; + await this.ensureAccessToken(); + + const templateObject = JSON.stringify({ + object_type: 'feed', + content: { + title: (opts?.title || '📷 이미지').slice(0, 200), + description: (opts?.description || '').slice(0, 400), + image_url: imageUrl, + link: { web_url: '', mobile_web_url: '' }, + }, + }); + + const doSend = () => fetch(KAKAO_MEMO_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ template_object: templateObject }), + }); + + let resp = await doSend(); + if (!resp.ok && resp.status === 401) { + this.accessToken = ''; + this.tokenExpiry = 0; + await this.ensureAccessToken(); + resp = await doSend(); + } + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`KakaoNotify sendImage failed: ${resp.status} ${body}`); + } + } + private async sendChunk(text: string): Promise { await this.ensureAccessToken(); diff --git a/src/gateway/routes-android.ts b/src/gateway/routes-android.ts new file mode 100644 index 0000000..095c8a8 --- /dev/null +++ b/src/gateway/routes-android.ts @@ -0,0 +1,255 @@ +import express from 'express'; +import net from 'net'; +import http from 'http'; +import { execFile } from 'child_process'; + +// ── Android emulator sessions (Docker + noVNC) ────────────────────────────── +// One container per session, running budtmo/docker-android with noVNC exposed +// over HTTP/WS and ADB over TCP. Both ports are bound to 127.0.0.1 only — the +// authenticated gateway proxy (HTTP routes below + attachAndroidWsProxy) is the +// sole access path from the browser. + +const ANDROID_IMAGE = 'budtmo/docker-android:emulator_11.0'; +const VNC_PORT_BASE = 26080; +const ADB_PORT_BASE = 25555; +const HARD_TTL_MS = 30 * 60 * 1000; // 30분 하드 킬 +const IDLE_TTL_MS = 5 * 60 * 1000; // 마지막 뷰어 접속 종료 5분 후 킬 +const BOOT_TIMEOUT_MS = 90 * 1000; // noVNC 포트 준비 대기 최대 90초 +const MAX_CONCURRENT = 2; // 전체 동시 세션 상한 + +interface AndroidSession { + containerId: string; + username: string; + vncPort: number; + adbPort: number; + createdAt: number; + ready: boolean; + timer: ReturnType; + idleTimer?: ReturnType; + viewerConns: Set; +} + +const androidSessions = new Map(); + +function killAndroidSession(id: string) { + const s = androidSessions.get(id); + if (!s) return; + androidSessions.delete(id); + clearTimeout(s.timer); + clearTimeout(s.idleTimer); + for (const sock of s.viewerConns) { try { sock.destroy(); } catch {} } + execFile('docker', ['rm', '-f', s.containerId], () => {}); +} + +function _allocatePort(base: number, taken: Set): number { + for (let i = 0; i < MAX_CONCURRENT + 1; i++) { + if (!taken.has(base + i)) return base + i; + } + throw new Error('포트 할당 실패'); +} + +// If the container behind a session died outside our control (OOM, manual +// `docker rm`, host restart of the daemon, etc.) the in-memory entry would +// otherwise sit there forever blocking that user's per-session cap. Verify +// liveness before trusting the map. +function _isContainerRunning(containerId: string): Promise { + return new Promise((resolve) => { + execFile('docker', ['inspect', '--format', '{{.State.Running}}', containerId], (err, stdout) => { + resolve(!err && stdout.trim() === 'true'); + }); + }); +} + +async function _reapDeadSession(id: string): Promise { + const s = androidSessions.get(id); + if (!s) return false; + const alive = await _isContainerRunning(s.containerId); + if (!alive) { killAndroidSession(id); return true; } + return false; +} + +// A bare TCP connect succeeds slightly before websockify's HTTP layer inside +// the container is actually ready to answer requests (brief accept-backlog +// window right after the port opens), which was causing a one-shot 502 on +// the very first vnc_lite.html fetch. Poll with a real HTTP GET instead. +function _waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = http.get({ host: '127.0.0.1', port, path: '/vnc_lite.html', timeout: 3000 }, (res) => { + res.resume(); + if (res.statusCode && res.statusCode < 500) { resolve(); return; } + retry(); + }); + req.on('error', retry); + req.on('timeout', () => { req.destroy(); retry(); }); + function retry() { + if (Date.now() >= deadline) { reject(new Error('emulator boot timeout')); return; } + setTimeout(attempt, 1000); + } + }; + attempt(); + }); +} + +export function registerAndroidRoutes(app: express.Application, getSessionUser: (req: express.Request) => any): void { + + app.post('/api/android/start', async (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Not authenticated' }); + + const perUserIds = [...androidSessions.entries()].filter(([, s]) => s.username === session.username).map(([sid]) => sid); + for (const sid of perUserIds) await _reapDeadSession(sid); + const perUser = [...androidSessions.values()].filter(s => s.username === session.username); + if (perUser.length > 0) return res.status(429).json({ error: '이미 실행 중인 Android 에뮬레이터가 있습니다' }); + + if (androidSessions.size >= MAX_CONCURRENT) { + for (const sid of [...androidSessions.keys()]) await _reapDeadSession(sid); + } + if (androidSessions.size >= MAX_CONCURRENT) return res.status(503).json({ error: '동시 실행 가능한 에뮬레이터 수를 초과했습니다. 잠시 후 다시 시도하세요.' }); + + const usedVnc = new Set([...androidSessions.values()].map(s => s.vncPort)); + const usedAdb = new Set([...androidSessions.values()].map(s => s.adbPort)); + const vncPort = _allocatePort(VNC_PORT_BASE, usedVnc); + const adbPort = _allocatePort(ADB_PORT_BASE, usedAdb); + + const id = 'android_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); + const containerName = 'android-emu-' + id; + + execFile('docker', [ + 'run', '-d', + '--name', containerName, + '--device', '/dev/kvm', + '-e', 'DEVICE=Samsung Galaxy S10', + '-e', 'EMULATOR_ARGS=-gpu swiftshader_indirect -no-snapshot -no-boot-anim', + '-e', 'WEB_VNC=true', + // Software-rendered (no GPU) cold boot easily takes 3-4+ min. The + // container's own readiness-check wrapper polls a fixed 60 times and + // gives up (killing the emulator process) if it isn't done in time — + // default is 60 * 2s = 2 min, too short here. Widen the interval so + // the same 60-attempt budget covers ~8 min instead. + '-e', 'DEVICE_INTERVAL_WAITING=8', + '-p', `127.0.0.1:${vncPort}:6080`, + '-p', `127.0.0.1:${adbPort}:5555`, + '--shm-size=1g', + // Host has 4 cores total; leave 1 for the gateway/OS. Software + // rendering (swiftshader) is multi-threaded, so more cores here + // measurably speeds up boot — at the cost of more contention if a + // second concurrent session is also running. + '--memory=3g', '--cpus=3', + ANDROID_IMAGE, + ], { timeout: 20000 }, async (err, stdout) => { + if (err) { + res.status(500).json({ error: 'Docker 컨테이너 시작 실패: ' + (err.message || err) }); + return; + } + const containerId = stdout.trim(); + const timer = setTimeout(() => killAndroidSession(id), HARD_TTL_MS); + const s: AndroidSession = { + containerId, username: session.username, vncPort, adbPort, + createdAt: Date.now(), ready: false, timer, viewerConns: new Set(), + }; + androidSessions.set(id, s); + + try { + await _waitForPort(vncPort, BOOT_TIMEOUT_MS); + s.ready = true; + res.json({ id, vncPort, adbPort }); + } catch (e: any) { + killAndroidSession(id); + res.status(504).json({ error: '에뮬레이터 부팅 시간 초과: ' + (e.message || e) }); + } + }); + }); + + app.get('/api/android/:id/status', (req, res) => { + const s = androidSessions.get(req.params.id); + if (!s) return res.status(404).json({ error: 'session not found' }); + res.json({ ready: s.ready, vncPort: s.vncPort, adbPort: s.adbPort }); + }); + + app.delete('/api/android/:id', (req, res) => { + const s = androidSessions.get(req.params.id); + if (!s) return res.status(404).json({ error: 'session not found' }); + killAndroidSession(req.params.id); + res.json({ ok: true }); + }); + + // Reverse-proxy noVNC's static web client (vnc_lite.html + its JS/CSS assets, + // served over plain HTTP by the container alongside the websockify WS port). + // Same shape as the /pdf-editor proxy — hand-rolled http.request piping. + app.all('/api/android/:id/app/{*assetPath}', (req: express.Request, res: express.Response) => { + const s = androidSessions.get(String(req.params.id)); + if (!s) return res.status(404).json({ error: 'session not found' }); + const rawPath = (req.params as Record).assetPath; + const subPath = Array.isArray(rawPath) ? rawPath.join('/') : String(rawPath || ''); + const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''; + const proxyReq = http.request({ + hostname: '127.0.0.1', + port: s.vncPort, + path: '/' + subPath + qs, + method: req.method, + headers: { ...req.headers, host: `127.0.0.1:${s.vncPort}` }, + }, (proxyRes) => { + res.writeHead(proxyRes.statusCode || 502, proxyRes.headers); + proxyRes.pipe(res); + }); + proxyReq.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'noVNC unavailable' }); }); + req.pipe(proxyReq); + }); +} + +// Raw WebSocket upgrade proxy for noVNC's websockify connection. Must be +// attached to the actual http.Server instance (after http.createServer(app)), +// since Express has no access to the raw 'upgrade' event. +export function attachAndroidWsProxy(server: http.Server, getSessionUser: (req: any) => any): void { + server.on('upgrade', (req, clientSocket, head) => { + const url = req.url || ''; + const m = url.match(/^\/api\/android\/([\w-]+)\/vnc/); + if (!m) return; // not ours — let other upgrade listeners (e.g. /ws) handle it + + const id = m[1]; + const s = androidSessions.get(id); + const authedSession = getSessionUser(req); + if (!s || !authedSession || authedSession.username !== s.username) { + clientSocket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + clientSocket.destroy(); + return; + } + + const backendSocket = net.connect({ host: '127.0.0.1', port: s.vncPort }, () => { + // websockify inside the container only recognizes the WS upgrade at the + // fixed path /websockify (everything else on this port is its static + // file server) — rewrite regardless of the external proxy path. + const headerLines = [`${req.method} /websockify HTTP/1.1`]; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const key = req.rawHeaders[i]; + const val = key.toLowerCase() === 'host' ? `127.0.0.1:${s.vncPort}` : req.rawHeaders[i + 1]; + headerLines.push(`${key}: ${val}`); + } + backendSocket.write(headerLines.join('\r\n') + '\r\n\r\n'); + if (head && head.length) backendSocket.write(head); + + s.viewerConns.add(clientSocket); + clearTimeout(s.idleTimer); + s.idleTimer = undefined; + + clientSocket.pipe(backendSocket); + backendSocket.pipe(clientSocket); + + const cleanup = () => { + s.viewerConns.delete(clientSocket); + try { clientSocket.destroy(); } catch {} + try { backendSocket.destroy(); } catch {} + if (s.viewerConns.size === 0) { + s.idleTimer = setTimeout(() => killAndroidSession(id), IDLE_TTL_MS); + } + }; + clientSocket.on('close', cleanup); + clientSocket.on('error', cleanup); + backendSocket.on('close', cleanup); + backendSocket.on('error', cleanup); + }); + backendSocket.on('error', () => { try { clientSocket.destroy(); } catch {} }); + }); +} diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 5b73c37..25125ee 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -17,6 +17,7 @@ import fsp from 'fs/promises'; import crypto from 'crypto'; import { WebSocketServer, WebSocket } from 'ws'; import { registerArduinoRoutes } from './routes-arduino'; +import { registerAndroidRoutes, attachAndroidWsProxy } from './routes-android'; import { registerPptxRoutes } from './routes-pptx'; import { registerVoiceRoutes } from './routes-voice'; import { registerMusicRoutes } from './routes-music'; @@ -96,7 +97,7 @@ import { recordAgentRun, } from '../scheduler'; import { TelegramChannel } from './telegram-channel'; -import { KakaoNotify } from './kakao-notify'; +import { KakaoNotify, resolveKakaoImageToken } from './kakao-notify'; import { KakaoChannel } from './kakao-channel'; import { OrchestrationTriggerState, @@ -707,7 +708,7 @@ type ChannelsConfig = { telegram: TelegramChannelConfig; discord: DiscordChannelConfig; whatsapp: WhatsAppChannelConfig; - kakao: { appKey: string; refreshToken: string; userMap: Record; channelEnabled: boolean }; + kakao: { appKey: string; refreshToken: string; userMap: Record; channelEnabled: boolean; publicBaseUrl: string }; }; // HIGH-01 fix: resolve vault references when normalizing channel configs. @@ -781,6 +782,7 @@ function resolveChannelsConfig(): ChannelsConfig { refreshToken: resolveToken(channels.kakao?.refreshToken) || vault.get('channels.kakao.refreshToken', 'kakao:init')?.expose() || '', userMap: channels.kakao?.userMap || {}, channelEnabled: channels.kakao?.channelEnabled === true, + publicBaseUrl: vault.get('channels.kakao.publicBaseUrl', 'kakao:init')?.expose() || '', }, }; } @@ -1400,7 +1402,7 @@ RULES: 현재날씨/예보 → weather_search. 대기질(국내) → weather_air email: `EMAIL TOOLS (멀티 계정 지원): 모든 도구에 account? 파라미터로 계정 ID 지정 가능. 미지정 시 모든 계정 순회. 사용자별 설정에 등록된 계정만 접근 가능. email_list(folder?,limit?,account?) → 받은편지함 목록 (uid·제목·발신자·날짜·읽음여부). email_read(uid,folder?,account?) → 메일 전문 조회. email_send(to,subject,body,cc?,account?) → 메일 발송. email_search(query?,from?,subject?,since?,before?,folder?,limit?,account?) → 메일 검색. email_delete(uid,folder?,account?) → 메일 삭제. RULES: 이메일 관련 요청에는 항상 email_* 도구 사용 — web_search 사용 금지. email_list 결과는 계정별로 그룹핑된 원본 포맷(📬/📭 마커, 번호, UID 포함)을 그대로 사용자에게 전달할 것 — 자의적 재포맷팅 금지.`, - kakao: `KAKAO TOOL: kakao_send_message(text) → 서버에 등록된 카카오 계정으로 "나에게 보내기" 전송 (텍스트/링크만 가능, 이미지 첨부 불가). RULES: 카카오톡 전송을 언급하기 전에 반드시 이 도구를 호출해서 결과를 확인할 것 — 도구 호출 없이 "보내드리겠습니다"라고만 답하지 말 것. 도구가 실패(카카오 미연동 등)하면 실패 사유를 그대로 사용자에게 전달할 것.`, + kakao: `KAKAO TOOL: kakao_send_message(text?, image_path?) → 서버에 등록된 카카오 계정으로 "나에게 보내기" 전송. text만 주면 텍스트/링크 메시지. image_path(워크스페이스 내 PNG/JPG/GIF/WEBP 경로, 다른 도구가 반환한 정확한 경로 사용)를 주면 사진 메시지로 전송하고 text는 캡션이 됨. PDF·문서·압축파일 등 이미지가 아닌 첨부는 카카오 API 자체가 지원하지 않으므로 불가능 — 그런 요청에는 안 된다고 답할 것. RULES: 카카오톡 전송을 언급하기 전에 반드시 이 도구를 호출해서 결과를 확인할 것 — 도구 호출 없이 "보내드리겠습니다"라고만 답하지 말 것. 도구가 실패(카카오 미연동 등)하면 실패 사유를 그대로 사용자에게 전달할 것.`, pdf: `PDF TOOLS: pdf_read(path, page_from?, page_to?, max_chars?) → extract text from a PDF. pdf_extract_images(path, mode?, out_dir?, page_from?, page_to?, dpi?) → extract images/figures from a PDF. mode: "figures" (PREFERRED — OpenCV auto-crop figures/tables, best for academic PDFs), "images" (embedded rasters — raw, often full-page scans), "both" = images+figures (default). out_dir: save to specific folder (e.g. PPTX project folder). Returns workspace-relative paths. NOTE: "pages" mode no longer exists — use "figures" instead. pdf_extract_tables(path, format?, engine?, page_from?, page_to?) → extract tables as structured data. format: "markdown" (default) | "csv" | "json". engine: "auto" (PyMuPDF first, pdfplumber fallback) | "pymupdf" | "pdfplumber". For scanned PDFs use pdf_extract_images mode "figures" instead.`, @@ -1491,6 +1493,7 @@ async function buildPersonalityContext( messageText: string, executionMode: string, historyLength: number, + recentContextText: string = '', ): Promise { // Per-request file cache — prevents reading USER.md/SOUL.md twice @@ -1538,7 +1541,11 @@ async function buildPersonalityContext( return parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''; } - const cats = detectToolCategories(messageText, sessionId); + // 짧은 후속 응답("응", "다시 보내줘")은 그 자체로 키워드가 없어 카테고리가 + // 감지되지 않는다 — 직전 어시스턴트 메시지까지 함께 스캔해서, 방금 제안한 + // 작업(예: "카카오톡으로 보내드릴까요?")에 대한 후속 턴에서도 관련 카테고리 + // (및 "반드시 도구를 호출할 것" 규칙 텍스트)가 계속 활성화되도록 한다. + const cats = detectToolCategories(`${recentContextText}\n${messageText}`, sessionId); // Build tool blocks for detected categories const toolBlockParts: string[] = []; @@ -2415,11 +2422,12 @@ function buildTools() { type: 'function', function: { name: 'kakao_send_message', - description: '카카오톡 "나에게 보내기"로 텍스트/링크를 본인 카카오톡에 전송. 차트 링크·요약·알림을 카카오톡으로 보내달라는 요청 시 사용. 이미지 첨부는 지원하지 않음 — URL은 텍스트에 그대로 포함해서 보낼 것.', + description: '카카오톡 "나에게 보내기"로 텍스트 또는 이미지를 본인 카카오톡에 전송. 차트 링크·요약·알림·스크린샷을 보내달라는 요청 시 사용. image_path 지정 시 사진 메시지로 전송(text는 캡션). PNG/JPG/GIF/WEBP만 지원 — 그 외 파일 형식 첨부는 카카오 API가 지원하지 않음.', parameters: { - type: 'object', required: ['text'], + type: 'object', required: [], properties: { - text: { type: 'string', description: '보낼 메시지 (URL 포함 가능, 2000자 초과 시 자동 분할)' }, + text: { type: 'string', description: '보낼 메시지 (URL 포함 가능, 2000자 초과 시 자동 분할). image_path와 함께 쓰면 캡션.' }, + image_path: { type: 'string', description: '워크스페이스 내 이미지 파일 경로 (PNG/JPG/GIF/WEBP). 다른 도구가 반환한 정확한 경로를 그대로 사용.' }, }, }, }, @@ -5654,7 +5662,8 @@ async function handleChat( } } - const personalityCtx = await buildPersonalityContext(sessionId, workspacePath, message, executionMode || 'interactive', history.length); + const lastAssistantMsg = [...history].reverse().find(h => h.role === 'assistant')?.content; + const personalityCtx = await buildPersonalityContext(sessionId, workspacePath, message, executionMode || 'interactive', history.length, String(lastAssistantMsg || '').slice(-500)); // Inject active browser session state so LLM knows to reuse it instead of re-opening const browserInfo = getBrowserSessionInfo(sessionId); @@ -9204,6 +9213,8 @@ app.use((req, _res, next) => { if (req.path.startsWith('/api/ppt/skins/') || req.path === '/api/ppt/skins') return next(); // 카카오 오픈빌더 웹훅 — 카카오 서버에서 오는 요청이라 인증 없음 if (req.path === '/api/kakao/webhook') return next(); + // 카카오 이미지 전송용 임시 공개 링크 — 카카오 서버가 직접 fetch (토큰 자체가 인증) + if (req.path.startsWith('/api/kakao/img/')) return next(); const token = extractAuthToken(req); const session = token ? activeSessions.get(token) : undefined; @@ -11930,6 +11941,18 @@ app.post('/api/kakao/webhook', async (req, res) => { } }); +// 카카오 이미지 전송용 임시 공개 링크 — kakao_send_message(image_path)가 발급한 +// 토큰으로 워크스페이스 이미지 파일 하나만, 짧은 시간(10분) 동안 인증 없이 서빙. +app.get('/api/kakao/img/:token', (req, res) => { + const absPath = resolveKakaoImageToken(String(req.params.token || '')); + if (!absPath || !fs.existsSync(absPath)) { res.status(404).end(); return; } + const ext = path.extname(absPath).toLowerCase(); + const contentType = IMAGE_TYPES[ext] || 'application/octet-stream'; + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'private, max-age=600'); + fs.createReadStream(absPath).pipe(res); +}); + // OAuth 시작 — 브라우저에서 열면 카카오 로그인 페이지로 이동 app.get('/api/kakao/auth/start', (req, res) => { const conf = resolveChannelsConfig().kakao; @@ -11965,6 +11988,10 @@ app.get('/api/kakao/auth/callback', async (req, res) => { kakaoNotify.updateTokens(data.refresh_token, data.access_token); console.log('[KakaoNotify] OAuth 완료 — 리프레시 토큰 vault 저장됨.'); + // 이 콜백이 성공했다는 건 이 host가 실제로 카카오 서버에서 도달 가능하다는 + // 뜻 — 이미지 전송(image_url을 카카오가 직접 fetch)에 쓸 공개 주소로 저장. + getVault(CONFIG_DIR_PATH).set('channels.kakao.publicBaseUrl', `${req.protocol}://${req.get('host')}`, 'kakao:baseurl'); + // 카카오 userId 조회 → 현재 계정 자동 매핑 if (stateUsername) { try { @@ -14008,13 +14035,26 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N registerArduinoRoutes(app, getSessionUser); +registerAndroidRoutes(app, getSessionUser); app.get('/{*path}', (_req, res) => { res.sendFile(path.join(webUiPath, 'index.html')); }); const server = http.createServer(app); -wss = new WebSocketServer({ server, path: '/ws' }); +// ws's own WebSocketServer, when attached via {server, path}, registers an +// unconditional 'upgrade' listener that actively aborts (writes HTTP 400 and +// destroys the socket) any request whose path doesn't match — it doesn't just +// ignore non-matching paths. That would race/clobber the Android emulator's +// WS proxy below, which also listens on 'upgrade'. Using noServer + manual +// path dispatch means each handler only touches sockets meant for it. +wss = new WebSocketServer({ noServer: true }); +server.on('upgrade', (req, socket, head) => { + if ((req.url || '').startsWith('/ws')) { + wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); + } +}); +attachAndroidWsProxy(server, getSessionUser); wss.on('error', (err: any) => { if (err?.code === 'EADDRINUSE') { console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`); diff --git a/src/tools/kakao.ts b/src/tools/kakao.ts index 42b0dd7..5b866f8 100644 --- a/src/tools/kakao.ts +++ b/src/tools/kakao.ts @@ -1,7 +1,14 @@ +import path from 'path'; +import fs from 'fs'; import { ToolResult } from '../types.js'; import { getConfig } from '../config/config.js'; import { getVault } from '../security/vault.js'; -import { KakaoNotify } from '../gateway/kakao-notify.js'; +import { KakaoNotify, registerKakaoImageToken } from '../gateway/kakao-notify.js'; + +const KAKAO_IMAGE_EXTS: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', +}; function getKakaoNotify(): KakaoNotify | null { const cm = getConfig(); @@ -20,25 +27,44 @@ function getKakaoNotify(): KakaoNotify | null { }); } +function getKakaoPublicBaseUrl(): string { + const cm = getConfig(); + const vault = getVault(cm.getConfigDir()); + return vault.get('channels.kakao.publicBaseUrl', 'kakao:init')?.expose() || ''; +} + +/** image_path를 워크스페이스 안으로 안전하게 해석. 워크스페이스 밖이면 null. */ +function resolveWorkspaceImage(imagePath: string, workspacePath: string): string | null { + const abs = path.normalize(path.resolve(workspacePath, imagePath)); + const root = path.normalize(workspacePath); + if (!(abs === root || abs.startsWith(root + path.sep))) return null; + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null; + if (!KAKAO_IMAGE_EXTS[path.extname(abs).toLowerCase()]) return null; + return abs; +} + // ── kakao_send_message ────────────────────────────────────────────────────── export const kakaoSendTool = { name: 'kakao_send_message', - description: '카카오톡 "나에게 보내기"로 텍스트를 본인 카카오톡에 전송합니다. 차트 링크, 요약, 알림 등을 카카오톡으로 보내달라는 요청에 사용. 이미지 첨부는 지원하지 않으므로 링크/텍스트로만 전송합니다.', + description: '카카오톡 "나에게 보내기"로 텍스트 또는 이미지를 본인 카카오톡에 전송합니다. 차트 링크, 요약, 알림, 스크린샷 등을 보내달라는 요청에 사용. image_path 지정 시 이미지를 사진 메시지로 전송(text는 설명/캡션이 됨). PNG/JPG/GIF/WEBP만 지원 — PDF·문서·압축파일 등 다른 첨부파일 종류는 카카오 API 자체가 지원하지 않음.', schema: { - text: '보낼 메시지 내용 (URL은 텍스트에 그대로 포함)', + text: '보낼 메시지 내용, 또는 이미지 첨부 시 설명/캡션 (URL은 텍스트에 그대로 포함)', + image_path: '(선택) 워크스페이스 내 이미지 파일 경로 — 반드시 이전 도구 결과가 반환한 정확한 경로 사용. PNG/JPG/GIF/WEBP만 지원.', }, jsonSchema: { type: 'object', properties: { - text: { type: 'string', description: '보낼 메시지 (URL 포함 가능, 최대 2000자 단위로 자동 분할)' }, + text: { type: 'string', description: '보낼 메시지 (URL 포함 가능, 최대 2000자 단위로 자동 분할). image_path와 함께 쓰면 이미지 설명/캡션.' }, + image_path: { type: 'string', description: '워크스페이스 내 이미지 파일 경로 (PNG/JPG/GIF/WEBP). 다른 도구가 반환한 정확한 경로를 그대로 사용할 것.' }, }, - required: ['text'], + required: [], additionalProperties: false, }, execute: async (args: any): Promise => { const text = String(args?.text || '').trim(); - if (!text) return { success: false, error: 'text(보낼 내용)가 필요합니다.' }; + const imagePath = String(args?.image_path || '').trim(); + if (!text && !imagePath) return { success: false, error: 'text 또는 image_path 중 하나는 필요합니다.' }; const notifier = getKakaoNotify(); if (!notifier) { @@ -48,6 +74,32 @@ export const kakaoSendTool = { }; } + if (imagePath) { + const workspacePath = String(args?._workspacePath || args?._workspace || ''); + const absPath = workspacePath ? resolveWorkspaceImage(imagePath, workspacePath) : null; + if (!absPath) { + return { success: false, error: `이미지 파일을 찾을 수 없습니다: ${imagePath} (PNG/JPG/GIF/WEBP만 지원, 워크스페이스 내 경로여야 함)` }; + } + const baseUrl = getKakaoPublicBaseUrl(); + if (!baseUrl) { + return { + success: false, + error: '카카오 이미지 전송에 필요한 공개 서버 주소가 없습니다. /api/kakao/auth/start 로 카카오 로그인을 브라우저에서 한 번 완료하면 자동으로 설정됩니다.', + }; + } + const token = registerKakaoImageToken(absPath); + const imageUrl = `${baseUrl}/api/kakao/img/${token}`; + try { + await notifier.sendImage(imageUrl, { title: text ? text.slice(0, 50) : '📷 이미지', description: text }); + return { + success: true, + stdout: `✅ 카카오톡(나에게 보내기)으로 이미지 전송 완료.${text ? `\n캡션: ${text.slice(0, 200)}` : ''}`, + }; + } catch (err: any) { + return { success: false, error: `카카오톡 이미지 전송 실패: ${err.message}` }; + } + } + try { await notifier.send(text); return { diff --git a/web-ui/android-emulator.html b/web-ui/android-emulator.html new file mode 100644 index 0000000..c16a197 --- /dev/null +++ b/web-ui/android-emulator.html @@ -0,0 +1,104 @@ + + + + +Android 에뮬레이터 + + + +
+
+ 준비 중… + + +
+
+
Android 에뮬레이터 부팅 중… (최대 90초)
+
+ +
+
+ + + diff --git a/web-ui/code.html b/web-ui/code.html index c3f33aa..88dd91c 100644 --- a/web-ui/code.html +++ b/web-ui/code.html @@ -331,7 +331,7 @@ - +
diff --git a/web-ui/code.js b/web-ui/code.js index 17e9b3a..2046d6b 100644 --- a/web-ui/code.js +++ b/web-ui/code.js @@ -1143,6 +1143,28 @@ function openArduinoEmulator() { } } +function openAndroidEmulator() { + const btn = document.getElementById('code-android-btn'); + if (btn) { btn.textContent = '⏳ 부팅 중…'; btn.disabled = true; } + _emuPanelLoad('/android-emulator.html?loading=1', 'Android'); + codeAiAppend('system', '🤖 Android 에뮬레이터 부팅 중... (최대 90초)'); + api('/api/android/start', { method: 'POST' }) + .then(d => { + if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; } + if (d.error) { + codeAiAppend('system', '⚠️ ' + d.error); + toggleEmuPanel(); + return; + } + _emuPanelLoad('/android-emulator.html?session=' + d.id, 'Android'); + codeAiAppend('system', '✅ Android 에뮬레이터 실행 중.'); + }).catch(e => { + if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; } + codeAiAppend('system', '⚠️ ' + e.message); + toggleEmuPanel(); + }); +} + // ── Arduino 업로드 페이지 열기 ──────────────────────────────────────────────── function csbUploadToBoard() { const file = codeFiles.find(f => f.id === activeCodeFileId); diff --git a/web-ui/index.html b/web-ui/index.html index 8d4f5d2..f04d990 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -54,6 +54,8 @@
+ + @@ -61,9 +63,7 @@ - -