가족용 복약(약 챙겨먹기) 알림 봇. "이게 뭐고 어떻게 쓰는지"를 빠르게 파악하기 위한 문서다. 설계 결정의 이유(일반화된 형태)는 DESIGN.md를 볼 것 — 이 문서는 "무엇을 할 수 있고 어떻게 동작하는가"에 집중하고, "왜 이렇게 짰는가"는 대부분 DESIGN.md로 넘긴다.
참고: 이 저장소는 실제 운영 중인 개인 프로젝트를 이름/개인정보/실제 URL·ID를 제거하고 공개용으로 정리한 사본이다. 자세한 배경은 DESIGN.md 맨 위 참고.
- 매일 정해진 시각에 약 먹을 시간을 알려주고, 안 먹으면 재알림을 보내고, 먹으면 버튼 한 번으로 기록되는 개인/가족용 봇.
- 주 채널은 텔레그램(버튼 UI). 텔레그램을 안 쓰는 가족을 위해 웹푸시 채널도 있음(가입부터 복약관리까지 텔레그램 없이 브라우저만으로 가능).
- 기록은 Google Sheets에 쌓이고, **Google Apps Script(GAS)**가 백엔드 로직 전부를 담당한다.
- 비용 0원을 목표로 설계됨 — 전부 무료 티어(GAS, Google Sheets, Telegram Bot API, Cloudflare Workers 무료 티어) 안에서 동작.
┌─────────────────────┐
SharpTools(물리 스위치) ──┼──────────────────────┼──▶ GAS doPost ──▶ Google Sheets(기록)
│ Cloudflare Worker │ │
텔레그램(메시지/버튼) ────┼──────────────────────┼─────────┤ ├──▶ Google Calendar(나만)
│ │ │
웹 브라우저(가입/개인관리) ┼──────────────────────┼─────────┘ └──▶ Telegram Bot API(알림 발송)
└──────────┬───────────┘
│ VAPID Web Push
▼
가족 브라우저(웹푸시 알림)
| 구성 요소 | 역할 | 왜 이걸 썼나 |
|---|---|---|
| Code.js (Google Apps Script) | 전체 비즈니스 로직 — 스케줄 판단, 명령어 처리, 기록/취소, 알림 발송 | 무료, Google Sheets/Calendar와 네이티브로 연동, 트리거(cron)를 코드 안에 그대로 짤 수 있음 |
| Google Sheets | 복용 기록(메인 시트) + 누락 기록 + 실행 진단 로그, 3개 탭 | 사람이 직접 열어서 눈으로 훑어볼 수 있는 DB — 별도 DB 서버가 필요 없음 |
| Script Properties | 사람별 설정(임시약, 시각 오버라이드, 가입 대기열 등) | GAS 자체 키-값 저장소, Sheets에 안 넣어도 되는 "설정성" 데이터용 |
| Telegram Bot | 주 채널 — 알림 수신, 버튼 탭, 명령어 입력 | 무료, 인라인 버튼 UI가 강력해서 타이핑 없이 대부분 조작 가능 |
| worker.js (Cloudflare Worker) | 텔레그램 웹훅 중계, 대시보드 인증, 웹푸시(VAPID/RFC8291 암호화) 발송 | GAS doPost가 못 하는 것들을 대신함(바로 아래 "왜 Cloudflare Worker가 따로 있나" 참고) — 이 저장소엔 참고용 사본만 있고 실제 배포는 사용자가 Cloudflare 대시보드에 직접 코드를 붙여넣는 수동 작업 |
| Web Push | 텔레그램 없는 가족을 위한 보조 채널 | 카카오톡/SMS는 비용이 들어서 배제, Web Push는 브라우저 표준이라 무료 |
| DESIGN.md | 설계 결정 요약 | 코드 주석만으론 안 남는 "왜"를 일반화해서 기록 |
| tests/harness*.js | Node.js vm 샌드박스로 GAS 전역(PropertiesService/CacheService/SpreadsheetApp 등)을 흉내내서 Code.js를 그대로 실행·검증 |
GAS엔 로컬 실행 환경이 없어서, 실제 배포 전에 로직을 검증할 유일한 방법 |
- 텔레그램 웹훅 중계: 텔레그램은 GAS 웹앱이 돌려주는 302 리다이렉트를 안 따라간다. Worker가 대신 따라가서 최종 응답을 텔레그램에 전달.
doPost가 HTTP 헤더를 못 읽음: 텔레그램의 웹훅 위조 방지용 시크릿 헤더 검증은 Worker에서만 가능 → Worker가 검증하고_workerAuth라는 내부 공유 시크릿을 body에 심어 GAS에 전달 (자세한 배경은 DESIGN.md의 "보안" 항목).- 대시보드 인증: 브라우저 표준 Basic Auth 팝업으로 로그인받은 뒤 GAS로 리다이렉트.
- 웹푸시 암호화(VAPID/RFC8291): GAS엔 타원곡선 서명 기능이 없어서, Worker의
crypto.subtle로 직접 구현(외부 라이브러리 없음). GAS는 "누구에게 뭘 보내라"만 Worker에 요청 — 이 방향(GAS→Worker)은 텔레그램 웹훅 방향(Worker→GAS)과 반대라 양방향 통신이 된다.
- 복용 완료 기록(버튼 1번 또는
/take), 90초 안에 취소(/undo) - 오늘 복약 현황 조회(
/status), 최근 30일 누락 조회(/miss) - 스누즈(10분/30분 후 다시 알림) — 리마인더 버튼에서 바로
- 임시약(감기약 등 단기 처방) 추가/시각수정/기간연장/중단 — 전부 버튼으로("🔧 복약 조정")
- 고정약(매일 복용) 추가/시각수정/제거 — 텔레그램으로 등록된 사람만(코드에 박힌 기본 두 사람은 코드를 고쳐야 해서 불가)
- 주기형 약(예: 특정 요일 패턴으로 복용/휴약이 반복되는 약) 일시중지/재개(
/pause_*,/resume_*), 리필 알림 - 처방 갱신(
/refill) - 텔레그램 없이: 웹 링크로 가입 신청(관리자 승인 필요) → 이후 개인 관리 페이지(
/push/me)에서 위 기능 대부분을 직접 수행 가능(단, 능동 알림 몇 종은 아직 웹푸시 미지원)
- 신규 가입 승인/거절 (텔레그램 버튼)
- 다른 사람 대신 관리 — 약 추가/수정/취소를
_for명령어로 대리 수행 - 사람 이름 변경/제거(
/rename_for,/removeperson,/people의 "⚙️ 관리" 버튼) - 전체 가족 현황을 한 번에 조회(
/status가 전원 표시) - 대시보드(웹, Basic Auth)로 가족 전체 현황을 브라우저에서 확인
- 매분: 모든 사람 × 모든 약의 체크/재알림 시각 폴링, 스누즈 큐 처리
- 매일: 처방 갱신 알림, 주기형 약 리필 알림, 트리거 상태 점검, 스누즈 트리거 정리, 공휴일 데이터 점검, 어제자 누락 기록 저장
- 매주/매월(선택): 주간 리포트, 월간 백업
- 상시: 실행이 느려지거나(🐢) 에러가 나면(🚨) 텔레그램 +
실행진단로그시트로 자동 보고 (자세한 배경은 DESIGN.md의 "실행 진단" 항목)
| 진입점 | 실행 방식 | 코드 반영 시점 |
|---|---|---|
checkAllTimedEvents |
1분 타이머 트리거 | GAS 편집기 저장 즉시(clasp push만 하면 됨, "Head" 실행) |
dailyChecks/weeklyReport/monthlyBackup |
시간 기반 트리거 | 위와 동일 |
doPost |
웹앱 POST — 텔레그램 웹훅, SharpTools, 웹푸시(가입/버튼/개인페이지) | 새 배포(버전)를 만들어야 반영됨 |
doGet |
웹앱 GET — 대시보드 | 위와 동일 |
doPost/doGet은 특정 배포 버전에 고정되기 때문에, 트리거 함수와 달리 코드를 고쳐도
웹앱을 재배포해야 실제로 반영된다.
message/callback_query(텔레그램) —_workerAuth필수(Worker를 거쳐야만 통과)_pushResolveLabel/_pushJoin/_pushAction/_pushMe(웹푸시, Worker가 전달) — 전부_workerAuth필수- 그 외(SharpTools) — 자체
SECRET_KEY로 인증
COMMAND_ROUTES(Code.js)라는 {접두사: 핸들러} 맵을 접두사 길이 내림차순으로 순회해서
매칭한다(/addtemp_gf가 /addtemp보다 항상 먼저 검사되도록). 새 명령어는 이 맵에 한 줄만
추가하면 됨.
버튼을 누르면 callback_data 문자열(예: log_me_morning, snooze_10_gf_pill)이 오고,
handleTelegramCallback이 첫 토큰(action)으로 분기한다. 대부분의 기능(약 추가/수정/제거,
사람 관리, 가입 승인 등)이 결국 타이핑 없이 이 버튼 흐름만으로 끝까지 가능하도록 되어 있다.
"며칠 드시나요?" 같은 여러 단계 질문-답변이 필요한 흐름은 wizard_<personKey> Script
Property에 {mode, step, target, ...그때까지 모은 값, startedAt} 형태로 진행 상태를
저장한다. 사용자가 텍스트를 보낼 때마다 handleTelegramMessage가 이 상태를 읽어서
(getWizardState) mode별 핸들러로 분기하고, 그 핸들러가 다음 질문을 보내며 step을
한 단계 전진시킨다(setWizardState). 아무 단계에서나 "취소"라고 보내면 즉시 종료된다.
TTL(슬라이딩 윈도우): 마지막 응답으로부터 1분(WIZARD_TTL_MS)이 지나면 자동 폐기된다.
매 단계 응답마다 startedAt이 갱신되므로 활발히 답하는 동안은 6단계짜리 주기약 등록도
안전하게 끝까지 진행되고, 반대로 답하다 방치하면 며칠 뒤 보낸 아무 텍스트가 옛 마법사의
답변으로 잘못 소비되는 사고를 막는다.
마법사 종류와 단계
mode |
진입 경로 | 단계 순서 |
|---|---|---|
add |
➕ 약 추가 → 대상 선택 → 🤒 단기 임시약 | 며칠 드시나요 → 하루 몇 번 → 약 이름 |
adddaily/addcycle |
➕ 약 추가 → 대상 선택 → ☀️ 매일 고정약/🔄 주기형 약 | 약 이름 → 체크시각 → (주기형만: 복용일수 → 휴약일수 → 시작일 → 재알림시각) |
edit |
🔧 복약 조정 → 시각 수정 → 대상 → 임시약 선택 | 새 시각(들, 콤마 구분) |
edittime |
🔧 복약 조정 → 시각 수정 → 대상 → 고정약 선택 | 새 시각 |
extendtemp |
🔧 복약 조정 → 기간 연장 → 대상 → 약 선택 | 연장 일수 |
renameperson |
/people → ⚙️ 관리 → 이름 변경 |
새 이름 |
질문 순서 원칙: "➕ 약 추가"와 "🔧 복약 조정" 둘 다 가장 많은 후속 선택지를 좌우하는 질문을 먼저 물어보도록 설계돼 있다(복약 조정: 누구 → 뭘 할지 → 어떤 약, 약 추가: 누구 → 어떤 종류). 다음 단계에서 실제로 가능한 선택지만 필터링해서 보여줄 수 있어야 "골랐는데 안 되는" 경험을 막을 수 있기 때문 — 두 메뉴 다 처음엔 반대 순서였다가 이 원칙에 맞춰 재정렬된 히스토리가 있다(자세한 배경은 DESIGN.md의 "대화형 상태 머신(마법사)과 질문 순서 설계" 항목).
| 경로 | 용도 | 인증 |
|---|---|---|
/ |
가족 전체 대시보드 | Basic Auth → GAS DASHBOARD_TOKEN |
/push/join |
텔레그램 없이 새로 가입 | 없음(관리자 텔레그램 승인이 실제 보안 경계) |
/push/subscribe |
기존 사람이 이 기기에서 처음 구독 | Basic Auth |
/push/me |
본인 개인 관리 페이지(취소/추가/수정) | 없음, 구독 endpoint 자체가 신원증명 |
/push/action, /push/send, /push/promote |
버튼 탭 처리, GAS↔Worker 내부 통신 | endpoint 일치 확인 / _workerAuth |
실제 최신 목록은 텔레그램에서 /help(코드의 handleHelpCommand())를 치면 본인 권한에 맞게
보여준다. 아래는 요약.
| 명령어 | 설명 |
|---|---|
/menu |
하단 버튼 메뉴 고정 |
/take |
알림 기다리지 않고 즉시 복용 완료 처리 |
/status |
오늘 복약 현황(완료 항목은 취소 버튼도 같이) |
/undo |
방금 기록 취소(90초 이내) |
/miss |
최근 30일 누락 조회 |
/edittime, /edittemp (인자 없이) |
"🔧 복약 조정" — 고정약+임시약 통합 목록에서 시각수정/연장/중단 |
/edittime <약키> <시각> |
고정약 체크 시각 직접 지정 |
/edittime <약키> retry <시각> |
재알림 시각 직접 지정 |
/addtemp <하루횟수> <일수> <이름> 또는 /addtemp <시각들> <일수> <이름> |
임시약 추가 |
/extendtemp, /removetemp |
임시약 연장/중단(타이핑 버전, "🔧 복약 조정"과 동일 기능) |
/refill[_YYYY-MM-DD] |
처방 갱신 (고정 매일약이 있는 사람만 노출) |
/pause_<약키>, /resume_<약키>[_YYYY-MM-DD] |
주기형 약 휴약/재개 |
| 명령어 | 설명 |
|---|---|
/people |
등록된 사람/약 목록 (사람마다 "⚙️ 관리"로 이름변경/추방) |
/addmed_for <대상> <이름> <시각> |
대신 고정약(매일) 추가 |
/addcyclemed_for <대상> <이름> <시각> <복용일수> <휴약일수> <시작일> |
대신 주기형 약 추가 |
/rename_for <대상> <새이름> |
등록된 사람 이름 변경 |
/removeperson <대상> |
등록된 사람 제거(과거 기록은 유지) |
/addtemp_for, /edittemp_for, /extendtemp_for, /removetemp_for <대상> ... |
임시약 대리 관리 |
/test_morning, /test_evening, /test_gfpill, /test_gfpillretry, /test_daily, /test_timed, /test_holiday |
트리거를 기다리지 않고 즉시 테스트 실행 |
- 텔레그램으로: 등록 안 된 사람이 봇에게 아무 메시지나 보내면 "이용하기" 버튼 → 이름 입력 → 관리자 승인.
- 텔레그램 없이: Worker의
/push/join링크로 이름 입력 → 관리자 승인(텔레그램 버튼은 동일).
- 왜 이렇게 짰는지(일반화된 설계 교훈): DESIGN.md.
- 실제 구현:
Code.js(GAS 백엔드),worker.js(Cloudflare Worker, 참고용 사본). - 동작이 실제로 맞는지 검증:
tests/harness*.js— Node.js로 바로 실행 가능 (node tests/harnessN.js), GAS 서비스(Properties/Cache/Spreadsheet 등)를 흉내낸 샌드박스 안에서Code.js를 그대로 돌려서 확인한다. - 직접 배포하는 데 필요한 설정값 목록: DESIGN.md 맨 아래 섹션.