본 문서는 Paimy가 연동하는 외부 API들의 상세 호출 방법을 정의한다.
| 서비스 | 용도 | 인증 방식 |
|---|---|---|
| Slack Web API | 메시지 수신/발송, 사용자 조회 | Bot Token |
| Notion API | 태스크 CRUD, 사용자 조회 | Integration Token |
| Google Calendar API | 일정 CRUD, 가용 시간 확인 | Service Account + Domain Delegation |
| Gmail API | 메일 조회, 검색 | Service Account + Domain Delegation |
| Claude API | 자연어 이해, Tool Use | API Key |
Header:
Authorization: Bearer {SLACK_BOT_TOKEN}
Content-Type: application/json
요청 검증 (수신 시):
import crypto from 'crypto';
function verifySlackRequest(
signingSecret: string,
signature: string,
timestamp: string,
body: string
): boolean {
const baseString = `v0:${timestamp}:${body}`;
const hmac = crypto.createHmac('sha256', signingSecret);
const expectedSignature = `v0=${hmac.update(baseString).digest('hex')}`;
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}Endpoint: POST /api/slack/events (Paimy 서버)
Event Payload 구조:
{
"type": "event_callback",
"event": {
"type": "app_mention",
"user": "U01ABC2DEF3",
"text": "<@U0PAIMY> 내 태스크 보여줘",
"channel": "C01CHANNEL1",
"ts": "1234567890.123456",
"thread_ts": "1234567890.123456"
}
}이벤트 타입별 처리:
| event.type | 설명 | 처리 |
|---|---|---|
app_mention |
@Paimy 멘션 | 메시지 파싱 → LLM 처리 |
message (im) |
DM 메시지 | 메시지 파싱 → LLM 처리 |
message (channel) |
채널 메시지 | 봇 멘션 포함 시만 처리 |
Challenge 응답 (최초 URL 검증):
// Request
{ "type": "url_verification", "challenge": "abc123" }
// Response
{ "challenge": "abc123" }Endpoint: POST https://slack.com/api/chat.postMessage
Request:
{
"channel": "C01CHANNEL1",
"text": "📋 이번 주 마감 태스크 3건이에요.",
"thread_ts": "1234567890.123456",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*📋 이번 주 마감 태스크 3건*"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "1. *레드티밍 가드레일 검증*\n 마감: 금요일 | 상태: In Progress"
}
}
]
}Response:
{
"ok": true,
"channel": "C01CHANNEL1",
"ts": "1234567890.123457",
"message": { ... }
}코드 예시:
async function sendSlackMessage(
channel: string,
text: string,
threadTs?: string,
blocks?: Block[]
) {
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SLACK_BOT_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
channel,
text,
thread_ts: threadTs,
blocks,
}),
});
return response.json();
}Endpoint: POST https://slack.com/api/conversations.open → chat.postMessage
async function sendDirectMessage(userId: string, text: string) {
// 1. DM 채널 열기
const openRes = await fetch('https://slack.com/api/conversations.open', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SLACK_BOT_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: userId }),
});
const { channel } = await openRes.json();
// 2. 메시지 발송
return sendSlackMessage(channel.id, text);
}Endpoint: GET https://slack.com/api/users.info?user={user_id}
Response:
{
"ok": true,
"user": {
"id": "U01ABC2DEF3",
"name": "chaewook.kim",
"profile": {
"display_name": "김채욱",
"email": "chaewook@company.com"
}
}
}| Tier | 제한 | 대상 메서드 |
|---|---|---|
| Tier 1 | 1 req/min | - |
| Tier 2 | 20 req/min | chat.postMessage |
| Tier 3 | 50 req/min | conversations.list |
| Tier 4 | 100 req/min | users.info |
Rate Limit 응답:
{
"ok": false,
"error": "ratelimited",
"retry_after": 30
}Headers:
Authorization: Bearer {NOTION_INTEGRATION_TOKEN}
Notion-Version: 2022-06-28
Content-Type: application/json
Base URL: https://api.notion.com/v1
Endpoint: POST /databases/{database_id}/query
Request - 담당자별 조회:
{
"filter": {
"property": "담당자",
"people": {
"contains": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
},
"sorts": [
{
"property": "마감일",
"direction": "ascending"
}
],
"page_size": 10
}Request - 복합 필터 (이번 주 마감 + 미완료):
{
"filter": {
"and": [
{
"property": "마감일",
"date": {
"on_or_after": "2024-01-15"
}
},
{
"property": "마감일",
"date": {
"on_or_before": "2024-01-21"
}
},
{
"property": "상태",
"select": {
"does_not_equal": "Done"
}
}
]
}
}Response:
{
"object": "list",
"results": [
{
"id": "page-id-1234",
"properties": {
"태스크명": {
"title": [{ "plain_text": "레드티밍 가드레일 검증" }]
},
"상태": {
"select": { "name": "In Progress" }
},
"담당자": {
"people": [{ "id": "person-id", "name": "김채욱" }]
},
"마감일": {
"date": { "start": "2024-01-19" }
},
"우선순위": {
"select": { "name": "High" }
}
},
"url": "https://notion.so/..."
}
],
"has_more": false,
"next_cursor": null
}코드 예시:
async function queryTasks(filter: object, sorts?: object[]) {
const response = await fetch(
`https://api.notion.com/v1/databases/${process.env.NOTION_TASK_DATABASE_ID}/query`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NOTION_INTEGRATION_TOKEN}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ filter, sorts, page_size: 20 }),
}
);
return response.json();
}Endpoint: GET /pages/{page_id}
Response:
{
"id": "page-id-1234",
"properties": {
"태스크명": { "title": [{ "plain_text": "레드티밍 가드레일 검증" }] },
"상태": { "select": { "name": "In Progress" } },
"담당자": { "people": [{ "id": "...", "name": "김채욱" }] },
"마감일": { "date": { "start": "2024-01-19" } },
"실행 상세": { "rich_text": [{ "plain_text": "보안 가드레일 성능 지표 추가" }] }
},
"url": "https://notion.so/..."
}Endpoint: PATCH /pages/{page_id}
Request - 상태 변경:
{
"properties": {
"상태": {
"select": { "name": "Done" }
}
}
}Request - 담당자 변경:
{
"properties": {
"담당자": {
"people": [{ "id": "new-person-id" }]
}
}
}Request - 마감일 변경:
{
"properties": {
"마감일": {
"date": { "start": "2024-01-26" }
}
}
}Request - 텍스트 추가 (실행 상세):
{
"properties": {
"실행 상세": {
"rich_text": [
{
"type": "text",
"text": { "content": "보안 가드레일 성능 지표 추가" }
}
]
}
}
}코드 예시:
async function updateTask(pageId: string, properties: object) {
const response = await fetch(
`https://api.notion.com/v1/pages/${pageId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${process.env.NOTION_INTEGRATION_TOKEN}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ properties }),
}
);
return response.json();
}
// 사용 예
await updateTask('page-id-1234', {
'상태': { select: { name: 'Done' } }
});Endpoint: POST /pages
Request:
{
"parent": {
"database_id": "your-database-id"
},
"properties": {
"태스크명": {
"title": [{ "text": { "content": "보고서 초안 작성" } }]
},
"상태": {
"select": { "name": "Backlog" }
},
"담당자": {
"people": [{ "id": "person-id" }]
},
"마감일": {
"date": { "start": "2024-01-20" }
},
"우선순위": {
"select": { "name": "Medium" }
},
"소스": {
"select": { "name": "Gmail" }
},
"원본 링크": {
"url": "https://mail.google.com/..."
}
}
}Endpoint: GET /users
Response:
{
"results": [
{
"id": "a1b2c3d4-...",
"name": "김채욱",
"type": "person",
"person": {
"email": "chaewook@company.com"
}
}
]
}| 요청 유형 | 제한 |
|---|---|
| 일반 요청 | 3 requests/second (평균) |
| Burst | 최대 짧은 순간 초과 허용 |
Rate Limit 응답:
{
"code": "rate_limited",
"message": "Rate limited"
}→ Retry-After 헤더 확인 후 재시도
import { google } from 'googleapis';
function getCalendarClient(userEmail: string) {
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
},
scopes: [
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/calendar.events',
],
clientOptions: {
subject: userEmail, // 위임 대상 사용자
},
});
return google.calendar({ version: 'v3', auth });
}Base URL: https://www.googleapis.com/calendar/v3
Endpoint: GET /calendars/{calendarId}/events
Parameters:
| 파라미터 | 설명 |
|---|---|
calendarId |
primary 또는 이메일 주소 |
timeMin |
시작 시간 (ISO 8601) |
timeMax |
종료 시간 (ISO 8601) |
maxResults |
최대 개수 |
singleEvents |
true - 반복 일정 개별 표시 |
orderBy |
startTime |
Request:
async function getCalendarEvents(
userEmail: string,
timeMin: string,
timeMax: string,
maxResults = 10
) {
const calendar = getCalendarClient(userEmail);
const response = await calendar.events.list({
calendarId: 'primary',
timeMin,
timeMax,
maxResults,
singleEvents: true,
orderBy: 'startTime',
});
return response.data.items;
}Response:
{
"items": [
{
"id": "event-id-123",
"summary": "팀 스탠드업",
"start": {
"dateTime": "2024-01-15T10:00:00+09:00"
},
"end": {
"dateTime": "2024-01-15T11:00:00+09:00"
},
"attendees": [
{ "email": "chaewook@company.com", "responseStatus": "accepted" },
{ "email": "sujin@company.com", "responseStatus": "needsAction" }
],
"hangoutLink": "https://meet.google.com/xxx-yyyy-zzz",
"htmlLink": "https://calendar.google.com/..."
}
]
}Endpoint: POST /calendars/{calendarId}/events
Request:
async function createCalendarEvent(
userEmail: string,
event: {
summary: string;
startTime: string;
endTime: string;
attendees?: string[];
description?: string;
location?: string;
}
) {
const calendar = getCalendarClient(userEmail);
const response = await calendar.events.insert({
calendarId: 'primary',
sendUpdates: 'all', // 참석자에게 알림 발송
requestBody: {
summary: event.summary,
start: {
dateTime: event.startTime,
timeZone: 'Asia/Seoul',
},
end: {
dateTime: event.endTime,
timeZone: 'Asia/Seoul',
},
attendees: event.attendees?.map(email => ({ email })),
description: event.description,
location: event.location,
conferenceData: {
createRequest: {
requestId: `paimy-${Date.now()}`,
conferenceSolutionKey: { type: 'hangoutsMeet' },
},
},
},
conferenceDataVersion: 1, // Google Meet 링크 자동 생성
});
return response.data;
}Response:
{
"id": "new-event-id",
"summary": "레드티밍 킥오프 미팅",
"htmlLink": "https://calendar.google.com/...",
"hangoutLink": "https://meet.google.com/xxx-yyyy-zzz"
}Endpoint: PATCH /calendars/{calendarId}/events/{eventId}
async function updateCalendarEvent(
userEmail: string,
eventId: string,
updates: {
summary?: string;
startTime?: string;
endTime?: string;
addAttendees?: string[];
}
) {
const calendar = getCalendarClient(userEmail);
// 기존 이벤트 조회
const existing = await calendar.events.get({
calendarId: 'primary',
eventId,
});
const requestBody: any = {};
if (updates.summary) {
requestBody.summary = updates.summary;
}
if (updates.startTime) {
requestBody.start = { dateTime: updates.startTime, timeZone: 'Asia/Seoul' };
}
if (updates.endTime) {
requestBody.end = { dateTime: updates.endTime, timeZone: 'Asia/Seoul' };
}
if (updates.addAttendees) {
const currentAttendees = existing.data.attendees || [];
requestBody.attendees = [
...currentAttendees,
...updates.addAttendees.map(email => ({ email })),
];
}
const response = await calendar.events.patch({
calendarId: 'primary',
eventId,
sendUpdates: 'all',
requestBody,
});
return response.data;
}Endpoint: DELETE /calendars/{calendarId}/events/{eventId}
async function deleteCalendarEvent(
userEmail: string,
eventId: string,
notifyAttendees = true
) {
const calendar = getCalendarClient(userEmail);
await calendar.events.delete({
calendarId: 'primary',
eventId,
sendUpdates: notifyAttendees ? 'all' : 'none',
});
}Endpoint: POST /freeBusy
async function checkAvailability(
userEmails: string[],
timeMin: string,
timeMax: string
) {
const calendar = getCalendarClient(process.env.GOOGLE_DELEGATED_USER_EMAIL!);
const response = await calendar.freebusy.query({
requestBody: {
timeMin,
timeMax,
timeZone: 'Asia/Seoul',
items: userEmails.map(email => ({ id: email })),
},
});
return response.data.calendars;
}
// 사용 예
const availability = await checkAvailability(
['chaewook@company.com', 'sujin@company.com'],
'2024-01-15T09:00:00+09:00',
'2024-01-15T18:00:00+09:00'
);
// Response
// {
// "chaewook@company.com": {
// "busy": [
// { "start": "2024-01-15T10:00:00+09:00", "end": "2024-01-15T11:00:00+09:00" }
// ]
// }
// }빈 시간 슬롯 계산:
function findFreeSlots(
busyTimes: { start: string; end: string }[],
timeMin: string,
timeMax: string,
durationMinutes: number
): { start: string; end: string }[] {
const slots: { start: string; end: string }[] = [];
let current = new Date(timeMin);
const end = new Date(timeMax);
// busy times를 시간순 정렬
const sorted = busyTimes.sort((a, b) =>
new Date(a.start).getTime() - new Date(b.start).getTime()
);
for (const busy of sorted) {
const busyStart = new Date(busy.start);
const gap = (busyStart.getTime() - current.getTime()) / 60000;
if (gap >= durationMinutes) {
slots.push({
start: current.toISOString(),
end: busyStart.toISOString(),
});
}
current = new Date(busy.end);
}
// 마지막 busy 이후 시간 확인
if (current < end) {
const gap = (end.getTime() - current.getTime()) / 60000;
if (gap >= durationMinutes) {
slots.push({
start: current.toISOString(),
end: end.toISOString(),
});
}
}
return slots;
}| 쿼터 | 제한 |
|---|---|
| 사용자당 일일 요청 | 1,000,000 |
| 100초당 요청 | 500 (사용자당) |
import { google } from 'googleapis';
function getGmailClient(userEmail: string) {
const auth = new google.auth.GoogleAuth({
credentials: {
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
clientOptions: {
subject: userEmail,
},
});
return google.gmail({ version: 'v1', auth });
}Base URL: https://gmail.googleapis.com/gmail/v1
Endpoint: GET /users/{userId}/messages
Query 문법:
| 검색어 | 설명 |
|---|---|
is:unread |
읽지 않은 메일 |
from:email@example.com |
발신자 |
after:2024/01/15 |
특정 날짜 이후 |
before:2024/01/20 |
특정 날짜 이전 |
subject:키워드 |
제목 검색 |
has:attachment |
첨부파일 있음 |
async function getEmails(
userEmail: string,
query: string,
maxResults = 10
) {
const gmail = getGmailClient(userEmail);
const response = await gmail.users.messages.list({
userId: 'me',
q: query,
maxResults,
});
return response.data.messages || [];
}
// 사용 예
const unreadEmails = await getEmails(
'chaewook@company.com',
'is:unread after:2024/01/15',
10
);Response:
{
"messages": [
{ "id": "msg-id-1", "threadId": "thread-id-1" },
{ "id": "msg-id-2", "threadId": "thread-id-2" }
],
"nextPageToken": "..."
}Endpoint: GET /users/{userId}/messages/{id}
async function getEmailDetail(userEmail: string, messageId: string) {
const gmail = getGmailClient(userEmail);
const response = await gmail.users.messages.get({
userId: 'me',
id: messageId,
format: 'full',
});
const message = response.data;
const headers = message.payload?.headers || [];
const getHeader = (name: string) =>
headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value;
// 본문 추출
let body = '';
if (message.payload?.body?.data) {
body = Buffer.from(message.payload.body.data, 'base64').toString('utf-8');
} else if (message.payload?.parts) {
const textPart = message.payload.parts.find(
p => p.mimeType === 'text/plain'
);
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString('utf-8');
}
}
return {
id: message.id,
threadId: message.threadId,
subject: getHeader('subject'),
from: getHeader('from'),
to: getHeader('to'),
date: getHeader('date'),
body,
snippet: message.snippet,
};
}Response (가공 후):
{
"id": "msg-id-1",
"threadId": "thread-id-1",
"subject": "[긴급] Q1 예산 검토 요청",
"from": "김부장 <boss@company.com>",
"to": "chaewook@company.com",
"date": "Mon, 15 Jan 2024 09:30:00 +0900",
"body": "안녕하세요,\n\nQ1 예산 관련하여 금주 내 검토 부탁드립니다...",
"snippet": "안녕하세요, Q1 예산 관련하여 금주 내 검토 부탁드립니다..."
}async function getEmailSummaries(userEmail: string, query: string, limit = 5) {
const messages = await getEmails(userEmail, query, limit);
const summaries = await Promise.all(
messages.map(async (msg) => {
const detail = await getEmailDetail(userEmail, msg.id!);
return {
id: detail.id,
subject: detail.subject,
from: detail.from,
date: detail.date,
snippet: detail.snippet,
};
})
);
return summaries;
}| 쿼터 | 제한 |
|---|---|
| 사용자당 일일 요청 | 1,000,000,000 |
| 100초당 요청 | 250 (사용자당) |
Headers:
x-api-key: {ANTHROPIC_API_KEY}
anthropic-version: 2023-06-01
Content-Type: application/json
Base URL: https://api.anthropic.com/v1
Endpoint: POST /messages
Request:
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 4096,
"system": "당신은 Paimy, 사내 AI PM 어시스턴트입니다...",
"tools": [
{
"name": "get_tasks",
"description": "노션에서 태스크 목록을 조회합니다.",
"input_schema": {
"type": "object",
"properties": {
"owner_notion_id": { "type": "string" },
"status": { "type": "string", "enum": ["Backlog", "In Progress", "Blocked", "Done"] },
"due_date_start": { "type": "string", "format": "date" },
"due_date_end": { "type": "string", "format": "date" }
}
}
},
{
"name": "update_task",
"description": "태스크의 속성을 수정합니다.",
"input_schema": {
"type": "object",
"properties": {
"task_id": { "type": "string" },
"status": { "type": "string" }
},
"required": ["task_id"]
}
}
],
"messages": [
{
"role": "user",
"content": "내 태스크 보여줘"
}
]
}Response (Tool Use 요청):
{
"id": "msg_01234",
"type": "message",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_01234",
"name": "get_tasks",
"input": {
"owner_notion_id": "a1b2c3d4-..."
}
}
],
"stop_reason": "tool_use"
}Request (Tool 결과 포함):
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 4096,
"system": "...",
"tools": [...],
"messages": [
{
"role": "user",
"content": "내 태스크 보여줘"
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_01234",
"name": "get_tasks",
"input": { "owner_notion_id": "a1b2c3d4-..." }
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01234",
"content": "[{\"id\":\"task-1\",\"name\":\"레드티밍 가드레일 검증\",\"status\":\"In Progress\",\"due_date\":\"2024-01-19\"}]"
}
]
}
]
}Response (최종):
{
"content": [
{
"type": "text",
"text": "📋 현재 진행 중인 태스크 1건이에요.\n\n1. **레드티밍 가드레일 검증**\n 마감: 금요일 | 상태: In Progress"
}
],
"stop_reason": "end_turn"
}import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
async function processUserMessage(
userMessage: string,
systemPrompt: string,
tools: Anthropic.Tool[],
context: ConversationContext
) {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: userMessage }
];
while (true) {
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: systemPrompt,
tools,
messages,
});
// Tool Use 요청인 경우
if (response.stop_reason === 'tool_use') {
const toolUseBlock = response.content.find(
block => block.type === 'tool_use'
) as Anthropic.ToolUseBlock;
// Tool 실행
const toolResult = await executeTool(
toolUseBlock.name,
toolUseBlock.input,
context
);
// 대화에 추가
messages.push({ role: 'assistant', content: response.content });
messages.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseBlock.id,
content: JSON.stringify(toolResult),
}],
});
continue; // 다시 Claude 호출
}
// 최종 텍스트 응답
const textBlock = response.content.find(
block => block.type === 'text'
) as Anthropic.TextBlock;
return textBlock.text;
}
}
async function executeTool(
name: string,
input: any,
context: ConversationContext
) {
switch (name) {
case 'get_tasks':
return await queryTasks(input);
case 'update_task':
return await updateTask(input.task_id, input);
case 'get_calendar_events':
return await getCalendarEvents(context.google_email, input.time_min, input.time_max);
// ... 기타 tools
default:
throw new Error(`Unknown tool: ${name}`);
}
}| 티어 | 분당 요청 | 분당 토큰 | 일일 토큰 |
|---|---|---|---|
| Tier 1 | 50 | 40,000 | 1,000,000 |
| Tier 2 | 1,000 | 80,000 | 2,500,000 |
| Tier 3 | 2,000 | 160,000 | 5,000,000 |
async function apiCallWithRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
lastError = error;
// Rate Limit
if (error.status === 429) {
const retryAfter = error.headers?.['retry-after'] || Math.pow(2, attempt);
await sleep(retryAfter * 1000);
continue;
}
// 서버 에러 (재시도 가능)
if (error.status >= 500) {
await sleep(baseDelay * Math.pow(2, attempt));
continue;
}
// 클라이언트 에러 (재시도 불가)
throw error;
}
}
throw lastError!;
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}Slack:
| 에러 | 설명 | 처리 |
|---|---|---|
ratelimited |
Rate Limit 초과 | Retry-After 후 재시도 |
channel_not_found |
채널 없음 | 사용자에게 알림 |
not_in_channel |
봇이 채널에 없음 | 채널 초대 요청 |
Notion:
| 에러 | 설명 | 처리 |
|---|---|---|
rate_limited |
Rate Limit 초과 | Retry-After 후 재시도 |
object_not_found |
페이지/DB 없음 | 사용자에게 알림 |
unauthorized |
권한 없음 | Integration 연결 확인 |
Google:
| 에러 | 설명 | 처리 |
|---|---|---|
403 |
권한 없음 | Domain Delegation 확인 |
404 |
리소스 없음 | 사용자에게 알림 |
429 |
Rate Limit 초과 | 지수 백오프 재시도 |
Claude:
| 에러 | 설명 | 처리 |
|---|---|---|
rate_limit_error |
Rate Limit 초과 | 재시도 |
overloaded_error |
서버 과부하 | 잠시 후 재시도 |
invalid_request_error |
잘못된 요청 | 로깅 후 사용자 알림 |
// 1. Slack에서 이벤트 수신
// POST /api/slack/events
const event = {
type: 'app_mention',
user: 'U01ABC2DEF3',
text: '<@UPAIMY> 내 이번 주 태스크 보여줘',
channel: 'C01CHANNEL1',
ts: '1234567890.123456'
};
// 2. 사용자 매핑 조회 (Supabase)
const userMapping = await supabase
.from('user_mappings')
.select('*')
.eq('slack_id', 'U01ABC2DEF3')
.single();
// → { notion_id: 'a1b2c3d4-...', google_email: 'chaewook@company.com' }
// 3. Claude API 호출 → Tool Use 응답
// Claude가 get_tasks tool 호출 결정
// 4. Notion API 호출
const tasks = await queryTasks({
owner_notion_id: 'a1b2c3d4-...',
due_date_start: '2024-01-15',
due_date_end: '2024-01-21'
});
// 5. Claude에 결과 전달 → 최종 응답 생성
const response = '📋 이번 주 마감 태스크 2건이에요.\n\n1. **레드티밍 가드레일 검증**...';
// 6. Slack 메시지 발송
await sendSlackMessage('C01CHANNEL1', response, '1234567890.123456');// 1. 컨텍스트에서 마지막 태스크 조회 (Supabase)
const context = await supabase
.from('conversation_context')
.select('last_task_id, last_task_name')
.eq('slack_thread_ts', '1234567890.123456')
.single();
// → { last_task_id: 'page-id-1234', last_task_name: '레드티밍 가드레일 검증' }
// 2. Claude API → update_task tool 호출 결정
// 3. Notion API 호출
await updateTask('page-id-1234', {
'상태': { select: { name: 'Done' } }
});
// 4. 완료 응답
await sendSlackMessage(
'C01CHANNEL1',
'✅ "레드티밍 가드레일 검증" 태스크를 완료 처리했어요!',
'1234567890.123456'
);- Slack 이벤트 수신 및 검증
- Slack 메시지 발송 (일반, DM, 스레드)
- Notion 쿼리/조회/수정/생성
- Google Calendar CRUD + FreeBusy
- Gmail 조회 및 파싱
- Claude Tool Use 루프
- 에러 처리 및 재시도 로직
- 각 API 개별 호출 테스트
- Tool Use 전체 플로우 테스트
- Rate Limit 시뮬레이션
- 에러 케이스 테스트