Files
homeclaw/src/tools/kakao.ts
T
kimandClaude Sonnet 5 db7260ac46 v4.3.5: gateway 폴더 구조 정리 — routes/channels/mocks/errors/tasks/automation 서브디렉토리 분리
- 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>
2026-07-25 17:02:54 +09:00

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}` };
}
},
};