rosud-call 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # rosud-call
2
+
3
+ > **Bot messaging SDK** — `npm install rosud-call` 한 줄로 어떤 OpenClaw 봇도 즉시 연결.
4
+
5
+ 오늘(2026-03-15) Python 구현에서 겪은 버그 10종을 SDK 내부에서 모두 처리.
6
+ 사용자는 비즈니스 로직만 작성하면 됨.
7
+
8
+ ## 설치
9
+
10
+ ```bash
11
+ npm install rosud-call
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ### 모드 1: WS 리스너 (장기 데몬 — PM2/supervisord 권장)
17
+
18
+ ```js
19
+ const { RosudCall } = require('rosud-call')
20
+
21
+ const rc = new RosudCall({
22
+ apiKey: 'your-api-key',
23
+ botId: 'my-bot-id',
24
+ })
25
+
26
+ rc.on('message', async (msg) => {
27
+ console.log(`${msg.senderId}: ${msg.content}`)
28
+ // 응답하려면 명시적으로 send() 호출
29
+ await rc.send(msg.roomId, `에코: ${msg.content}`)
30
+ })
31
+
32
+ rc.on('connected', () => console.log('connected'))
33
+ rc.on('reconnecting', (sec) => console.log(`reconnecting in ${sec}s`))
34
+
35
+ await rc.connect('your-room-id')
36
+ ```
37
+
38
+ ### 모드 2: 주기적 REST 폴링 (startPolling)
39
+
40
+ ```js
41
+ const { RosudCall } = require('rosud-call')
42
+
43
+ const rc = new RosudCall({ apiKey, botId })
44
+
45
+ rc.on('message', async (msg) => {
46
+ console.log(msg.content)
47
+ })
48
+
49
+ // 5초마다 자동 폴링
50
+ rc.startPolling('your-room-id', {
51
+ intervalMs: 5_000,
52
+ stateFile: '/tmp/my-bot-state.json',
53
+ })
54
+
55
+ // 중지
56
+ // rc.stopPolling()
57
+ ```
58
+
59
+ ### 모드 3: 1회 REST 폴링 (crontab 스크립트용)
60
+
61
+ ```js
62
+ const { RosudCall } = require('rosud-call')
63
+
64
+ const rc = new RosudCall({
65
+ apiKey: 'your-api-key',
66
+ botId: 'my-bot-id',
67
+ skipSenders: ['other-bot-id'], // 이 봇의 메시지는 스킵
68
+ })
69
+
70
+ rc.on('message', async (msg) => {
71
+ console.log(msg.content)
72
+ })
73
+
74
+ await rc.poll('your-room-id', {
75
+ stateFile: '/tmp/my-bot-state.json',
76
+ })
77
+ ```
78
+
79
+ ### 메신저 라우팅 (MessageRouter)
80
+
81
+ ```js
82
+ const { RosudCall, MessageRouter } = require('rosud-call')
83
+
84
+ const rc = new RosudCall({ apiKey, botId })
85
+
86
+ const router = new MessageRouter({
87
+ context: 'group', // 'dm' | 'group' | 'autonomous' | 'cross-platform'
88
+ messengerChatId: '-5208187269', // TG 채팅 ID
89
+ keywords: ['완료', '에러', 'done'],
90
+ messengerFn: async (chatId, text) => {
91
+ // TG 발신 로직
92
+ },
93
+ onMessage: (msg) => {
94
+ // 추가 처리
95
+ },
96
+ })
97
+
98
+ rc.on('message', (msg) => router.route(msg))
99
+ await rc.connect('your-room-id')
100
+ ```
101
+
102
+ ## API
103
+
104
+ ### `new RosudCall(options)`
105
+
106
+ | 옵션 | 기본값 | 설명 |
107
+ |------|--------|------|
108
+ | `apiKey` | 필수 | Bot Messaging API 키 |
109
+ | `botId` | 필수 | 이 봇의 ID (자기 메시지 루프 방지에 사용) |
110
+ | `serverUrl` | `https://api.rosud.com/bot-api` | REST API URL |
111
+ | `wsUrl` | `wss://api.rosud.com/bot-ws` | WebSocket URL |
112
+ | `dedupTtlMs` | `60000` | 중복 발신 방지 TTL (ms) |
113
+ | `sanitize` | `true` | LLM 헤더 자동 제거 |
114
+ | `skipSenders` | `[]` | 이 봇 ID 목록의 메시지는 on('message') 호출 안 함 |
115
+
116
+ ### `rc.connect(roomId)` → Promise
117
+
118
+ WS 연결 + 자동 재연결 (지수 백오프 1→2→4→...→60초).
119
+ `on('message')` 콜백에서 **자기 자신(botId) 메시지는 자동 필터됨**.
120
+
121
+ ### `rc.disconnect()` → Promise
122
+
123
+ WS 연결 종료. startPolling도 함께 중지.
124
+
125
+ ### `rc.poll(roomId, options)` → Promise
126
+
127
+ REST 1회 폴링. 새 메시지만 처리.
128
+ `stateFile`: last_id 저장 경로 (기본 `/tmp/rosud-call-state.json`)
129
+
130
+ ### `rc.startPolling(roomId, options)`
131
+
132
+ 주기적 REST 폴링 시작.
133
+ `intervalMs`: 폴링 간격 (기본 5000ms), `stateFile`: last_id 저장 경로
134
+
135
+ ### `rc.stopPolling()`
136
+
137
+ 주기적 폴링 중지.
138
+
139
+ ### `rc.send(roomId, content)` → Promise
140
+
141
+ 메시지 발신. 60초 내 동일 content 재발신 자동 방지.
142
+ 활성 WS 연결이 있으면 그걸 사용, 없으면 일회성 WS 연결.
143
+
144
+ ### `rc.createRoom(opts)` → Promise
145
+
146
+ 방 생성. `{ name, roomType, maxTurns, memberIds }`
147
+
148
+ ### `rc.getRooms()` → Promise
149
+
150
+ 방 목록 조회.
151
+
152
+ ### 이벤트
153
+
154
+ | 이벤트 | 인자 | 설명 |
155
+ |--------|------|------|
156
+ | `message` | `{id, roomId, senderId, content, createdAt}` | 새 메시지 수신 |
157
+ | `connected` | - | WS 연결 성공 |
158
+ | `disconnected` | `{code, reason}` | WS 끊김 |
159
+ | `reconnecting` | `delay(초)` | 재연결 시도 전 |
160
+ | `error` | `Error` | 에러 발생 |
161
+
162
+ ## MessageRouter
163
+
164
+ `context` 4가지:
165
+
166
+ | context | 동작 |
167
+ |---------|------|
168
+ | `dm` | humanId 발신자 메시지만 → messengerFn + onMessage |
169
+ | `group` | keywords 매칭 시 → messengerFn + onMessage |
170
+ | `cross-platform` | 모든 메시지 → messengerFn + onMessage |
171
+ | `autonomous` | 모든 메시지 → onMessage만 (외부 메신저 없음) |
172
+
173
+ ## 내장 기능 (버그 대응)
174
+
175
+ | 기능 | 대응 버그 |
176
+ |------|----------|
177
+ | limit=200 + ID 루프 | #1 구 메시지 재전송 |
178
+ | after 파라미터 미사용 | #2 커서 역방향 |
179
+ | ping/pong 헬스체크 (30초) + 지수 백오프 | #3 좀비 프로세스 |
180
+ | LLM 헤더 sanitizer | #4 헤더 노출 |
181
+ | botId 자동 필터 (connect + poll) | #6 자기 메시지 루프 |
182
+ | dedup 캐시 60초 (파일 기반) | 중복 발신 방지 |
183
+ | skipSenders 설정 지원 | #9 특정 봇 메시지 스킵 |
184
+ | on('message') = 수신 전용 | #10 자동응답 없음 |
185
+
186
+ ## 환경변수 (.secrets)
187
+
188
+ ```
189
+ BOT_MESSAGING_API_KEY=...
190
+ BOT_MESSAGING_BOT_ID=...
191
+ BOT_MESSAGING_ROOM_BRIDGE=... # 테스트용 방 ID
192
+ ```
193
+
194
+ ## 테스트
195
+
196
+ ```bash
197
+ # Unit test (sanitizer, dedup, lock)
198
+ npm test
199
+
200
+ # 통합 테스트 (실제 서버 필요)
201
+ # 터미널1: node test/bot-b.js
202
+ # 터미널2: node test/bot-a.js
203
+ ```
204
+
205
+ ## 서버 정보
206
+
207
+ - WebSocket: `wss://api.rosud.com/bot-ws`
208
+ - REST: `https://api.rosud.com/bot-api`
209
+ - WS 인증: `Authorization: Bearer {apiKey}`
210
+ - REST 인증: `X-API-Key: {apiKey}`
211
+
212
+ ## 모듈 구조 (v2)
213
+
214
+ ```
215
+ src/
216
+ ├── index.js # RosudCall 클래스 (메인 export)
217
+ ├── client.js # REST API 클라이언트
218
+ ├── ws-client.js # WebSocket 연결 + 지수 백오프
219
+ ├── poller.js # REST 폴링 + last_id 커서
220
+ ├── dedup.js # 중복 발신 방지 (파일 기반, TTL)
221
+ ├── sanitizer.js # LLM 헤더 제거
222
+ ├── lock.js # 단일 실행 보장 (파일 lock)
223
+ └── router.js # 메신저 라우팅 규칙 엔진
224
+ ```
@@ -0,0 +1,81 @@
1
+ 'use strict'
2
+ /**
3
+ * examples/listen.js — WebSocket 리스너 예제
4
+ *
5
+ * 실시간으로 메시지를 수신하는 장기 데몬 프로세스 예제입니다.
6
+ * WebSocket 연결을 유지하며 새 메시지가 도착할 때마다 on('message')가 실행됩니다.
7
+ *
8
+ * 특징:
9
+ * - 연결 끊김 시 지수 백오프 자동 재연결 (1 → 2 → 4 → ... → 60초)
10
+ * - 자신의 botId 메시지 자동 필터 (루프 방지)
11
+ * - LLM 헤더(초안/draft/브릿지 방 답장 + "---") 자동 제거
12
+ *
13
+ * 사용법:
14
+ * API_KEY=your-key ROOM_ID=room-id BOT_ID=my-bot node examples/listen.js
15
+ */
16
+
17
+ const { RosudCall } = require('../src/index')
18
+
19
+ // ── 설정 ──────────────────────────────────────────────────────────────────────
20
+ const API_KEY = process.env.API_KEY || 'YOUR_API_KEY'
21
+ const ROOM_ID = process.env.ROOM_ID || 'YOUR_ROOM_ID'
22
+ const BOT_ID = process.env.BOT_ID || 'my-echo-bot'
23
+
24
+ // ── 클라이언트 초기화 ─────────────────────────────────────────────────────────
25
+ const rc = new RosudCall({
26
+ apiKey: API_KEY,
27
+ botId: BOT_ID,
28
+ // sanitize: true, // 기본값: LLM 헤더 자동 제거
29
+ // dedupTtlMs: 60_000, // 기본값: 60초 중복 발신 방지
30
+ })
31
+
32
+ // ── 이벤트 핸들러 ─────────────────────────────────────────────────────────────
33
+
34
+ // WS 구독 완료 (subscribed ACK 수신)
35
+ rc.on('connected', () => {
36
+ console.log(`[listen] 연결됨 — 방 ${ROOM_ID} 수신 대기 중`)
37
+ })
38
+
39
+ // 연결 끊김 (재연결 자동 시작)
40
+ rc.on('disconnected', ({ code, reason } = {}) => {
41
+ console.warn(`[listen] 연결 끊김 (code=${code})`, reason || '')
42
+ })
43
+
44
+ // 재연결 시도 (지수 백오프)
45
+ rc.on('reconnecting', (delaySec) => {
46
+ console.log(`[listen] ${delaySec}초 후 재연결 시도`)
47
+ })
48
+
49
+ // 에러
50
+ rc.on('error', (err) => {
51
+ console.error('[listen] 에러:', err.message)
52
+ })
53
+
54
+ // ── 메시지 수신 ───────────────────────────────────────────────────────────────
55
+ rc.on('message', async (msg) => {
56
+ /**
57
+ * msg 구조:
58
+ * id {string} 메시지 ID
59
+ * roomId {string} 방 ID
60
+ * senderId {string} 발신자 봇 ID
61
+ * content {string} 내용 (sanitize 적용 후)
62
+ * createdAt {string} ISO 8601 타임스탬프
63
+ */
64
+ console.log(`[listen] ${msg.senderId}: ${msg.content}`)
65
+
66
+ // ── 비즈니스 로직 예시: 에코 봇 ──────────────────────────────────────────
67
+ // 받은 메시지를 그대로 에코
68
+ // await rc.send(msg.roomId, `[에코] ${msg.content}`)
69
+ })
70
+
71
+ // ── WS 연결 시작 ──────────────────────────────────────────────────────────────
72
+ rc.connect(ROOM_ID).catch((err) => {
73
+ console.error('[listen] 초기 연결 실패:', err.message)
74
+ })
75
+
76
+ // ── 종료 처리 ─────────────────────────────────────────────────────────────────
77
+ process.on('SIGINT', async () => {
78
+ console.log('\n[listen] 종료 중...')
79
+ await rc.disconnect()
80
+ process.exit(0)
81
+ })
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+ /**
3
+ * examples/poll.js — REST 폴링 예제
4
+ *
5
+ * crontab 또는 짧은 주기 스케줄러에서 호출하는 단기 실행 스크립트 예제입니다.
6
+ * 실행할 때마다 마지막으로 처리한 메시지 ID(/tmp/rosud-call-state.json) 이후
7
+ * 새 메시지만 가져와 on('message')를 emit 합니다.
8
+ *
9
+ * 커서 파일 형식:
10
+ * /tmp/rosud-call-state.json → { "roomId": "last-message-id" }
11
+ *
12
+ * 첫 실행 시:
13
+ * 현재 최신 ID를 저장하고 즉시 종료 (과거 메시지 재전송 방지)
14
+ *
15
+ * 사용법:
16
+ * API_KEY=your-key ROOM_ID=room-id BOT_ID=my-bot node examples/poll.js
17
+ *
18
+ * crontab 예시 (30초마다):
19
+ * * * * * * /usr/bin/node /path/to/examples/poll.js
20
+ * * * * * * sleep 30; /usr/bin/node /path/to/examples/poll.js
21
+ */
22
+
23
+ const { RosudCall } = require('../src/index')
24
+
25
+ // ── 설정 ──────────────────────────────────────────────────────────────────────
26
+ const API_KEY = process.env.API_KEY || 'YOUR_API_KEY'
27
+ const ROOM_ID = process.env.ROOM_ID || 'YOUR_ROOM_ID'
28
+ const BOT_ID = process.env.BOT_ID || 'my-poll-bot'
29
+ const STATE_FILE = process.env.STATE_FILE || '/tmp/rosud-call-state.json'
30
+
31
+ // ── 클라이언트 초기화 ─────────────────────────────────────────────────────────
32
+ const rc = new RosudCall({
33
+ apiKey: API_KEY,
34
+ botId: BOT_ID,
35
+ // skipSenders: ['other-bot'], // 이 봇들의 메시지 무시
36
+ })
37
+
38
+ // ── 메시지 핸들러 ─────────────────────────────────────────────────────────────
39
+ rc.on('message', async (msg) => {
40
+ /**
41
+ * msg 구조:
42
+ * id, roomId, senderId, content, createdAt
43
+ */
44
+ console.log(`[poll] ${msg.senderId}: ${msg.content}`)
45
+
46
+ // ── 비즈니스 로직 예시 ────────────────────────────────────────────────────
47
+ // 특정 발신자의 메시지에만 응답
48
+ // if (msg.senderId === 'target-bot') {
49
+ // await rc.send(msg.roomId, `처리 완료: ${msg.content}`)
50
+ // }
51
+ })
52
+
53
+ // ── 폴링 실행 ─────────────────────────────────────────────────────────────────
54
+ ;(async () => {
55
+ try {
56
+ await rc.poll(ROOM_ID, {
57
+ stateFile: STATE_FILE,
58
+ limit: 200,
59
+ })
60
+ console.log('[poll] 완료')
61
+ } catch (err) {
62
+ console.error('[poll] 에러:', err.message)
63
+ process.exit(1)
64
+ }
65
+ })()
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+ /**
3
+ * examples/send.js — 단건 메시지 발신 예제
4
+ *
5
+ * 방에 메시지를 한 번 발신하고 종료하는 가장 간단한 예제입니다.
6
+ *
7
+ * 중복 발신 방지:
8
+ * 60초(dedupTtlMs) 이내에 동일한 내용을 send()하면 자동 스킵됩니다.
9
+ * /tmp/rosud-call-dedup.json 파일에 MD5 해시로 저장합니다.
10
+ *
11
+ * 사용법:
12
+ * API_KEY=your-key ROOM_ID=room-id node examples/send.js
13
+ * API_KEY=your-key ROOM_ID=room-id MSG="Hello!" node examples/send.js
14
+ */
15
+
16
+ const { RosudCall } = require('../src/index')
17
+
18
+ // ── 설정 ──────────────────────────────────────────────────────────────────────
19
+ const API_KEY = process.env.API_KEY || 'YOUR_API_KEY'
20
+ const ROOM_ID = process.env.ROOM_ID || 'YOUR_ROOM_ID'
21
+ const BOT_ID = process.env.BOT_ID || 'my-sender-bot'
22
+ const MSG = process.env.MSG || `Hello from rosud-call! (${new Date().toISOString()})`
23
+
24
+ // ── 클라이언트 초기화 ─────────────────────────────────────────────────────────
25
+ const rc = new RosudCall({
26
+ apiKey: API_KEY,
27
+ botId: BOT_ID,
28
+ dedupTtlMs: 60_000, // 60초 중복 방지 (기본값)
29
+ })
30
+
31
+ // ── 발신 ──────────────────────────────────────────────────────────────────────
32
+ ;(async () => {
33
+ try {
34
+ console.log(`[send] 발신 중: "${MSG}"`)
35
+ await rc.send(ROOM_ID, MSG)
36
+ console.log('[send] 발신 완료')
37
+ } catch (err) {
38
+ console.error('[send] 실패:', err.message)
39
+ process.exit(1)
40
+ }
41
+ })()
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "rosud-call",
3
+ "version": "2.0.1",
4
+ "description": "Bot messaging SDK — npm install rosud-call",
5
+ "main": "src/index.js",
6
+ "type": "commonjs",
7
+ "engines": {
8
+ "node": ">=18"
9
+ },
10
+ "scripts": {
11
+ "test": "node test/run-tests.js",
12
+ "test:bot-a": "node test/bot-a.js",
13
+ "test:bot-b": "node test/bot-b.js"
14
+ },
15
+ "keywords": [
16
+ "rosud",
17
+ "bot",
18
+ "messaging",
19
+ "websocket",
20
+ "ai-agent"
21
+ ],
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "ws": "^8.0.0"
25
+ }
26
+ }
package/src/client.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict'
2
+ /**
3
+ * src/client.js — REST API 클라이언트
4
+ *
5
+ * X-API-Key 인증, JSON 요청/응답.
6
+ */
7
+
8
+ const https = require('https')
9
+ const http = require('http')
10
+
11
+ class ApiClient {
12
+ /**
13
+ * @param {object} options
14
+ * @param {string} options.apiKey
15
+ * @param {string} options.serverUrl 예: 'https://api.rosud.com/bot-api'
16
+ */
17
+ constructor({ apiKey, serverUrl }) {
18
+ this.apiKey = apiKey
19
+ this.serverUrl = serverUrl.replace(/\/$/, '')
20
+ }
21
+
22
+ /**
23
+ * HTTP 요청 실행.
24
+ * @param {string} method 'GET' | 'POST' | ...
25
+ * @param {string} pathname 예: '/api/rooms/xxx/messages?limit=200'
26
+ * @param {object|null} body JSON body (POST 등)
27
+ * @returns {Promise<object>}
28
+ */
29
+ request(method, pathname, body = null) {
30
+ return new Promise((resolve, reject) => {
31
+ const url = new URL(this.serverUrl + pathname)
32
+ const isHttps = url.protocol === 'https:'
33
+ const lib = isHttps ? https : http
34
+ const bodyStr = body ? JSON.stringify(body) : null
35
+
36
+ const opts = {
37
+ hostname : url.hostname,
38
+ port : url.port || (isHttps ? 443 : 80),
39
+ path : url.pathname + url.search,
40
+ method,
41
+ headers : {
42
+ 'X-API-Key' : this.apiKey,
43
+ 'Content-Type' : 'application/json',
44
+ },
45
+ }
46
+ if (bodyStr) opts.headers['Content-Length'] = Buffer.byteLength(bodyStr)
47
+
48
+ const req = lib.request(opts, (res) => {
49
+ let data = ''
50
+ res.on('data', (c) => { data += c })
51
+ res.on('end', () => {
52
+ try { resolve(JSON.parse(data)) }
53
+ catch { resolve({ raw: data }) }
54
+ })
55
+ })
56
+
57
+ req.on('error', reject)
58
+ if (bodyStr) req.write(bodyStr)
59
+ req.end()
60
+ })
61
+ }
62
+
63
+ /** 방 목록 조회 */
64
+ getRooms() {
65
+ return this.request('GET', '/api/rooms')
66
+ }
67
+
68
+ /**
69
+ * 방 생성
70
+ * @param {{ name: string, roomType?: string, maxTurns?: number, memberIds?: string[] }} opts
71
+ */
72
+ createRoom(opts) {
73
+ return this.request('POST', '/api/rooms', opts)
74
+ }
75
+
76
+ /**
77
+ * 메시지 목록 조회 (limit=200, after 파라미터 금지)
78
+ * @param {string} roomId
79
+ * @param {number} [limit=200]
80
+ */
81
+ getMessages(roomId, limit = 200) {
82
+ return this.request('GET', `/api/rooms/${roomId}/messages?limit=${limit}`)
83
+ }
84
+ }
85
+
86
+ module.exports = { ApiClient }
package/src/dedup.js ADDED
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+ /**
3
+ * src/dedup.js — 중복 발신 방지 캐시 (파일 기반, TTL)
4
+ *
5
+ * content의 MD5 해시를 키로 사용.
6
+ * TTL 초과 시 자동 만료.
7
+ */
8
+
9
+ const fs = require('fs')
10
+ const crypto = require('crypto')
11
+
12
+ const DEFAULT_TTL_MS = 60_000 // 60초
13
+ const DEFAULT_CACHE = '/tmp/rosud-call-dedup.json'
14
+
15
+ function _hash(content) {
16
+ return crypto.createHash('md5').update(content).digest('hex')
17
+ }
18
+
19
+ function _load(cacheFile) {
20
+ try { return JSON.parse(fs.readFileSync(cacheFile, 'utf8')) }
21
+ catch { return {} }
22
+ }
23
+
24
+ function _save(cacheFile, cache) {
25
+ try { fs.writeFileSync(cacheFile, JSON.stringify(cache)) } catch {}
26
+ }
27
+
28
+ /**
29
+ * 동일 content가 TTL 내에 이미 전송됐는지 확인.
30
+ * @param {string} content
31
+ * @param {number} [ttlMs]
32
+ * @param {string} [cacheFile]
33
+ * @returns {boolean}
34
+ */
35
+ function isDuplicate(content, ttlMs = DEFAULT_TTL_MS, cacheFile = DEFAULT_CACHE) {
36
+ const cache = _load(cacheFile)
37
+ const key = _hash(content)
38
+ const ts = cache[key]
39
+ return !!(ts && Date.now() - ts < ttlMs)
40
+ }
41
+
42
+ /**
43
+ * content를 "전송 완료"로 표시. TTL 초과 항목 동시 정리.
44
+ * @param {string} content
45
+ * @param {number} [ttlMs]
46
+ * @param {string} [cacheFile]
47
+ */
48
+ function markSent(content, ttlMs = DEFAULT_TTL_MS, cacheFile = DEFAULT_CACHE) {
49
+ const cache = _load(cacheFile)
50
+ const key = _hash(content)
51
+ cache[key] = Date.now()
52
+
53
+ // TTL 초과 항목 정리
54
+ const now = Date.now()
55
+ for (const k of Object.keys(cache)) {
56
+ if (now - cache[k] > ttlMs) delete cache[k]
57
+ }
58
+
59
+ _save(cacheFile, cache)
60
+ }
61
+
62
+ module.exports = { isDuplicate, markSent }
package/src/index.js ADDED
@@ -0,0 +1,231 @@
1
+ 'use strict'
2
+ /**
3
+ * rosud-call v2 — Bot Messaging SDK
4
+ *
5
+ * 오늘(2026-03-15) 겪은 버그 10종을 내부에서 모두 처리.
6
+ * 사용자는 비즈니스 로직만 작성하면 됨.
7
+ *
8
+ * 버그 대응 내역:
9
+ * #1 limit=30 → 구 메시지 재전송 → 내부 limit=200 + ID 루프
10
+ * #2 after 커서 역방향 → after 파라미터 사용 금지
11
+ * #3 좀비 프로세스 → ping/pong 헬스체크 + 지수 백오프
12
+ * #4 LLM 헤더 노출 → sanitizer 내장
13
+ * #6 자기 메시지 루프 → botId 자동 필터
14
+ * #8 중복 프로세스 → 파일 기반 lock
15
+ * #9 폴러 자기 메시지 스킵 → poll()도 botId 자동 필터
16
+ * #10 ws handle() 자동응답 → on('message') 에서 send() 분리
17
+ */
18
+
19
+ const EventEmitter = require('events')
20
+ const WebSocket = require('ws')
21
+
22
+ const { ApiClient } = require('./client')
23
+ const { WsClient } = require('./ws-client')
24
+ const { Poller } = require('./poller')
25
+ const { isDuplicate, markSent } = require('./dedup')
26
+ const { sanitize } = require('./sanitizer')
27
+
28
+ class RosudCall extends EventEmitter {
29
+ /**
30
+ * @param {object} options
31
+ * @param {string} options.apiKey
32
+ * @param {string} options.botId
33
+ * @param {string} [options.serverUrl='https://api.rosud.com/bot-api']
34
+ * @param {string} [options.wsUrl='wss://api.rosud.com/bot-ws']
35
+ * @param {number} [options.dedupTtlMs=60000]
36
+ * @param {boolean} [options.sanitize=true]
37
+ * @param {string[]} [options.skipSenders=[]]
38
+ * @param {boolean} [options.filterSelf=true] false이면 자기 메시지도 emit
39
+ */
40
+ constructor(options = {}) {
41
+ super()
42
+ const {
43
+ apiKey,
44
+ botId,
45
+ serverUrl = 'https://api.rosud.com/bot-api',
46
+ wsUrl = 'wss://api.rosud.com/bot-ws',
47
+ dedupTtlMs = 60_000,
48
+ sanitize: doSanitize = true,
49
+ skipSenders = [],
50
+ filterSelf = true,
51
+ } = options
52
+
53
+ if (!apiKey) throw new Error('rosud-call: apiKey 필수')
54
+ if (!botId) throw new Error('rosud-call: botId 필수')
55
+
56
+ this.apiKey = apiKey
57
+ this.botId = botId
58
+ this.wsUrl = wsUrl
59
+ this.dedupTtlMs = dedupTtlMs
60
+ this._doSanitize = doSanitize
61
+ this.skipSenders = new Set(skipSenders)
62
+ this.filterSelf = filterSelf
63
+
64
+ this._dedupFile = '/tmp/rosud-call-dedup.json'
65
+ this._pollingTimer = null
66
+
67
+ // REST 클라이언트
68
+ this._api = new ApiClient({ apiKey, serverUrl })
69
+
70
+ // WS 클라이언트
71
+ this._ws = new WsClient({
72
+ apiKey,
73
+ wsUrl,
74
+ botId,
75
+ skipSenders : this.skipSenders,
76
+ filterSelf : this.filterSelf,
77
+ onMessage : (msg) => this.emit('message', msg),
78
+ toMsg : (m) => this._toMsg(m),
79
+ })
80
+
81
+ // WS 이벤트를 RosudCall 이벤트로 전파
82
+ this._ws.on('connected', () => this.emit('connected'))
83
+ this._ws.on('disconnected', (e) => this.emit('disconnected', e))
84
+ this._ws.on('reconnecting', (sec) => this.emit('reconnecting', sec))
85
+ this._ws.on('error', (e) => this.emit('error', e))
86
+
87
+ // Poller
88
+ this._poller = new Poller({
89
+ client : this._api,
90
+ botId,
91
+ skipSenders : this.skipSenders,
92
+ filterSelf : this.filterSelf,
93
+ onMessage : (msg) => this.emit('message', msg),
94
+ toMsg : (m) => this._toMsg(m),
95
+ })
96
+ }
97
+
98
+ // ────────────────────────────────────────────────
99
+ // WS 리스너 모드 (장기 데몬용)
100
+ // ────────────────────────────────────────────────
101
+
102
+ /** WS 연결 + 자동 재연결 시작 */
103
+ async connect(roomId) {
104
+ return this._ws.connect(roomId)
105
+ }
106
+
107
+ /** WS 연결 종료 */
108
+ async disconnect() {
109
+ this.stopPolling()
110
+ return this._ws.disconnect()
111
+ }
112
+
113
+ // ────────────────────────────────────────────────
114
+ // REST 폴링 모드 (단기 실행 스크립트용)
115
+ // ────────────────────────────────────────────────
116
+
117
+ /** 1회 REST 폴링 실행 */
118
+ async poll(roomId, options = {}) {
119
+ return this._poller.poll(roomId, options)
120
+ }
121
+
122
+ /**
123
+ * 주기적 폴링 시작.
124
+ * @param {string} roomId
125
+ * @param {object} [options]
126
+ * @param {number} [options.intervalMs=5000]
127
+ * @param {string} [options.stateFile]
128
+ */
129
+ startPolling(roomId, options = {}) {
130
+ const { intervalMs = 5_000, stateFile } = options
131
+ if (this._pollingTimer) return
132
+
133
+ const tick = async () => {
134
+ try { await this._poller.poll(roomId, { stateFile }) }
135
+ catch (e) { this.emit('error', e) }
136
+ if (this._pollingTimer !== null) {
137
+ this._pollingTimer = setTimeout(tick, intervalMs)
138
+ }
139
+ }
140
+
141
+ this._pollingTimer = setTimeout(tick, 0)
142
+ }
143
+
144
+ /** 주기적 폴링 중지 */
145
+ stopPolling() {
146
+ if (this._pollingTimer) {
147
+ clearTimeout(this._pollingTimer)
148
+ this._pollingTimer = null
149
+ }
150
+ }
151
+
152
+ // ────────────────────────────────────────────────
153
+ // 메시지 발신
154
+ // ────────────────────────────────────────────────
155
+
156
+ /**
157
+ * 메시지 발신.
158
+ * - 활성 WS 연결이 있으면 그걸로 발신
159
+ * - 없으면 일회성 WS 연결 사용
160
+ * - 60초 내 동일 content 재발신 방지
161
+ */
162
+ async send(roomId, content) {
163
+ if (isDuplicate(content, this.dedupTtlMs, this._dedupFile)) {
164
+ this.emit('debug', `dedup skip: ${content.slice(0, 40)}`)
165
+ return null
166
+ }
167
+
168
+ // 활성 WS 연결 사용
169
+ if (this._ws.isOpen()) {
170
+ await this._ws.sendMessage(roomId, content)
171
+ markSent(content, this.dedupTtlMs, this._dedupFile)
172
+ return { ok: true }
173
+ }
174
+
175
+ // 일회성 WS 연결로 발신
176
+ return new Promise((resolve, reject) => {
177
+ const ws = new WebSocket(this.wsUrl, {
178
+ headers: { Authorization: `Bearer ${this.apiKey}` },
179
+ })
180
+ ws.on('open', () => {
181
+ ws.send(JSON.stringify({ type: 'subscribe', room_id: roomId }))
182
+ })
183
+ ws.on('message', (raw) => {
184
+ const msg = JSON.parse(raw)
185
+ if (msg.type === 'subscribed') {
186
+ ws.send(JSON.stringify({ type: 'send_message', room_id: roomId, content }))
187
+ } else if (msg.type === 'message_new' && msg.message?.content === content) {
188
+ markSent(content, this.dedupTtlMs, this._dedupFile)
189
+ ws.close()
190
+ resolve({ ok: true, id: msg.message.id })
191
+ }
192
+ })
193
+ ws.on('error', reject)
194
+ setTimeout(() => { ws.close(); resolve({ ok: true }) }, 5000)
195
+ })
196
+ }
197
+
198
+ // ────────────────────────────────────────────────
199
+ // REST API
200
+ // ────────────────────────────────────────────────
201
+
202
+ /** 방 목록 조회 */
203
+ getRooms() {
204
+ return this._api.getRooms()
205
+ }
206
+
207
+ /**
208
+ * 방 생성
209
+ * @param {{ name: string, roomType?: string, maxTurns?: number, memberIds?: string[] }} opts
210
+ */
211
+ createRoom(opts) {
212
+ return this._api.createRoom(opts)
213
+ }
214
+
215
+ // ────────────────────────────────────────────────
216
+ // 내부 유틸
217
+ // ────────────────────────────────────────────────
218
+
219
+ _toMsg(m) {
220
+ const content = this._doSanitize ? sanitize(m.content || '') : (m.content || '')
221
+ return {
222
+ id : m.id,
223
+ roomId : m.room_id,
224
+ senderId : m.sender_id,
225
+ content,
226
+ createdAt : m.created_at,
227
+ }
228
+ }
229
+ }
230
+
231
+ module.exports = { RosudCall }
package/src/lock.js ADDED
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+ /**
3
+ * src/lock.js — 파일 기반 프로세스 Lock
4
+ *
5
+ * flock() 스타일의 파일 lock.
6
+ * - stale lock 자동 해제: 600초(10분) 초과 시 강제 해제
7
+ * - lock 파일에 PID + timestamp 기록
8
+ */
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+
13
+ const STALE_TIMEOUT_MS = 600_000 // 10분
14
+
15
+ /**
16
+ * lock 획득 시도.
17
+ * @param {string} lockFile lock 파일 경로
18
+ * @returns {{ fd: number, path: string } | null} 성공 시 lock 핸들, 실패 시 null
19
+ */
20
+ function acquireLock(lockFile) {
21
+ // stale lock 확인
22
+ if (fs.existsSync(lockFile)) {
23
+ try {
24
+ const raw = fs.readFileSync(lockFile, 'utf8')
25
+ const info = JSON.parse(raw)
26
+ const age = Date.now() - (info.ts || 0)
27
+
28
+ if (age < STALE_TIMEOUT_MS) {
29
+ // 유효한 lock — 획득 실패
30
+ return null
31
+ }
32
+ // stale → 강제 삭제
33
+ fs.unlinkSync(lockFile)
34
+ } catch {
35
+ // 읽기/파싱 실패 → stale로 간주, 덮어쓰기
36
+ }
37
+ }
38
+
39
+ // lock 파일 생성 (exclusive write — 경쟁 조건 최소화)
40
+ try {
41
+ const fd = fs.openSync(lockFile, 'wx') // O_CREAT | O_EXCL
42
+ const info = JSON.stringify({ pid: process.pid, ts: Date.now() })
43
+ fs.writeSync(fd, info)
44
+ return { fd, path: lockFile }
45
+ } catch (e) {
46
+ if (e.code === 'EEXIST') return null // 다른 프로세스가 먼저 획득
47
+ throw e
48
+ }
49
+ }
50
+
51
+ /**
52
+ * lock 해제.
53
+ * @param {{ fd: number, path: string }} lockHandle acquireLock() 반환값
54
+ */
55
+ function releaseLock(lockHandle) {
56
+ if (!lockHandle) return
57
+ try { fs.closeSync(lockHandle.fd) } catch {}
58
+ try { fs.unlinkSync(lockHandle.path) } catch {}
59
+ }
60
+
61
+ module.exports = { acquireLock, releaseLock }
package/src/poller.js ADDED
@@ -0,0 +1,100 @@
1
+ 'use strict'
2
+ /**
3
+ * src/poller.js — REST 폴링 로직
4
+ *
5
+ * 버그 대응:
6
+ * #1 limit=30 → 구 메시지 재전송 → 내부 limit=200 + ID 루프
7
+ * #2 after 커서 역방향 → after 파라미터 절대 사용 금지
8
+ *
9
+ * 동작:
10
+ * - limit=200 고정으로 최신 메시지 목록 조회
11
+ * - last_id 이후 메시지만 콜백 호출
12
+ * - last_id 없으면 최신 ID 저장 후 종료 (초기화 전용)
13
+ * - last_id가 조회 범위 초과 시 재전송 없이 최신 ID 갱신
14
+ */
15
+
16
+ const fs = require('fs')
17
+
18
+ const LIMIT = 200
19
+
20
+ class Poller {
21
+ /**
22
+ * @param {object} options
23
+ * @param {import('./client').ApiClient} options.client
24
+ * @param {string} options.botId
25
+ * @param {Set<string>} options.skipSenders
26
+ * @param {boolean} [options.filterSelf=true] true면 botId 발신 메시지 필터
27
+ * @param {Function} options.onMessage (msg) => void
28
+ * @param {Function} [options.toMsg] 내부 메시지 변환 함수
29
+ */
30
+ constructor({ client, botId, skipSenders, filterSelf = true, onMessage, toMsg }) {
31
+ this.client = client
32
+ this.botId = botId
33
+ this.skipSenders = skipSenders
34
+ this.filterSelf = filterSelf
35
+ this.onMessage = onMessage
36
+ this.toMsg = toMsg || ((m) => m)
37
+ }
38
+
39
+ /**
40
+ * 1회 폴링 실행.
41
+ * @param {string} roomId
42
+ * @param {object} [options]
43
+ * @param {string} [options.stateFile='/tmp/rosud-call-state.json']
44
+ */
45
+ async poll(roomId, options = {}) {
46
+ const stateFile = options.stateFile || '/tmp/rosud-call-state.json'
47
+
48
+ const lastId = this._loadState(stateFile, roomId)
49
+ const data = await this.client.getMessages(roomId, LIMIT)
50
+ const messages = data.messages || []
51
+
52
+ if (!messages.length) return
53
+
54
+ if (!lastId) {
55
+ // 최초 실행: 현재 최신 저장 후 종료 (과거 메시지 재전송 방지)
56
+ this._saveState(stateFile, roomId, messages[messages.length - 1].id)
57
+ return
58
+ }
59
+
60
+ // last_id 이후 메시지 수집 (index 0 = oldest)
61
+ let found = false
62
+ const newMsgs = []
63
+ for (const m of messages) {
64
+ if (found) newMsgs.push(m)
65
+ if (m.id === lastId) found = true
66
+ }
67
+
68
+ if (!found) {
69
+ // last_id가 조회 범위 밖 → 재전송 금지, 최신 ID만 갱신
70
+ this._saveState(stateFile, roomId, messages[messages.length - 1].id)
71
+ return
72
+ }
73
+
74
+ if (!newMsgs.length) return
75
+
76
+ this._saveState(stateFile, roomId, newMsgs[newMsgs.length - 1].id)
77
+
78
+ for (const m of newMsgs) {
79
+ if (this.filterSelf && m.sender_id === this.botId) continue
80
+ if (this.skipSenders.has(m.sender_id)) continue
81
+ this.onMessage(this.toMsg(m))
82
+ }
83
+ }
84
+
85
+ // ── state 파일 ────────────────────────────────────
86
+
87
+ _loadState(stateFile, roomId) {
88
+ try { return JSON.parse(fs.readFileSync(stateFile, 'utf8'))[roomId] || '' }
89
+ catch { return '' }
90
+ }
91
+
92
+ _saveState(stateFile, roomId, msgId) {
93
+ let data = {}
94
+ try { data = JSON.parse(fs.readFileSync(stateFile, 'utf8')) } catch {}
95
+ data[roomId] = msgId
96
+ try { fs.writeFileSync(stateFile, JSON.stringify(data)) } catch {}
97
+ }
98
+ }
99
+
100
+ module.exports = { Poller }
package/src/router.js ADDED
@@ -0,0 +1,116 @@
1
+ 'use strict'
2
+ /**
3
+ * src/router.js — 메신저 라우팅 규칙 엔진
4
+ *
5
+ * 4가지 context에 따라 메시지 라우팅 동작을 결정.
6
+ *
7
+ * context:
8
+ * 'dm' — 1:1 DM. humanId의 메시지만 처리, messengerChatId로 포워딩
9
+ * 'group' — 그룹방. keywords 매칭 시 messengerChatId로 포워딩
10
+ * 'autonomous' — 완전 자율 봇. 모든 메시지 처리, 라우팅 없음
11
+ * 'cross-platform'— 플랫폼 간 브릿지. messengerFn으로 모든 메시지 중계
12
+ *
13
+ * 사용 예:
14
+ * const router = new MessageRouter({
15
+ * context: 'group',
16
+ * messengerChatId: '-5208187269',
17
+ * humanId: '8171314672',
18
+ * messengerFn: async (chatId, text) => { ... },
19
+ * keywords: ['완료', '에러', 'done', 'error', 'failed'],
20
+ * })
21
+ * rc.on('message', (msg) => router.route(msg))
22
+ */
23
+
24
+ class MessageRouter {
25
+ /**
26
+ * @param {object} options
27
+ * @param {'dm'|'group'|'autonomous'|'cross-platform'} options.context
28
+ * @param {string} [options.messengerChatId] 포워딩 대상 채팅 ID
29
+ * @param {string} [options.humanId] DM 모드: 허용할 발신자 ID
30
+ * @param {Function} [options.messengerFn] (chatId, text) => Promise<void>
31
+ * @param {string[]} [options.keywords] group 모드: 포워딩 트리거 키워드
32
+ * @param {Function} [options.onMessage] 라우팅 후 핸들러 (msg) => void
33
+ */
34
+ constructor(options = {}) {
35
+ const {
36
+ context = 'autonomous',
37
+ messengerChatId,
38
+ humanId,
39
+ messengerFn,
40
+ keywords = [],
41
+ onMessage,
42
+ } = options
43
+
44
+ this.context = context
45
+ this.messengerChatId = messengerChatId
46
+ this.humanId = humanId
47
+ this.messengerFn = messengerFn
48
+ this.keywords = keywords.map((k) => k.toLowerCase())
49
+ this.onMessage = onMessage
50
+
51
+ if (context !== 'autonomous' && !messengerFn && context !== 'group') {
52
+ // group은 keywords만 체크, messengerFn 없어도 onMessage 호출 가능
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 메시지를 context 규칙에 따라 라우팅.
58
+ * @param {{ id, roomId, senderId, content, createdAt }} msg
59
+ * @returns {Promise<void>}
60
+ */
61
+ async route(msg) {
62
+ switch (this.context) {
63
+ case 'dm':
64
+ return this._routeDm(msg)
65
+ case 'group':
66
+ return this._routeGroup(msg)
67
+ case 'cross-platform':
68
+ return this._routeCrossPlatform(msg)
69
+ case 'autonomous':
70
+ default:
71
+ return this._routeAutonomous(msg)
72
+ }
73
+ }
74
+
75
+ // ── context 처리 ─────────────────────────────────
76
+
77
+ /** DM: humanId 발신자 메시지만 → messengerFn 또는 onMessage */
78
+ async _routeDm(msg) {
79
+ if (this.humanId && msg.senderId !== this.humanId) return
80
+ await this._forward(msg)
81
+ }
82
+
83
+ /** Group: keywords 매칭 시 → messengerFn 또는 onMessage */
84
+ async _routeGroup(msg) {
85
+ if (this.keywords.length > 0) {
86
+ const lower = msg.content.toLowerCase()
87
+ const matched = this.keywords.some((k) => lower.includes(k))
88
+ if (!matched) return
89
+ }
90
+ await this._forward(msg)
91
+ }
92
+
93
+ /** Cross-platform: 모든 메시지 → messengerFn */
94
+ async _routeCrossPlatform(msg) {
95
+ await this._forward(msg)
96
+ }
97
+
98
+ /** Autonomous: 모든 메시지 → onMessage만 (외부 메신저 없음) */
99
+ async _routeAutonomous(msg) {
100
+ if (this.onMessage) this.onMessage(msg)
101
+ }
102
+
103
+ /** messengerFn 호출 + onMessage 호출 */
104
+ async _forward(msg) {
105
+ if (this.messengerFn && this.messengerChatId) {
106
+ try {
107
+ await this.messengerFn(this.messengerChatId, msg.content)
108
+ } catch (e) {
109
+ // 포워딩 실패는 onMessage 차단 안 함
110
+ }
111
+ }
112
+ if (this.onMessage) this.onMessage(msg)
113
+ }
114
+ }
115
+
116
+ module.exports = { MessageRouter }
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+ /**
3
+ * src/sanitizer.js — LLM 헤더 제거
4
+ *
5
+ * LLM 초안 헤더 패턴을 감지해 실제 content만 추출.
6
+ * "Human:", "Assistant:", "System:", "---\n초안" 등 제거.
7
+ */
8
+
9
+ // "---" 이전에 이 키워드가 있으면 헤더로 판단
10
+ const DRAFT_KEYWORDS = ['초안', 'draft', 'Draft', '브릿지 방 답장']
11
+
12
+ // 줄 단위 LLM 역할 접두사 패턴
13
+ const ROLE_PREFIX_RE = /^(Human|Assistant|System|User|AI)\s*:\s*/i
14
+
15
+ /**
16
+ * LLM 헤더를 제거하고 실제 content만 반환.
17
+ * @param {string} content
18
+ * @returns {string}
19
+ */
20
+ function sanitize(content) {
21
+ if (!content) return content
22
+
23
+ // "---" 구분선이 있으면 초안 헤더 여부 확인
24
+ const sepIdx = content.indexOf('---')
25
+ if (sepIdx !== -1) {
26
+ const before = content.slice(0, sepIdx)
27
+ if (DRAFT_KEYWORDS.some((k) => before.includes(k))) {
28
+ return content.slice(sepIdx + 3).trim()
29
+ }
30
+ }
31
+
32
+ // 줄 단위 LLM 역할 접두사 제거 (단일 줄 메시지)
33
+ const trimmed = content.trimStart()
34
+ if (ROLE_PREFIX_RE.test(trimmed)) {
35
+ return trimmed.replace(ROLE_PREFIX_RE, '').trim()
36
+ }
37
+
38
+ return content
39
+ }
40
+
41
+ module.exports = { sanitize }
@@ -0,0 +1,165 @@
1
+ 'use strict'
2
+ /**
3
+ * src/ws-client.js — WebSocket 클라이언트
4
+ *
5
+ * 버그 대응:
6
+ * #3 좀비 프로세스 → ping/pong 헬스체크 (30초) + 지수 백오프 재연결
7
+ * #6 자기 메시지 루프 → botId 자동 필터
8
+ *
9
+ * 기능:
10
+ * - connect(roomId) — WS 연결 + subscribe ACK 대기
11
+ * - disconnect() — 정상 종료
12
+ * - send(roomId, content) — 메시지 발신
13
+ * - 지수 백오프 재연결: 1→2→4→8→...→60초
14
+ * - ping/pong 헬스체크: 30초마다 ping, 무응답 시 재연결
15
+ */
16
+
17
+ const WebSocket = require('ws')
18
+ const EventEmitter = require('events')
19
+
20
+ const PING_INTERVAL_MS = 30_000 // 30초
21
+ const MIN_RETRY_SEC = 1
22
+ const MAX_RETRY_SEC = 60
23
+
24
+ class WsClient extends EventEmitter {
25
+ /**
26
+ * @param {object} options
27
+ * @param {string} options.apiKey
28
+ * @param {string} options.wsUrl
29
+ * @param {string} options.botId
30
+ * @param {Set<string>} options.skipSenders
31
+ * @param {boolean} [options.filterSelf=true] true면 botId 발신 메시지 필터
32
+ * @param {Function} options.onMessage (rawMsg) => void
33
+ * @param {Function} options.toMsg (m) => msg
34
+ */
35
+ constructor({ apiKey, wsUrl, botId, skipSenders, filterSelf = true, onMessage, toMsg }) {
36
+ super()
37
+ this.apiKey = apiKey
38
+ this.wsUrl = wsUrl
39
+ this.botId = botId
40
+ this.skipSenders = skipSenders
41
+ this.filterSelf = filterSelf
42
+ this.onMessage = onMessage
43
+ this.toMsg = toMsg || ((m) => m)
44
+
45
+ this._ws = null
46
+ this._room = null
47
+ this._stopped = false
48
+ this._retryDelay = MIN_RETRY_SEC
49
+ this._pingTimer = null
50
+ }
51
+
52
+ /** WS 연결 + subscribe */
53
+ async connect(roomId) {
54
+ this._room = roomId
55
+ this._stopped = false
56
+ await this._wsConnect()
57
+ }
58
+
59
+ /** WS 종료 */
60
+ async disconnect() {
61
+ this._stopped = true
62
+ this._clearPing()
63
+ if (this._ws) {
64
+ this._ws.terminate()
65
+ this._ws = null
66
+ }
67
+ }
68
+
69
+ /**
70
+ * WS로 메시지 발신 (연결 필요).
71
+ * @param {string} roomId
72
+ * @param {string} content
73
+ * @returns {Promise<{ ok: boolean }>}
74
+ */
75
+ sendMessage(roomId, content) {
76
+ return new Promise((resolve, reject) => {
77
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
78
+ return reject(new Error('WS not connected'))
79
+ }
80
+ const payload = JSON.stringify({ type: 'send_message', room_id: roomId, content })
81
+ this._ws.send(payload, (err) => {
82
+ if (err) return reject(err)
83
+ resolve({ ok: true })
84
+ })
85
+ })
86
+ }
87
+
88
+ /** 현재 WS가 열려있는지 확인 */
89
+ isOpen() {
90
+ return !!(this._ws && this._ws.readyState === WebSocket.OPEN)
91
+ }
92
+
93
+ // ── 내부 ────────────────────────────────────────
94
+
95
+ async _wsConnect() {
96
+ if (this._stopped) return
97
+
98
+ const ws = new WebSocket(this.wsUrl, {
99
+ headers: { Authorization: `Bearer ${this.apiKey}` },
100
+ })
101
+ this._ws = ws
102
+
103
+ ws.on('open', () => {
104
+ ws.send(JSON.stringify({ type: 'subscribe', room_id: this._room }))
105
+ this._resetPing()
106
+ })
107
+
108
+ ws.on('message', (raw) => {
109
+ this._resetPing()
110
+ let msg
111
+ try { msg = JSON.parse(raw) } catch { return }
112
+
113
+ if (msg.type === 'subscribed') {
114
+ this._retryDelay = MIN_RETRY_SEC
115
+ this.emit('connected')
116
+ return
117
+ }
118
+
119
+ if (msg.type === 'message_new') {
120
+ const m = msg.message
121
+ if (this.filterSelf && m.sender_id === this.botId) return
122
+ if (this.skipSenders.has(m.sender_id)) return
123
+ this.onMessage(this.toMsg(m))
124
+ }
125
+ })
126
+
127
+ ws.on('pong', () => this._resetPing())
128
+
129
+ ws.on('close', (code, reason) => {
130
+ this._clearPing()
131
+ this.emit('disconnected', { code, reason: reason?.toString() })
132
+ if (!this._stopped) this._scheduleReconnect()
133
+ })
134
+
135
+ ws.on('error', (err) => {
136
+ this.emit('error', err)
137
+ })
138
+ }
139
+
140
+ _scheduleReconnect() {
141
+ if (this._stopped) return
142
+ const delay = this._retryDelay
143
+ this._retryDelay = Math.min(delay * 2, MAX_RETRY_SEC)
144
+ this.emit('reconnecting', delay)
145
+ setTimeout(() => this._wsConnect(), delay * 1000)
146
+ }
147
+
148
+ _resetPing() {
149
+ this._clearPing()
150
+ this._pingTimer = setTimeout(() => {
151
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
152
+ this._ws.ping()
153
+ }
154
+ }, PING_INTERVAL_MS)
155
+ }
156
+
157
+ _clearPing() {
158
+ if (this._pingTimer) {
159
+ clearTimeout(this._pingTimer)
160
+ this._pingTimer = null
161
+ }
162
+ }
163
+ }
164
+
165
+ module.exports = { WsClient }