RECIPE 049
미답변 채팅 자동 배정
- ▸GitHub Webhook으로 PR(Pull Request) 생성 이벤트를 감지할 수 있다
- ▸변경된 코드를 OpenAI로 자동 리뷰해 피드백을 생성할 수 있다
- ▸리뷰 결과를 GitHub PR 코멘트로 자동 등록할 수 있다
✅사전 준비
- □GitHub Personal Access Token 발급 (repo, pull_requests 권한)
- □n8n Webhook URL을 GitHub 저장소 Webhook에 등록 (이벤트: pull_request)
- □OpenAI API 자격증명 연결 완료
- □리뷰 기준 정의 (보안 취약점 / 코드 스타일 / 성능 / 테스트 여부)
PR diff 수집 — HTTP Request
URL: https://api.github.com/repos/{{ $json.repository.full_name }}/pulls/{{ $json.number }}/files
Method: GET
Headers:
Authorization: Bearer YOUR_GITHUB_TOKEN
Accept: application/vnd.github.v3+json변경 코드 추출 — Code 노드
const files = $input.all().map(i => i.json);
const diff = files
.slice(0, 5) // 최대 5개 파일만 (토큰 절약)
.map(f => `### ${f.filename}\n${f.patch?.slice(0, 1000) ?? '(변경 없음)'}`)
.join('\n\n');
return [{ json: { diff, fileCount: files.length, prNumber: $('Webhook').first().json.number } }];코드 리뷰 — OpenAI 프롬프트
Model: gpt-4o
System:
너는 시니어 개발자야. PR 코드를 리뷰하고 아래 항목을 체크해줘:
1. 보안 취약점
2. 성능 문제
3. 코드 가독성
4. 잠재적 버그
User:
다음 코드 변경사항을 리뷰해줘.
구체적인 개선 제안을 포함해서 마크다운 형식으로 작성해.
{{ $json.diff }}GitHub PR 코멘트 등록 — HTTP Request
URL: https://api.github.com/repos/{{ $('Webhook').first().json.repository.full_name }}/issues/{{ $json.prNumber }}/comments
Method: POST
Headers:
Authorization: Bearer YOUR_GITHUB_TOKEN
Body (JSON):
{ "body": "## 🤖 AI 코드 리뷰\n\n{{ $json.reviewContent }}" }⚠️ 주의AI 코드 리뷰는 참고용으로만 활용하고 최종 승인은 반드시 사람이 해야 합니다. 보안에 민감한 코드는 외부 AI API에 전달하지 않도록 주의하세요.
⚡
워크플로우 JSON
아래 JSON을 복사해 n8n에 바로 임포트하세요
📥 n8n 임포트 방법
n8n 화면 우측 상단 메뉴 (⋮) → Import from JSON → 아래 JSON 전체 복사 후 붙여넣기 → Import 클릭
n8n 화면 우측 상단 메뉴 (⋮) → Import from JSON → 아래 JSON 전체 복사 후 붙여넣기 → Import 클릭
{
"recipeId": "recipe049",
"title": "미답변 채팅 자동 배정",
"chapter": 4,
"category": "고객 응대 자동화",
"difficulty": "⭐⭐",
"estimatedTime": "30분",
"usedApps": [
"채널톡 API",
"Slack"
],
"description": "30분마다 채널톡에서 30분 이상 미답변 채팅을 조회하여, 현재 대기 중인 상담원 중 응대 건수가 가장 적은 담당자를 자동 배정하고 해당 담당자 Slack DM으로 알림을 보냅니다.",
"nodes": [
{
"id": "trigger_schedule",
"type": "trigger",
"service": "Schedule",
"name": "30분마다 실행",
"config": {
"interval": "every 30 minutes"
}
},
{
"id": "get_unanswered",
"type": "action",
"service": "HTTP Request",
"name": "미답변 채팅 조회",
"config": {
"method": "GET",
"url": "https://api.channel.io/open/v5/conversations",
"headers": {
"x-access-key": "{{$env.CHANNELTALK_ACCESS_KEY}}",
"x-access-secret": "{{$env.CHANNELTALK_ACCESS_SECRET}}"
},
"queryParams": {
"state": "open",
"assigneeType": "unassigned",
"waitingTimeGte": 30
}
}
},
{
"id": "check_exists",
"type": "condition",
"service": "IF",
"name": "미답변 채팅 존재 여부",
"config": {
"condition": "{{$json['conversations'] && $json['conversations'].length > 0}}"
}
},
{
"id": "get_agents",
"type": "action",
"service": "HTTP Request",
"name": "상담원 현황 조회",
"config": {
"method": "GET",
"url": "https://api.channel.io/open/v5/members",
"headers": {
"x-access-key": "{{$env.CHANNELTALK_ACCESS_KEY}}",
"x-access-secret": "{{$env.CHANNELTALK_ACCESS_SECRET}}"
},
"queryParams": {
"available": true
}
}
},
{
"id": "find_least_busy",
"type": "action",
"service": "Function",
"name": "가장 여유로운 상담원 선택",
"config": {
"code": "const agents = $node['get_agents'].json['members'];\nconst conversations = $node['get_unanswered'].json['conversations'];\n\n// 담당 건수 계산\nconst agentLoad = {};\nagents.forEach(a => agentLoad[a.id] = 0);\nconversations.forEach(c => {\n if (c.assigneeId) agentLoad[c.assigneeId] = (agentLoad[c.assigneeId] || 0) + 1;\n});\n\n// 가장 적은 담당자 선택\nconst leastBusy = agents.reduce((min, a) => \n (agentLoad[a.id] || 0) < (agentLoad[min.id] || 0) ? a : min\n);\n\nreturn conversations.map(conv => ({\n json: {\n conversationId: conv.id,\n customerName: conv.customer?.name || '비회원',\n waitingMinutes: conv.waitingMinutes,\n assigneeId: leastBusy.id,\n assigneeName: leastBusy.name,\n assigneeSlackId: leastBusy.slackId\n }\n}));"
}
},
{
"id": "assign_agent",
"type": "action",
"service": "HTTP Request",
"name": "채널톡 담당자 배정",
"config": {
"method": "PUT",
"url": "https://api.channel.io/open/v5/conversations/{{$json['conversationId']}}/assign",
"headers": {
"x-access-key": "{{$env.CHANNELTALK_ACCESS_KEY}}",
"x-access-secret": "{{$env.CHANNELTALK_ACCESS_SECRET}}"
},
"body": {
"assigneeId": "{{$json['assigneeId']}}"
}
}
},
{
"id": "notify_agent",
"type": "action",
"service": "Slack",
"name": "담당자 Slack 알림",
"config": {
"operation": "sendDM",
"userId": "{{$json['assigneeSlackId']}}",
"message": "💬 미답변 채팅이 배정되었습니다!\n\n👤 고객: {{$json['customerName']}}\n⏰ 대기 시간: {{$json['waitingMinutes']}}분\n\n👉 채널톡에서 바로 확인하세요!"
}
},
{
"id": "no_action",
"type": "noOp",
"name": "미답변 채팅 없음 — 스킵",
"config": {}
}
],
"edges": [
{
"from": "trigger_schedule",
"to": "get_unanswered"
},
{
"from": "get_unanswered",
"to": "check_exists"
},
{
"from": "check_exists",
"to": "get_agents",
"branch": "true"
},
{
"from": "check_exists",
"to": "no_action",
"branch": "false"
},
{
"from": "get_agents",
"to": "find_least_busy"
},
{
"from": "find_least_busy",
"to": "assign_agent"
},
{
"from": "assign_agent",
"to": "notify_agent"
}
],
"tips": [
"상담원의 Slack ID는 채널톡 멤버 정보 커스텀 필드에 미리 저장해두면 자동 매핑이 편해요.",
"대기 시간 기준(30분)은 팀 운영 방식에 따라 자유롭게 조정하세요. 바쁜 시간대는 15분으로 줄여볼 수 있어요.",
"배정 후 고객에게도 '담당자가 배정되었습니다. 잠시만 기다려주세요.' 자동 메시지를 보내면 이탈률을 낮출 수 있어요."
],
"qrUrl": "www.koreaaitimes.com/books/n8n-vol2/recipe049"
}