카카오톡 자동 발송 프로그램: API 없이 372줄로 답장까지 받는다

카카오톡 자동 발송 프로그램: API 없이 372줄로 답장까지 받는다

핵심 요약

  • 맥 카카오톡은 macOS 접근성(Accessibility) API로 조작할 수 있습니다. 화면 읽기 프로그램이 쓰는 길을 빌려 다른 앱의 입력창, 메시지 목록, 버튼을 객체로 읽고 누릅니다.
  • Node.js에서 osascript로 AppleScript를 실행해 입력창에 글을 넣고 Enter를 누릅니다. 넣은 값을 다시 읽어 확인하고, 전송 뒤 입력창이 비었는지도 봅니다.
  • 어려운 쪽은 보내기가 아니라 답장이 끝났는지 판단하기입니다. 메시지 목록을 2초마다 읽고, 새 말풍선이 있는 상태로 10초 동안 변화가 없으면 끝난 것으로 봅니다.
  • 잘린 긴 말풍선은 "전체보기"의 저장 기능으로 원문을 받아 옵니다. 저장 파일이 10초 안에 생기지 않으면 잘린 본문에 실패 표시를 붙여 남깁니다.
  • UI 조작은 한 번에 하나만 하고, 답을 기다리는 시간만 겹쳐 여러 대화방을 동시에 진행합니다. 개인 계정을 자동으로 조작하는 일은 카카오톡 운영정책에 걸릴 수 있어, 필자는 대회에서 인터뷰 대상으로 주어진 챗봇에게 질문하고 우리 팀 챗봇의 응답을 시험하는 데만 썼습니다.

목차

왜 화면 자동화를 골랐나

필자는 챗봇 대회에 나가면서 카카오톡으로 여러 관계자 챗봇을 인터뷰해야 했습니다. 한 대화방에 질문을 대여섯 개 보내고, 답을 읽고, 빈 곳을 다시 묻는 일을 여러 차수에 걸쳐 반복했습니다. 손으로 하면 질문을 빠뜨리거나, 긴 답을 옮겨 적다 일부를 놓치기 쉽습니다.

그런데 내 카카오톡 계정으로 아무 대화방(챗봇 대화방 포함)에 메시지를 보내고 답을 읽어 오는 공식 API는 없습니다. 카카오톡 메시지 API는 "같은 서비스 내 사용자간 메시지 발송만 지원"하고, 보내는 곳은 나와의 채팅방과 친구 두 가지뿐이며, 대화방을 읽는 API는 목록에 없습니다(카카오디벨로퍼스, 카카오톡 메시지). 알림톡은 서비스의 카카오톡 채널이 사용자에게 보내는 메시지이고(같은 카카오디벨로퍼스 문서의 제품 비교표), 챗봇 관리자센터로 만든 챗봇도 카카오톡 채널에 연결해 답하는 쪽입니다(카카오 비즈니스 가이드, 챗봇 관리자센터). 둘 다 질문하는 사람 역할을 대신할 수 없습니다.

정해진 시각에 한 번 보내는 것으로 충분하면 맥 카카오톡의 예약 메시지로 됩니다(카카오톡 Mac 3.1.6 업데이트 공지). 대화를 통째로 남기는 것은 채팅방 설정의 "대화 내용 저장"으로 txt 파일을 받으면 됩니다(카카오 고객센터). 필자에게 필요한 것은 답을 확인하고 다음 질문을 보내는 일을 반복하는 것이었고, 이건 둘 다로는 안 됩니다. 결국 사람이 맥 카카오톡 앱에서 하는 동작을 그대로 흉내 내는 스크립트를 만들었습니다. 필자가 찾아본 범위에서는 국내에 공개된 카카오톡 자동 발송 글 대부분이 윈도우 PC 카카오톡을 pyautogui나 win32 API로 다루고, 맥에서 접근성 API로 답장까지 읽는 글은 드물었습니다. 이 글은 그 스크립트(kakao-ask.js, 372줄)가 어떻게 돌아가는지 단계별로 설명하고, 맨 끝에 전체 코드를 붙였습니다. 아래 표는 필자가 검토한 방법을 정리한 것입니다.

방법 할 수 있는 일 이 용도에서의 한계
카카오톡 메시지 API 나와의 채팅방, 친구에게 템플릿 메시지 보내기 아무 대화방에 보낼 수 없고 답을 읽는 API가 없음
알림톡, 챗봇 회사 카카오톡 채널이 사용자에게 보내거나 답하기 질문하는 개인 계정 쪽이 아님
예약 메시지 정해 둔 시각에 메시지 한 번 보내기 답을 보고 다음 질문을 이어 보낼 수 없음
대화 내용 저장 채팅방 대화를 txt 파일로 받기 다 끝난 뒤 한 번에 받는 기능
이 글의 스크립트 질문 보내기, 답이 끝날 때까지 기다리기, 질문별 로그 화면 조작이라 운영정책과 UI 변경에 취약
맥 카카오톡 입력창 아래 도구 막대에서 일정 버튼과 예약 메시지 버튼을 확대해 보여 주고, 오른쪽에 채팅방 메뉴의 브리핑 보드 항목을 강조한 공지 이미지
맥 카카오톡 3.1.6 공지의 그림입니다. 입력창 아래의 시계 모양 말풍선이 예약 메시지 버튼이고, 예약 내역은 채팅방 메뉴의 브리핑 보드에서 봅니다. 출처: 카카오톡 Mac 3.1.6 업데이트 공지

