v3.4.0: 서버사이드 Android 에뮬레이터 (코드 에디터 내장)
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 <noreply@anthropic.com>
This commit is contained in:
+58
-6
@@ -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<string, string> = {
|
||||
'.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<ToolResult> => {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user