- src/gateway/ 76개 .ts 파일 중 55개를 종류별 서브디렉토리로 이동 (routes/ 35, channels/ 4, mocks/ 3, errors/ 5, tasks/ 8, automation/ 3) - 루트엔 server.ts(server-v2.ts에서 개명) 포함 21개 핵심 인프라 파일만 유지 - gateway 내부 상대경로 import 101곳 + 외부 참조 5곳(cli/index.ts, tools/kakao.ts, tools/pptx.ts, tools/shell.ts, tools/task-control.ts)을 스크립트로 재계산, 빌드/재시작/26개 엔드포인트 스모크테스트로 검증 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
114 lines
5.5 KiB
TypeScript
114 lines
5.5 KiB
TypeScript
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, registerKakaoImageToken } from '../gateway/channels/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();
|
|
const cfg = cm.getConfig() as any;
|
|
const raw = cfg.channels?.kakao || {};
|
|
const vault = getVault(cm.getConfigDir());
|
|
|
|
const appKey = cm.resolveSecret(raw.appKey) || vault.get('channels.kakao.appKey', 'kakao:init')?.expose() || '';
|
|
const refreshToken = cm.resolveSecret(raw.refreshToken) || vault.get('channels.kakao.refreshToken', 'kakao:init')?.expose() || '';
|
|
if (!appKey || !refreshToken) return null;
|
|
|
|
return new KakaoNotify({
|
|
appKey,
|
|
refreshToken,
|
|
onTokenRefreshed: (newToken) => vault.set('channels.kakao.refreshToken', newToken, 'kakao:token'),
|
|
});
|
|
}
|
|
|
|
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: '카카오톡 "나에게 보내기"로 텍스트 또는 이미지를 본인 카카오톡에 전송합니다. 차트 링크, 요약, 알림, 스크린샷 등을 보내달라는 요청에 사용. image_path 지정 시 이미지를 사진 메시지로 전송(text는 설명/캡션이 됨). PNG/JPG/GIF/WEBP만 지원 — PDF·문서·압축파일 등 다른 첨부파일 종류는 카카오 API 자체가 지원하지 않음.',
|
|
schema: {
|
|
text: '보낼 메시지 내용, 또는 이미지 첨부 시 설명/캡션 (URL은 텍스트에 그대로 포함)',
|
|
image_path: '(선택) 워크스페이스 내 이미지 파일 경로 — 반드시 이전 도구 결과가 반환한 정확한 경로 사용. PNG/JPG/GIF/WEBP만 지원.',
|
|
},
|
|
jsonSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
text: { type: 'string', description: '보낼 메시지 (URL 포함 가능, 최대 2000자 단위로 자동 분할). image_path와 함께 쓰면 이미지 설명/캡션.' },
|
|
image_path: { type: 'string', description: '워크스페이스 내 이미지 파일 경로 (PNG/JPG/GIF/WEBP). 다른 도구가 반환한 정확한 경로를 그대로 사용할 것.' },
|
|
},
|
|
required: [],
|
|
additionalProperties: false,
|
|
},
|
|
execute: async (args: any): Promise<ToolResult> => {
|
|
const text = String(args?.text || '').trim();
|
|
const imagePath = String(args?.image_path || '').trim();
|
|
if (!text && !imagePath) return { success: false, error: 'text 또는 image_path 중 하나는 필요합니다.' };
|
|
|
|
const notifier = getKakaoNotify();
|
|
if (!notifier) {
|
|
return {
|
|
success: false,
|
|
error: '카카오 연동이 설정되어 있지 않습니다. 서버 설정에서 /api/kakao/auth/start 로 먼저 카카오 로그인을 완료하세요.',
|
|
};
|
|
}
|
|
|
|
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 {
|
|
success: true,
|
|
stdout: `✅ 카카오톡(나에게 보내기)으로 전송 완료.\n내용: ${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`,
|
|
};
|
|
} catch (err: any) {
|
|
return { success: false, error: `카카오톡 전송 실패: ${err.message}` };
|
|
}
|
|
},
|
|
};
|