준비물

  • macOS와 맥용 카카오톡. 로그인한 상태에서 질문할 대화방 창을 미리 열어 둡니다.
  • Node.js 20.19 이상(22 버전은 22.7 이상). 외부 패키지는 필요 없습니다. 스크립트가 import와 최상위 await를 쓰는 ES 모듈이라, 이보다 낮은 버전이면 파일 이름을 .mjs로 바꿔 실행합니다. osascript는 macOS에 기본으로 들어 있습니다.
  • 손쉬운 사용 권한. 시스템 설정 → 개인정보 보호 및 보안 → 손쉬운 사용에서 스크립트를 실행할 터미널 앱을 허용합니다. 이 권한이 없으면 다른 앱의 UI를 읽거나 조작할 수 없습니다. Apple은 보안과 개인정보 보호를 위해 이 권한이 기본으로 꺼져 있고 사용자가 앱마다 직접 켜야 한다고 설명합니다(Apple, Mac Automation Scripting Guide). 현재 macOS의 경로는 Apple 지원 문서에도 같은 이름으로 나옵니다(Apple 지원, 손쉬운 사용 앱이 Mac에 접근하도록 허용하기).
예전 macOS의 보안 및 개인 정보 보호 설정 손쉬운 사용 목록에서 스크립트 앱과 스크립트 편집기에 컴퓨터 제어를 허용한 화면
예전 macOS의 손쉬운 사용 권한 목록입니다. 지금은 시스템 설정 → 개인정보 보호 및 보안 → 손쉬운 사용에 같은 목록이 있고, 여기에 스크립트를 실행할 터미널 앱을 넣습니다. 출처: Apple, Mac Automation Scripting Guide

원리: 화면 읽기 프로그램이 쓰는 길을 빌린다

macOS의 접근성 프레임워크는 VoiceOver 같은 보조 기술이 앱 화면을 읽고 조작할 수 있게 UI를 트리 구조로 보여 줍니다. 창 안에 스크롤 영역이 있고, 그 안에 표가 있고, 표의 행마다 텍스트가 있는 식입니다. 얼마나 자세히 보이는지는 앱이 접근성 정보를 얼마나 노출하느냐에 달려 있습니다(Apple, Accessibility Programming Guide for OS X). Apple 가이드는 UI 스크립트가 마우스 클릭과 키 입력 같은 사용자 동작을 흉내 내고, 이 기능이 접근성 프레임워크에 기대며, 관련 명령이 System Events 앱의 Processes Suite에 있다고 설명합니다(Apple, Mac Automation Scripting Guide). AppleScript에서는 tell application "System Events" 안에서 tell process "KakaoTalk"으로 대상을 잡습니다.

맥 카카오톡 대화방 창에서 상대 이름 나리 아래 말풍선이 여러 개 이어지고 말풍선 묶음 끝에 오후 5시 45분 같은 시각이 붙어 있으며 아래에 입력창과 전송 버튼이 있는 화면. 오른쪽은 대화 캡처 영역을 고르는 화면
맥 카카오톡 대화방 창입니다(2023년 3.1.6 공지 화면이라 지금 버전과 다를 수 있습니다). 이름은 연속된 말풍선 가운데 첫 말풍선에만 붙고, 시각은 말풍선 옆에 따로 붙습니다. 오른쪽 절반은 공지가 소개한 대화 캡처 화면입니다. 아래 트리의 static text, text area, 입력창이 화면의 이 부분들입니다. 출처: 카카오톡 Mac 3.1.6 업데이트 공지

이 창을 접근성 트리로 보면 대략 이렇게 생겼습니다.

window "대화방 이름"
├─ scroll area 1
│   └─ table 1              ← 메시지 목록
│       └─ row i
│           └─ UI element 1
│               ├─ static text   ← 보낸 사람 이름, "오후 3:12" 같은 시각
│               ├─ text area 1   ← 말풍선 본문
│               └─ button        ← 긴 메시지의 "전체보기"
└─ (마지막) scroll area
    └─ text area            ← 메시지 입력창

이 구조를 알면 할 일은 단순합니다. 입력창에 글을 넣고 Enter를 누르면 전송이고, 표의 행을 읽으면 대화 내용을 얻습니다. UI 구조를 처음 파악할 때는 Xcode에 들어 있는 Accessibility Inspector로 요소를 하나씩 찍어 보면 편합니다. Xcode 메뉴의 Open Developer Tool → Accessibility Inspector로 열고, 기본으로 맨 앞에 있는 앱을 살펴봅니다(Apple, Accessibility Inspector, Inspecting the accessibility of the screens in your app).

Accessibility Inspector가 메뉴 항목의 계층을 AXApplication, AXMenuBar, AXMenuBarItem, AXMenu, AXMenuItem 순서로 보여 주고 그 아래 속성과 동작 목록을 펼친 화면
예전 버전 Accessibility Inspector 화면입니다. 위쪽은 앱에서 메뉴 항목까지 내려가는 요소 계층이고, 아래쪽은 그 요소의 역할, 제목 같은 속성과 실행할 수 있는 동작입니다. 카카오톡 창도 같은 방법으로 들여다봤습니다. 출처: Apple, Mac Automation Scripting Guide

Node.js에서 AppleScript 실행하기

스크립트 본체는 Node.js로 쓰고, 카카오톡을 만지는 부분만 AppleScript로 썼습니다. 실행은 osascript에 스크립트를 표준 입력으로 넘기는 방식입니다.

import { execFileSync } from "node:child_process";

function osa(script, ...args) {
  return execFileSync("osascript", ["-", ...args.map(String)], {
    input: script,
    encoding: "utf8",
    stdio: "pipe",
  }).trim();
}

