Bell은 AUSG Slack 워크스페이스에서 무료 플랜의 User Group 멘션 제약을 보완하는 작은 그룹 멘션 봇입니다.
@Bell 10기 운영진
🔔 **10기 운영진** — @A @B @C @D
그룹과 Slack User ID는 Git 저장소가 아니라 Cloudflare D1 한 곳에 저장하며, /bell 모달에서 누구나 관리할 수 있습니다. 관리자·RBAC·별도 웹 UI는 두지 않습니다.
Slack
│
│ Events API / Slash Command / Global·Message Shortcut / Interactions
▼
Cloudflare Worker
├── Slack 서명 검증
├── 명령 파싱
├── Block Kit / Modal
└── Slack Web API ──────────────▶ 채널 / ephemeral 메시지
│
▼
Cloudflare D1
├── groups
├── group_members
└── processed_app_mentions
Worker는 다음 세 endpoint만 제공합니다.
| Endpoint | 역할 |
|---|---|
POST /slack/events |
url_verification, app_mention |
POST /slack/commands |
/bell |
POST /slack/interactions |
전역·메시지 바로가기, 모달 선택·저장·삭제·그룹 호출 |
Events API는 app_mention만 구독합니다. message.channels처럼 일반 채널 메시지를 모두 받는 이벤트는 사용하지 않습니다.
@Bell 10기 운영진
해당 그룹의 실제 Slack User ID를 <@U123ABC> 형식으로 멘션합니다.
실제 그룹 호출은 호출 메시지의 스레드에 한 줄로 표시되고 멤버에게 알림을 보냅니다. 채널의 새 글에서 Bell을 호출하면 그 글의 첫 댓글로 답하고, 기존 댓글에서 호출하면 같은 스레드에 이어서 답합니다. 별도의 채널 최상위 글은 만들지 않습니다.
Bell이 없던 메시지를 수정해 @Bell을 처음 추가한 경우에는 정상적으로 한 번 호출합니다. 이미 Bell이 처리한 같은 메시지를 다시 수정하더라도 그룹 멤버를 반복해서 호출하지 않습니다.
공지 본문을 같은 메시지에 함께 적을 수도 있습니다.
@Bell 행사팀 오늘 3시에 모여주세요
같은 줄에 본문이 이어지면 Bell은 메시지 앞부분과 일치하는 등록 그룹 중 가장 긴 이름을 선택합니다. 예를 들어 AUSG와 AUSG 운영진이 모두 등록되어 있으면 다음 메시지는 AUSG 운영진을 호출합니다.
@Bell AUSG 운영진 오늘 회의합니다
Bell은 @Bell이 있는 줄에서 멘션 뒤의 내용만 명령으로 해석합니다. 멘션 줄 앞뒤로 여러 줄의 본문을 작성해도 됩니다.
@Bell 행사팀 오늘 3시에 모여주세요
장소는 회의실입니다
늦지 않게 와주세요
멘션 줄 위와 아래는 그룹 조회에 포함되지 않습니다. 따라서 공지를 먼저 작성하고 마지막 줄에서 그룹을 호출하는 방식도 사용할 수 있습니다.
테스트
@Bell 행사팀
그룹명만 멘션 줄에 쓰거나, 같은 줄에 본문을 함께 쓸 수 있습니다.
@Bell 행사팀
오늘 3시에 모여주세요
@Bell 행사팀 | 오늘 3시에 모여주세요
멘션 줄에 본문이 있으면 그 앞부분과 일치하는 등록 그룹 중 가장 긴 이름을 선택합니다. |를 사용하면 구분자 앞부분을 정확한 그룹명으로 조회합니다. 본문은 사용자가 작성한 원래 Slack 메시지에 그대로 남고, Bell은 🔔 행사팀 — @멤버… 형식의 멘션 한 줄만 추가합니다. 그룹명이 다른 그룹명의 접두사이거나 본문 첫 단어와 헷갈릴 수 있다면 | 방식이 가장 명확합니다.
이미 작성한 공지에서는 메시지의 ⋯ 메뉴에서 앱에 연결 → Bell로 그룹 호출을 선택할 수도 있습니다.
공지 메시지의 ⋯
→ 앱에 연결
→ Bell로 그룹 호출
→ 그룹 선택
→ 호출
Bell은 선택한 메시지의 스레드에 같은 형식의 실제 멘션을 작성합니다. 그룹 선택 모달을 제출하기 전에는 메시지를 보내지 않습니다. Slack의 Message Shortcut은 스레드 댓글에서 시작하면 원래 스레드에 응답하는 기능이 제한될 수 있으므로, 채널의 최상위 공지 메시지에서 사용하는 것을 권장합니다.
@Bell 목록
@Bell list
전체 그룹과 인원 수를 호출자에게만 보여줍니다.
@Bell 10기 운영진 목록
@Bell 10기 운영진 list
특정 그룹의 구성원을 실제 멘션 형태로 호출자에게만 보여줍니다.
@Bell help
@Bell 도움말
사용법을 호출자에게만 보여줍니다.
/bell
Bell 그룹 관리 (전역 바로가기)
동일한 그룹 관리 모달을 엽니다. /bell은 Slack의 플랫폼 제한으로 스레드 입력창에서 실행되지 않지만, 전역 바로가기는 메시지 작성기의 바로가기 메뉴나 Slack 검색에서 실행할 수 있습니다. 기존 그룹 선택, 새 그룹 생성, 이름 변경, multi_users_select를 이용한 멤버 교체, 확인 후 삭제를 지원합니다. 빈 그룹도 저장할 수 있습니다.
목록·도움말·없는 그룹·빈 그룹 안내는 chat.postEphemeral로 보내므로 Bell의 응답은 호출자에게만 보입니다. 다만 @Bell 목록처럼 사용자가 채널에 작성한 호출 메시지 자체는 일반 Slack 메시지이므로 채널에 남습니다. 완전히 비공개로 관리하려면 /bell 모달을 사용합니다.
목록, list, 도움말, help와 … 목록 / … list처럼 Bell 명령으로 해석되는 이름은 저장할 수 없습니다. |는 그룹명과 본문의 명시적 구분자로 예약되어 있어 그룹 이름에 넣을 수 없습니다. 그룹 이름은 Unicode NFC와 단일 공백으로 정규화하므로 Modal에서 만든 이름과 Slack 메시지의 이름이 동일하게 조회됩니다.
Bot Token Scopes에 다음 세 개만 추가합니다.
app_mentions:read
chat:write
commands
multi_users_select의 사용자 목록은 Slack이 제공하므로 users:read는 필요하지 않습니다. chat:write.public도 사용하지 않으며, Bell을 사용할 채널에는 앱을 초대해야 합니다.
scope를 바꾼 뒤에는 워크스페이스에 앱을 다시 설치합니다.
Event Subscriptions를 켜고 Request URL을 지정합니다.
https://<WORKER_DOMAIN>/slack/events
Subscribe to bot events에는 다음 하나만 추가합니다.
app_mention
Slash Commands에서 /bell을 만들고 Request URL을 지정합니다.
https://<WORKER_DOMAIN>/slack/commands
Interactivity & Shortcuts를 켜고 Request URL을 지정합니다.
https://<WORKER_DOMAIN>/slack/interactions
같은 화면의 Shortcuts에서 Global Shortcut을 하나 추가합니다.
Name: Bell 그룹 관리
Short Description: Bell 그룹관리 모달을 엽니다
Callback ID: bell_manage_groups
Shortcut은 텍스트 명령이 아니므로 별도의 띄어쓰기 alias가 없습니다. 표시 이름에는 그룹 관리, 설명에는 그룹관리를 사용해 두 표기를 모두 노출합니다. 기존 commands scope와 Interactivity Request URL을 그대로 사용하므로 scope 추가나 앱 재설치는 필요하지 않습니다.
같은 화면에서 On messages 유형의 Message Shortcut도 추가합니다.
Name: Bell로 그룹 호출
Short Description: 선택한 메시지의 스레드에서 Bell 그룹을 호출합니다
Callback ID: bell_mention_group
Message Shortcut도 기존 commands scope와 같은 Interactivity Request URL을 사용하므로 scope 추가나 앱 재설치는 필요하지 않습니다. 설정을 저장하면 일반 메시지의 ⋯ 메뉴에서 Bell로 그룹 호출을 선택할 수 있습니다.
Bell용 데이터베이스는 하나만 만듭니다.
npx wrangler d1 create bell출력된 database_id로 wrangler.jsonc의 00000000-0000-0000-0000-000000000000 placeholder를 교체합니다.
실제 값은 소스나 wrangler.jsonc에 넣지 않습니다.
npx wrangler secret put SLACK_BOT_TOKEN
npx wrangler secret put SLACK_SIGNING_SECRETSlack App의 Bot User OAuth Token과 Basic Information의 Signing Secret을 각각 입력합니다.
schema는 migrations/ 아래의 순차 migration으로 관리합니다.
로컬 D1:
npx wrangler d1 migrations apply bell --local원격 D1:
npx wrangler d1 migrations apply bell --remotegroups.name의 UNIQUE index와 group_members(group_id, slack_user_id)의 복합 Primary Key가 현재 조회를 이미 커버하므로 중복 index는 만들지 않습니다. group_members는 복합 Primary Key 자체를 저장 구조로 사용하는 WITHOUT ROWID 테이블로 만들어 별도 rowid B-tree도 두지 않습니다.
npm install
cp .dev.vars.example .dev.vars
npx wrangler d1 migrations apply bell --local
npm run dev.dev.vars에 개발용 Slack App의 값을 넣습니다. 이 파일과 .env 계열은 Git에서 제외됩니다.
Slack에서 로컬 Worker를 직접 호출하려면 별도의 공개 HTTPS 터널이 필요합니다. 순수 로직과 D1 동작은 터널 없이 테스트할 수 있습니다.
npm run check다음을 한 번에 확인합니다.
- Wrangler 생성 타입이 최신인지 확인
- TypeScript typecheck
- ESLint와
no-floating-promises - 실제 Workers 런타임 기반 Vitest
- 로컬 D1 migration을 사용한 저장소·모달 테스트
개별 명령도 사용할 수 있습니다.
npm run cf-typegen
npm run typecheck
npm run lint
npm test
npm run buildnpm run build는 wrangler deploy --dry-run만 실행하며 원격에 배포하지 않습니다.
대상 Cloudflare 계정과 D1 ID를 확인한 뒤 다음 순서로 진행합니다.
npm run check
npx wrangler d1 migrations apply bell --remote
npm run deploy배포 후 생성된 Worker URL을 Slack App의 세 Request URL에 반영합니다.
모든 Slack endpoint는 raw request body, X-Slack-Signature, X-Slack-Request-Timestamp를 사용해 HMAC SHA-256 서명을 검증합니다. 현재 시각과 5분 넘게 차이 나는 요청은 replay 요청으로 간주해 401 Unauthorized로 거부합니다.
Events 요청은 검증 직후 200으로 ACK하고 D1 조회와 Slack Web API 전송을 waitUntil()에서 처리합니다. Slack이 X-Slack-Retry-Num과 함께 다시 보낸 요청은 중복 멘션을 줄이기 위해 처리하지 않고 ACK합니다. Queue·Durable Object·별도 dedup 저장소는 MVP에 추가하지 않았으므로 이 정책은 의도적으로 best-effort입니다.
Slack Web API 요청에는 명시적인 timeout을 둡니다. chat.postMessage와 views.update가 HTTP 429를 반환하면 Retry-After가 8초 이하일 때 한 번만 재시도합니다. 3초 안에 사용해야 하는 trigger_id가 있는 views.open은 기다렸다 재시도하지 않고 2초에 중단합니다. 네트워크 오류나 timeout은 중복 메시지를 만들 수 있으므로 자동 재시도하지 않습니다.
bell
├── groups
│ ├── id
│ ├── name
│ ├── created_at
│ └── updated_at
└── group_members
├── group_id
└── slack_user_id
그룹 삭제 시 ON DELETE CASCADE로 멤버 행도 함께 삭제됩니다. 생성과 수정은 D1 batch() 트랜잭션으로 처리합니다. 수정 시 기존 목록 전체를 다시 쓰지 않고, 빠진 멤버만 삭제하고 새 멤버만 추가합니다. 같은 이름 충돌과 이미 삭제된 그룹도 batch 결과와 DB 제약조건으로 판정하므로 사전 조회를 여러 번 하지 않습니다. /bell 첫 화면은 그룹 목록과 첫 그룹 상세를 하나의 batch 왕복으로 읽습니다.
- 그룹 호출은 그룹과 멤버를
LEFT JOIN한 SQL 한 번으로 읽습니다. 본문이 같은 줄에 있어도 모든 그룹을 읽지 않고, 공백 경계에서 만든 이름 후보만groups.name의UNIQUEindex로 조회한 뒤 가장 긴 등록 그룹 하나를 선택합니다. - 메시지 바로가기는 모달을 열 때 정적 선택 메뉴에 표시할 그룹 이름만 최대 99행 읽고, 제출할 때 선택한 그룹과 최신 멤버만 한 번 조회합니다. 멤버 목록을 모달 metadata에 복제하지 않아 오래 열린 모달에서도 최신 멤버를 호출합니다.
- 전체 목록은 그룹별 멤버 수를 Primary Key 범위로 세는 SQL 한 번을 사용합니다.
- 그룹 수정은
groups1행을 갱신하고, 멤버는 실제로 빠진 행만 삭제하며 새 행만 삽입합니다. 그대로인 멤버는 다시 쓰지 않습니다. 이름이 그대로면 UNIQUE index 대상인name도 다시 쓰지 않습니다. - 그룹 생성 시 99개 한도를 원자적으로 지키기 위한 그룹 수 조회가 있지만, 생성은 호출보다 훨씬 드문 관리 작업이고 최대 스캔도 99행입니다.
- 실제 그룹 멤버 호출이 결정된
app_mention만 원본 메시지의(channel_id, message_ts)를 Primary Key로 기록합니다. 같은 메시지의 이후 수정은 index 확인 후 즉시 종료하므로 그룹·멤버 조회와 Slack API 호출을 반복하지 않습니다. 목록·도움말·없는 그룹·빈 그룹은 기록하지 않아 메시지를 고쳐 다시 시도할 수 있고, Slack 전송이 실패한 경우에도 기록을 해제합니다. - 별도
member_count컬럼은 두지 않습니다. 그룹이 최대 99개인 현재 규모에서는 목록을 열 때COUNT(*)로 계산하는 편이 쓰기마다 카운터 정합성을 관리하는 것보다 단순하고 안전합니다. - 별도 중복 index, KV, Durable Object, Queue는 사용하지 않습니다.
실제 청구용 rows_read / rows_written은 D1의 실행 통계가 최종 기준이지만, 일반적인 @Bell 그룹명 호출은 그룹 1행과 해당 멤버 행만 읽는 경로입니다.
구현하지 않은 항목:
- 관리자 권한, RBAC, 그룹 소유권
- Durable Objects, KV, R2, Queues
- Socket Mode, 별도 서버, Express
- GitHub 파일 기반 그룹 데이터
- 웹 관리자 화면, 감사 로그, 통계
- fuzzy search, alias, nested group
Slack 모달의 정적 그룹 선택 메뉴 제한에 맞춰 그룹은 최대 99개, 그룹당 멤버는 최대 100명으로 제한합니다. 99개에 도달하면 새 그룹 선택 항목을 숨기고, 동시 생성도 D1 batch 안에서 거부합니다. AUSG 내부 도구의 실제 규모를 넘게 되면 그때 동적 선택 UI를 검토합니다.