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 +224 -0
- package/examples/listen.js +81 -0
- package/examples/poll.js +65 -0
- package/examples/send.js +41 -0
- package/package.json +26 -0
- package/src/client.js +86 -0
- package/src/dedup.js +62 -0
- package/src/index.js +231 -0
- package/src/lock.js +61 -0
- package/src/poller.js +100 -0
- package/src/router.js +116 -0
- package/src/sanitizer.js +41 -0
- package/src/ws-client.js +165 -0
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
|
+
})
|
package/examples/poll.js
ADDED
|
@@ -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
|
+
})()
|
package/examples/send.js
ADDED
|
@@ -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 }
|
package/src/sanitizer.js
ADDED
|
@@ -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 }
|
package/src/ws-client.js
ADDED
|
@@ -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 }
|