질문 텍스트는 스크립트 문자열에 끼워 넣지 않고 인자(argv)로 넘깁니다. AppleScript 안에서는 on run argv로 받습니다. man osascript에 따르면 표준 입력으로 읽는 스크립트에 인자를 넘기려면 스크립트 이름 자리에 -를 써야 하고, 그 뒤의 인자는 문자열 목록으로 run 핸들러에 들어갑니다. Node.js의 execFileSync는 input 옵션 값을 자식 프로세스의 표준 입력으로 넘깁니다(Node.js, child_process). 이렇게 하면 질문에 따옴표나 줄바꿈이 있어도 이스케이프를 신경 쓸 필요가 없고, 질문 내용이 스크립트 코드로 해석될 일도 없습니다.

1단계: 대화방 창 찾기

필자가 써 보니 카카오톡은 앱이 앞에 나와 있을 때만 창을 접근성 API에 제대로 노출했습니다. 그래서 모든 AppleScript는 activate로 시작합니다.

창 이름으로 바로 참조하는 방식(window "대화방 이름")은 가끔 창을 못 찾았습니다. 그래서 창 목록을 돌면서 이름이 같은 창의 번호를 찾아 번호로 접근합니다.

on winIdx(winName)
  tell application "System Events" to tell process "KakaoTalk"
    repeat with i from 1 to count of windows
      if name of window i is winName then return i
    end repeat
  end tell
  error "window not found: " & winName number -1728
end winIdx

그래도 창을 못 찾는 오류(-1719, -1728)가 나면 카카오톡을 다시 앞으로 띄우고 1초 기다린 뒤 한 번 더 시도합니다. AppleScript 오류표에서 -1719는 "인덱스가 잘못됐거나 범위를 벗어남", -1728은 "가리킨 객체가 없음"입니다(Apple, AppleScript Error Codes).

주의할 점이 하나 있습니다. 손쉬운 사용 권한이 빠졌을 때도 -1719가 나옵니다. 이때는 다시 시도해도 같은 오류가 반복되니, 재시도 뒤에도 -1719가 나면 권한부터 확인합니다.

System Events got an error 문구 뒤에 My UI Script is not allowed assistive access, 오류 번호 -1719가 적힌 오류 창
손쉬운 사용 권한 없이 UI 스크립트를 돌렸을 때 나오는 오류 창입니다. 창을 못 찾을 때와 같은 -1719가 찍힙니다. 출처: Apple, Mac Automation Scripting Guide

2단계: 메시지 입력과 전송

입력창은 창의 마지막 스크롤 영역에 있습니다. 첫 번째가 아니라 마지막을 고른 이유는 메시지 목록과 입력창 사이에 다른 영역이 끼어드는 경우가 있었기 때문입니다.

set ta to first text area of last scroll area of window wi
set focused of ta to true
set value of ta to expected      -- 클립보드 대신 값을 직접 넣는다
delay 0.4
if (value of ta) as string is not expected then
  set value of ta to ""
  error "pasted text mismatch"
end if
key code 36                      -- Return(Enter) 키

흔히 쓰는 방법은 클립보드에 복사해서 붙여 넣는 것입니다. 그런데 필자 환경에서는 pbcopy가 가끔 아무 오류 없이 클립보드에 값을 넣지 못했습니다. 그래서 입력창 값을 직접 넣습니다. 넣은 뒤에는 입력창 내용을 다시 읽어 보내려던 문장과 같은지 확인하고, 다르면 지우고 멈춥니다.

Enter를 누른 뒤에는 입력창이 비었는지 봅니다. 글자가 남아 있으면 전송이 안 된 것이므로 오류를 냅니다.

3단계: 답장이 끝났는지 판단하기

이 부분이 가장 까다롭습니다. 챗봇은 말풍선을 여러 개로 나눠 보내기도 하고, 첫 말풍선과 다음 말풍선 사이에 몇 초씩 걸리기도 합니다. "새 메시지가 생겼다"만으로는 답이 끝났는지 알 수 없습니다.

그래서 이렇게 판단합니다.

  1. 질문을 보내기 전에 메시지 목록의 행 수를 세어 둡니다.
  2. 보낸 뒤 2초마다 그 이후의 행만 읽습니다. 각 행에서 보낸 사람 이름과 본문을 뽑습니다.
  3. 읽은 내용이 바뀌면 타이머를 다시 시작합니다.
  4. 새 말풍선이 있고 10초 동안 아무 변화가 없으면 답이 끝난 것으로 봅니다.
  5. 4분이 지나도 답이 끝나지 않으면 실패로 처리합니다.

행을 읽을 때 걸린 함정이 두 가지 있었습니다. 하나는 보낸 사람 이름과 시각이 똑같이 static text로 나온다는 점입니다. "오전"이나 "오후"로 시작하는 값은 시각이니 이름에서 뺍니다. 다른 하나는 여러 말풍선으로 온 답에서 이름이 첫 말풍선에만 붙는다는 점입니다. 그래서 이름이 붙은 첫 행을 찾은 뒤, 그 뒤에 이어진 이름 없는 행까지 한 답으로 묶습니다.

AppleScript에서 Node.js로 여러 행을 넘길 때는 레코드 구분자(\x1e)와 필드 구분자(\x1f)를 썼습니다. 본문에 줄바꿈이나 탭이 들어 있어도 섞이지 않습니다.

4단계: 잘린 긴 말풍선의 원문 가져오기

맥 카카오톡은 긴 메시지를 "…"로 잘라서 보여 주고, 전체 내용은 "전체보기" 버튼을 눌러야 볼 수 있습니다. 접근성 API로 읽은 본문도 잘린 상태입니다.

원문이 필요해서 사람이 하는 순서를 그대로 따라 했습니다.

  1. 본문이 "…"로 끝나면 그 행의 "전체보기" 버튼을 누릅니다.
  2. 열린 대화상자에서 저장 버튼을 누르면 ~/Downloads에 KakaoTalk_Chat_….txt 파일이 생깁니다.
  3. 저장 동작을 시작하기 1초 전 이후에 수정된 KakaoTalk_Chat_*.txt 파일을 찾아 읽고, 바로 지웁니다.
  4. 10초 안에 파일이 안 생기면 잘린 본문 끝에 "전체보기 저장 실패"라고 표시해서 남깁니다.

마지막 단계 덕분에 저장 파일을 못 받았을 때도 로그를 보면 바로 알 수 있습니다. 대화상자가 아예 열리지 않으면 그 자리에서 오류를 내고 멈춥니다.

여러 대화방 동시에 돌리기

관계자가 여럿이라 대화방을 하나씩 처리하면 오래 걸렸습니다. 시간 대부분은 답을 기다리는 데 들어갑니다. 이 때문에 --multi 모드를 만들었습니다. 필자는 관계자 챗봇 9개를 인터뷰하면서 질문 313개를 이 스크립트와 같은 ### Q번호 형식의 로그 9개로 남겼습니다. 다만 차수 대부분은 질문을 손으로 묶어 보내고 대화를 로그에 옮겨 적었고, 스크립트로 보낸 것은 일부 차수입니다.

node kakao-ask.js --multi "대화방A=a-questions.md" "대화방B=b-questions.md" --linger 300

키보드 포커스는 하나뿐이니 입력과 전송은 차례대로 합니다. 대신 한 대화방에 질문을 보내고 답을 기다리는 동안 다른 대화방에 질문을 보냅니다. 루프를 돌면서 대화방마다 "답을 기다리는 중이면 새 행을 확인하고, 할 일이 없으면 다음 질문을 보낸다"를 반복합니다.

질문 파일은 1. 질문, 2. 질문 형식의 마크다운입니다. 답은 ### Q1. 질문 제목 아래에 로그 파일로 덧붙입니다. 여기에 두 가지를 더했습니다.

  • 이어서 하기: 로그에 같은 번호와 같은 문장의 제목이 이미 있으면 그 질문은 건너뜁니다. 중간에 끊겨도 다시 실행하면 이어서 진행됩니다.
  • 실행 중 질문 추가: 루프를 돌 때마다 질문 파일을 다시 읽습니다. 앞선 답을 보고 질문 파일에 줄을 추가하면, 그 대화방이 한가해지는 대로 이어서 보냅니다. 모든 대화방이 한가해진 뒤 --linger로 정한 시간(기본 120초) 동안 새 질문이 없으면 끝납니다.

한 대화방에서 오류가 나면 그 대화방만 멈추고 나머지는 계속 진행합니다.

사고를 막는 안전장치

화면을 직접 조작하는 스크립트라 사고가 나면 엉뚱한 대화방에 메시지가 갈 수 있습니다. 그래서 몇 가지 장치를 넣었습니다.

  • 실행 잠금: 여러 터미널에서 동시에 실행하면 입력이 섞입니다. 카카오톡을 조작하고 답을 기다리는 동안 /tmp/kakao-ask.lock 디렉터리를 만들어 그 안에 프로세스 번호를 적고, 다른 실행은 잠금이 풀릴 때까지 기다립니다. mkdir은 이미 있으면 실패하므로 잠금으로 쓰기 좋습니다. 잠금을 가진 프로세스가 이미 죽었으면 잠금을 지우고 가져갑니다. Ctrl+C로 중단해도 잠금은 풀리게 했습니다.
  • 클립보드 복원: 실행 전에 클립보드의 텍스트를 pbpaste로 저장해 두고, 정상적으로 끝나면 pbcopy로 되돌립니다. 지금 버전은 입력에 클립보드를 쓰지 않으니 예전 방식에서 남은 장치입니다. 텍스트만 저장하므로 이미지나 파일을 복사해 둔 상태였다면 실행 뒤 원래 클립보드는 돌아오지 않습니다.
  • 길이 제한: 200자가 넘는 질문은 보내지 않고 건너뜁니다. 실수로 긴 문서를 통째로 보내는 일을 막기 위해서이고, 필자가 인터뷰한 챗봇 대화방도 질문 여러 개를 묶어 길게 보냈을 때 "[SYSTEM] 200자 이하로 입력해주세요"라며 받지 않았습니다.
  • 테스트는 나와의 채팅에서: --send-only 모드로 먼저 나와의 채팅에 보내 보고, 동작을 확인한 뒤에 실제 대화방에 씁니다.

한계와 카카오톡 운영정책

  • 카카오톡이 계속 앞으로 튀어나옵니다. 실행 중에는 맥으로 다른 일을 하기 어렵습니다.
  • UI 구조가 바뀌면 깨집니다. 카카오톡이 업데이트되어 창 구조가 달라지면 요소를 찾는 경로를 다시 맞춰야 합니다.
  • "10초 무변화"는 추정입니다. 챗봇이 말풍선 사이에 10초 넘게 쉬면 답이 덜 끝난 상태로 기록될 수 있습니다. 대상에 따라 코드의 SETTLE_MS(기본 10000밀리초) 값을 조정해야 합니다.
  • 오래 돌리면 카카오톡이 무거워질 수 있습니다. 맥에서 여러 대화방 창을 돌며 읽는 봇을 운영한 한 개발자는 카카오톡이 메모리를 22GB까지 쓰고 있었고, 한 방을 읽는 데 걸리는 시간도 82초까지 늘었다고 적었습니다. 처음에는 읽은 횟수 탓으로 봤다가, 같은 방을 1분 간격으로 몰아 읽을 때만 읽기 시간이 늘고 5분 간격에서는 늘지 않았다고 결론을 고쳤습니다(한 개발자의 네이버 블로그 글). 이 글의 스크립트도 답을 기다리는 동안 2초마다 읽으니, 몇 시간씩 돌릴 때는 활성 상태 보기에서 카카오톡 메모리를 확인하는 편이 좋습니다.
  • 맥에서만 돕니다. Windows 카카오톡에는 다른 방법이 필요합니다.
  • 허가된 용도로만 씁니다. 아래에 따로 적었습니다.

카카오톡 운영정책은 "카카오톡이 허용하지 않는 방법으로 서비스를 이용하는" 행위를 금지 행위로 두고, 이런 행위에는 즉시 카카오톡 전체 서비스 이용이 제한될 수 있다고 적었습니다(카카오톡 운영정책, 허용하지 않는 방법에 의한 서비스 이용). 정책에서 "매크로"라는 말이 직접 나오는 곳은 카카오톡의 프로토콜이나 API를 악용한 프로그램(봇, 매크로 등)을 만들어 남에게 퍼뜨리는 행위입니다. 운영정책 문서에서 공식 앱의 화면을 접근성 API로 조작하는 방식을 직접 언급한 문장은 찾지 못했습니다. 하지만 카카오 데브톡에서 화면을 자동으로 읽고 카카오톡의 버튼을 누르는 상용 오픈채팅 관리 앱을 묻는 질문에 카카오 측 계정이 카카오디벨로퍼스 운영정책의 회원 의무(제1조)와 금지된 행동(제5조) 조항을 들어 "운영정책에서는 말씀과 같은 사용행위를 금지하고 있는점 참고 부탁드립니다"라고 답했습니다(카카오 데브톡, 카카오디벨로퍼스 운영정책). 카카오톡 운영정책과는 다른 문서지만, 화면을 자동으로 읽고 버튼을 누르는 방식을 카카오 측이 금지로 본다는 점은 분명합니다. 허용된다고 보기는 어렵습니다.

카카오는 대화 내용이 아니라 이용환경과 이용패턴을 자동으로 감지해 보호조치를 겁니다. 짧은 기간에 채팅방을 많이 만들거나 PC 에뮬레이터 같은 비정상 환경에서 쓰는 경우가 예로 나오고, 조치가 걸리면 짧은 시간 안에 많은 메시지를 보내지 못하거나 PC 버전 같은 서브디바이스를 쓰지 못할 수 있습니다. 추가로 감지되는 특이사항이 없으면 대부분 수일 안에 자동으로 풀린다고 합니다(자동감지에 의한 이용자보호조치). 이 스크립트는 한 대화방에 질문을 하나씩, 앞 질문의 답이 끝난 뒤에만 보내므로 한 대화방에 메시지를 몰아 보내지는 않습니다. 다만 --multi에서는 한가한 대화방마다 바로 보내므로 대화방 수만큼 몇 초 간격으로 메시지가 나갑니다. 필자는 대회에서 인터뷰 대상으로 주어진 챗봇에게 질문하고 우리 팀 챗봇의 응답을 시험하는 데만 썼고, 광고나 대량 발송에 쓰면 안 됩니다.

챗봇이 답을 보내는 쪽은 다르다

이 스크립트는 질문하는 사람 쪽을 흉내 냅니다. 반대편인 필자 팀의 챗봇은 화면 조작 없이 카카오 봇 게이트웨이에 WebSocket으로 연결해 메시지를 받고, 콜백 API로 답을 보냅니다. 카카오디벨로퍼스의 카카오톡 봇 문서(로그인해야 열립니다)도 봇 서버가 웹소켓으로 상시 연결해 발화를 받고, 답장은 웹소켓이 아닌 메시지 콜백 API로 보낸다고 설명합니다. 이름이 비슷한 챗봇 관리자센터(구 카카오 i 오픈빌더)의 AI 챗봇 콜백은 스킬 서버 방식의 다른 기능입니다. 서버에서 상시로 돌아가는 쪽은 이렇게 정식 연동을 쓰고, 화면 자동화는 필자의 맥에서 인터뷰를 하거나 우리 팀 챗봇의 응답을 시험할 때만 썼습니다.

전체 코드: kakao-ask.js

아래는 이 글에서 설명한 스크립트 전체입니다. Node.js 20.19 이상(22 버전은 22.7 이상)에서 외부 패키지 없이 돌아갑니다. 허락받은 대화 상대에게 질문하는 용도로 만든 것이니 광고나 대량 발송에는 쓰지 않습니다. 사용법부터 적습니다.

# 질문 파일의 "N. 질문" 줄을 차례대로 보내고 답을 로그에 기록
node kakao-ask.js "대화방 이름" questions.md log.md

# 질문 하나만 보내기
node kakao-ask.js "대화방 이름" --q "질문"

# 답을 기다리지 않고 보내기만 (나와의 채팅 테스트용)
node kakao-ask.js "대화방 이름" --send-only "메시지"

# 최근 메시지 N개 읽기
node kakao-ask.js "대화방 이름" --read 5

# 여러 대화방 동시 진행
node kakao-ask.js --multi "대화방A=a-questions.md" "대화방B=b-questions.md"
// Asks a KakaoTalk chatbot questions by driving the macOS KakaoTalk app (AppleScript UI scripting).
// The chat window must already be open. One question at a time: send, wait for the reply to settle, log it.
//   node scripts/kakao-ask.js <창이름> <questions.md> [log.md]   -> asks every "N. ..." line in order
//   node scripts/kakao-ask.js <창이름> --q "질문" [log.md]
//   node scripts/kakao-ask.js <창이름> --send-only "메시지"      -> send without waiting (e.g. 나와의 채팅 test)
//   node scripts/kakao-ask.js <창이름> --read [N]                -> print the last N messages (full text)
//   node scripts/kakao-ask.js --multi 창=questions.md[=log.md] ... [--linger 초]  -> several windows at once
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";

const RS = "\x1e";
const US = "\x1f";
const DOWNLOADS = path.join(os.homedir(), "Downloads");
const SETTLE_MS = 10000;
const TIMEOUT_MS = 240000;
const UTF8_ENV = { ...process.env, LANG: "en_US.UTF-8" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Several sessions may run this script at once; KakaoTalk has one focus, so each send+wait holds a
// machine-wide lock (a directory with the holder's pid). A lock whose pid is gone is stale.
const LOCK = "/tmp/kakao-ask.lock";
let lockOwned = false;
function releaseLock() {
  if (!lockOwned) return;
  lockOwned = false;
  fs.rmSync(LOCK, { recursive: true, force: true });
}
function lockHolderAlive() {
  try {
    process.kill(Number(fs.readFileSync(path.join(LOCK, "pid"), "utf8")), 0);
    return true;
  } catch (e) {
    return e.code === "EPERM" || e.code === "ENOENT"; // ENOENT: holder is still writing its pid
  }
}
async function withLock(fn) {
  let waited = false;
  while (!lockOwned) {
    try {
      fs.mkdirSync(LOCK);
      lockOwned = true;
      fs.writeFileSync(path.join(LOCK, "pid"), String(process.pid));
    } catch (e) {
      if (e.code !== "EEXIST") break; // cannot create the lock here (e.g. sandboxed /tmp): run unlocked
      if (!lockHolderAlive()) {
        fs.rmSync(LOCK, { recursive: true, force: true });
        continue;
      }
      if (!waited) console.error("waiting: another kakao-ask run is using KakaoTalk");
      waited = true;
      await sleep(1000);
    }
  }
  try {
    return await fn();
  } finally {
    releaseLock();
  }
}
for (const sig of ["SIGINT", "SIGTERM"])
  process.on(sig, () => {
    releaseLock();
    process.exit(130);
  });

// KakaoTalk only exposes its windows to accessibility while it is the active app, so every script
// activates it first; a window lookup that still fails (-1719/-1728) is retried once.
function osa(script, ...args) {
  const run = () =>
    execFileSync("osascript", ["-", ...args.map(String)], { input: script, encoding: "utf8", stdio: "pipe" }).trim();
  try {
    return run();
  } catch (e) {
    if (!/\(-17(19|28)\)/.test(String(e.stderr))) throw e;
    execFileSync("osascript", ["-e", 'tell application "KakaoTalk" to activate', "-e", "delay 1"]);
    return run();
  }
}

// Windows are addressed by index: by-name references to KakaoTalk windows resolve intermittently.
const WIN_IDX = `
on winIdx(winName)
  tell application "System Events" to tell process "KakaoTalk"
    repeat with i from 1 to count of windows
      if name of window i is winName then return i
    end repeat
  end tell
  error "window not found: " & winName number -1728
end winIdx`;

const SNAPSHOT = `${WIN_IDX}
on run argv
  set winName to item 1 of argv
  set startI to (item 2 of argv) as integer
  set RS to character id 30
  set US to character id 31
  tell application "KakaoTalk" to activate
  delay 0.3
  set wi to my winIdx(winName)
  tell application "System Events" to tell process "KakaoTalk"
    set t to table 1 of scroll area 1 of window wi
    set n to count of rows of t
    set out to (n as string) & RS
    if startI < 1 then set startI to 1
    repeat with i from startI to n
      set who to ""
      set body to ""
      try
        set c to UI element 1 of row i of t
        repeat with s in (static texts of c)
          set v to (value of s) as string
          if v does not start with "오전" and v does not start with "오후" then set who to v
        end repeat
        try
          set body to (value of text area 1 of c) as string
        end try
      end try
      set out to out & i & US & who & US & body & RS
    end repeat
    return out
  end tell
end run`;

const SEND = `${WIN_IDX}
on run argv
  set winName to item 1 of argv
  set expected to item 2 of argv
  tell application "KakaoTalk" to activate
  delay 0.3
  tell application "System Events" to tell process "KakaoTalk" to perform action "AXRaise" of window (my winIdx(winName))
  delay 0.3
  set wi to my winIdx(winName)
  tell application "System Events" to tell process "KakaoTalk"
    -- the input box is the last scroll area: a "텍스트 엔트리 영역" pane can appear between the list and it
    set ta to first text area of last scroll area of window wi
    set focused of ta to true
    set value of ta to expected -- AX value, not the clipboard: pbcopy can silently fail to reach the pasteboard
    delay 0.4
    set pasted to ""
    try
      set pasted to (value of ta) as string
    end try
    if pasted is not expected then
      set value of ta to ""
      error "pasted text mismatch: " & pasted
    end if
    key code 36
    delay 0.5
    set leftover to ""
    try
      set leftover to (value of ta) as string
    end try
    return leftover
  end tell
end run`;

// Long bubbles end with "…"; the "전체보기" dialog's download button saves the full text to ~/Downloads.
const SAVE_FULL = `${WIN_IDX}
on run argv
  set winName to item 1 of argv
  set ri to (item 2 of argv) as integer
  tell application "KakaoTalk" to activate
  delay 0.3
  tell application "System Events" to tell process "KakaoTalk" to perform action "AXRaise" of window (my winIdx(winName))
  delay 0.3
  set wi to my winIdx(winName)
  tell application "System Events" to tell process "KakaoTalk"
    click (last button of UI element 1 of row ri of table 1 of scroll area 1 of window wi)
    delay 1.2
    if subrole of window 1 is not "AXDialog" then error "full-text dialog did not open"
    click button 2 of window 1
    delay 1.0
    keystroke return
    delay 1.2
    click (first button of window 1 whose description is "닫기 버튼")
  end tell
end run`;

function snapshot(win, start) {
  const [n, ...rows] = osa(SNAPSHOT, win, start).split(RS);
  return {
    count: Number(n),
    rows: rows.filter(Boolean).map((r) => {
      const [i, who, body] = r.split(US);
      return { i: Number(i), who, body };
    }),
  };
}

async function fullText(win, row) {
  if (!row.body.endsWith("…")) return row.body;
  const since = Date.now() - 1000;
  osa(SAVE_FULL, win, row.i);
  for (let t = 0; t < 20; t++) {
    const f = fs
      .readdirSync(DOWNLOADS)
      .filter((n) => n.startsWith("KakaoTalk_Chat_") && n.endsWith(".txt"))
      .map((n) => path.join(DOWNLOADS, n))
      .find((p) => fs.statSync(p).mtimeMs > since);
    if (f) {
      const text = fs.readFileSync(f, "utf8").replace(/^/, "");
      fs.unlinkSync(f);
      return text;
    }
    await sleep(500);
  }
  return row.body + "\n[전체보기 저장 실패: 잘린 본문]";
}

function send(win, text) {
  const left = osa(SEND, win, text);
  if (left) throw new Error(`message still in the input box after Enter: ${left}`);
}

async function ask(win, question) {
  const before = snapshot(win, 99999999).count;
  send(win, question);

  const started = Date.now();
  let lastSig = "";
  let stableSince = Date.now();
  while (Date.now() - started < TIMEOUT_MS) {
    await sleep(2000);
    const snap = snapshot(win, before + 1);
    const sig = JSON.stringify(snap.rows);
    if (sig !== lastSig) {
      lastSig = sig;
      stableSince = Date.now();
      continue;
    }
    // Only the first bubble of a multi-bubble reply carries the sender name; the rest follow it unnamed.
    const first = snap.rows.findIndex((r) => r.who && r.body);
    const replies = first < 0 ? [] : snap.rows.slice(first).filter((r) => r.body);
    if (replies.length && Date.now() - stableSince >= SETTLE_MS) {
      const parts = [];
      for (const r of replies) parts.push(await fullText(win, r));
      return parts.join("\n\n");
    }
  }
  throw new Error(`no reply from ${win} within ${TIMEOUT_MS / 1000}s`);
}

function parseQuestions(file) {
  return fs
    .readFileSync(file, "utf8")
    .split("\n")
    .map((l) => l.match(/^\s*(\d+)\.\s+(.+)$/))
    .filter(Boolean)
    .map((m) => ({ n: m[1], q: m[2].trim() }));
}

const logEntry = (n, q, a) => `### Q${n}. ${q}\n\n${a}\n\n`;

// --multi: one process tends several chat windows. UI actions stay serial (one focus), but the slow part,
// waiting for each bot's reply, overlaps. Question files are re-read every tick, so lines appended while it
// runs are sent once that window is free. A question counts as asked when its "### Qn. 질문" header is in
// the log (number and text both: one log can hold several rounds that reuse numbers), so a rerun resumes.
function parseSpec(spec) {
  const [win, qfile, log = qfile?.replace(/-questions[^/]*\.md$/, "-log.md")] = spec.split("=");
  if (!win || !qfile || log === qfile) throw new Error(`bad spec "${spec}": 창이름=questions.md[=log.md]`);
  if (!fs.existsSync(qfile)) throw new Error(`no such questions file: ${qfile}`);
  return { win, qfile, log, waiting: null, done: new Set(), failed: false };
}

async function multi(people, lingerMs) {
  const asked = (p, { n, q }) =>
    p.done.has(`${n}. ${q}`) || (fs.existsSync(p.log) && fs.readFileSync(p.log, "utf8").includes(`### Q${n}. ${q}\n`));
  let idleSince = null;

  while (true) {
    for (const p of people.filter((p) => !p.failed)) {
      try {
        const w = p.waiting;
        if (w) {
          const snap = snapshot(p.win, w.before + 1);
          const sig = JSON.stringify(snap.rows);
          const first = snap.rows.findIndex((r) => r.who && r.body);
          const replies = first < 0 ? [] : snap.rows.slice(first).filter((r) => r.body);
          if (sig !== w.lastSig) {
            w.lastSig = sig;
            w.stableSince = Date.now();
          } else if (replies.length && Date.now() - w.stableSince >= SETTLE_MS) {
            const parts = [];
            for (const r of replies) parts.push(await fullText(p.win, r));
            const a = parts.join("\n\n");
            console.log(`${p.win}> [${w.n}] ${a}\n`);
            fs.appendFileSync(p.log, logEntry(w.n, w.q, a));
            p.waiting = null;
          } else if (Date.now() - w.started > TIMEOUT_MS) {
            throw new Error(`no reply within ${TIMEOUT_MS / 1000}s to [${w.n}]`);
          }
        }
        if (p.waiting) continue;
        const next = parseQuestions(p.qfile).find((x) => !asked(p, x));
        if (!next) continue;
        p.done.add(`${next.n}. ${next.q}`);
        if (next.q.length > 200) {
          console.error(`skip ${p.win} Q${next.n}: ${next.q.length}자 (200자 초과)`);
          continue;
        }
        const before = snapshot(p.win, 99999999).count;
        send(p.win, next.q);
        console.log(`나> ${p.win} [${next.n}] ${next.q}`);
        p.waiting = { ...next, before, started: Date.now(), lastSig: "", stableSince: Date.now() };
      } catch (e) {
        p.failed = true;
        console.error(`error ${p.win}: ${String(e.stderr || e.message).trim()} (이 창은 중단, 나머지는 계속)`);
      }
    }
    const busy = people.some((p) => !p.failed && p.waiting);
    if (busy) idleSince = null;
    else if ((idleSince ??= Date.now()) && Date.now() - idleSince >= lingerMs) break;
    await sleep(2000);
  }
  for (const p of people) if (p.failed) process.exitCode = 1;
}

if (process.argv[2] === "--multi") {
  const args = process.argv.slice(3);
  const li = args.indexOf("--linger");
  const lingerMs = li < 0 ? 120000 : Number(args.splice(li, 2)[1]) * 1000;
  if (!args.length) {
    console.error("usage: node scripts/kakao-ask.js --multi 창이름=questions.md[=log.md] ... [--linger 초]");
    process.exit(1);
  }
  const people = args.map(parseSpec);
  const clipBackup = execFileSync("pbpaste", { encoding: "utf8" });
  try {
    await withLock(() => multi(people, lingerMs));
  } finally {
    execFileSync("pbcopy", { input: clipBackup, env: UTF8_ENV });
  }
  process.exit();
}

const [win, mode, ...rest] = process.argv.slice(2);
if (!win || !mode) {
  console.error('usage: node scripts/kakao-ask.js <창이름> <questions.md | --q "질문" | --send-only "메시지" | --read [N]> [log.md]');
  process.exit(1);
}

const clipBackup = execFileSync("pbpaste", { encoding: "utf8" });
try {
  if (mode === "--read") {
    const n = Number(rest[0] || 5);
    await withLock(async () => {
      const { count } = snapshot(win, 99999999);
      for (const r of snapshot(win, count - n + 1).rows.filter((r) => r.body))
        console.log(`#${r.i} [${r.who || "나"}] ${await fullText(win, r)}\n`);
    });
  } else if (mode === "--send-only") {
    await withLock(async () => send(win, rest[0]));
    console.log(`sent to ${win}: ${rest[0]}`);
  } else {
    const questions = mode === "--q" ? [{ n: "-", q: rest[0] }] : parseQuestions(mode);
    const logFile = mode === "--q" ? rest[1] : rest[0];
    for (const { n, q } of questions) {
      if (q.length > 200) {
        console.error(`skip Q${n}: ${q.length}자 (200자 초과)`);
        continue;
      }
      console.log(`\n나> [${n}] ${q}`);
      const a = await withLock(() => ask(win, q));
      console.log(`${win}> ${a}\n`);
      if (logFile) fs.appendFileSync(logFile, logEntry(n, q, a));
    }
  }
} finally {
  execFileSync("pbcopy", { input: clipBackup, env: UTF8_ENV });
}

출처

  1. 카카오디벨로퍼스, 카카오톡 메시지: developers.kakao.com/docs/latest/ko/kakaotalk-message/common
  2. 카카오 비즈니스 가이드, 챗봇 관리자센터: kakaobusiness.gitbook.io/main/tool/chatbot/start/overview
  3. 카카오톡 Mac 3.1.6 업데이트 공지: pc.kakao.com/talk/notices/ko/2730?agent=mac
  4. 카카오 고객센터: cs.kakao.com/helps_html/1073209354?locale=ko
  5. Apple, Mac Automation Scripting Guide: developer.apple.com/library/archive/documentation/LanguagesUtilities/Co…
  6. Apple 지원, 손쉬운 사용 앱이 Mac에 접근하도록 허용하기: support.apple.com/ko-kr/guide/mac-help/mh43185/mac
  7. Apple, Accessibility Programming Guide for OS X: developer.apple.com/library/archive/documentation/Accessibility/Concept…
  8. Apple, Accessibility Inspector: developer.apple.com/documentation/accessibility/accessibility-inspector
  9. Inspecting the accessibility of the screens in your app: developer.apple.com/documentation/accessibility/inspecting-the-accessib…
  10. Node.js, child_process: nodejs.org/api/child_process.html
  11. Apple, AppleScript Error Codes: developer.apple.com/library/archive/documentation/AppleScript/Conceptua…
  12. 한 개발자의 네이버 블로그 글: blog.naver.com/ssamssae/224474141668
  13. 카카오톡 운영정책: talksafety.kakao.com/policy
  14. 허용하지 않는 방법에 의한 서비스 이용: talksafety.kakao.com/policy/stability/abnormalusage
  15. 카카오 데브톡: devtalk.kakao.com/t/topic/151892
  16. 카카오디벨로퍼스 운영정책: developers.kakao.com/terms/latest/ko/site-policies
  17. 자동감지에 의한 이용자보호조치: talksafety.kakao.com/measure/detection
  18. AI 챗봇 콜백: kakaobusiness.gitbook.io/main/tool/chatbot/skill_guide/ai_chatbot_callb…