clawmate 1.2.0 → 1.3.0

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.
@@ -0,0 +1,560 @@
1
+ /**
2
+ * 텔레그램 봇 통합 모듈
3
+ *
4
+ * 텔레그램 메시지 ↔ ClawMate 양방향 통신.
5
+ * - 텔레그램에서 온 메시지를 파싱하여 AI Bridge에 명령 전달
6
+ * - 펫의 상태/말을 텔레그램으로 역전달
7
+ *
8
+ * 봇 토큰 우선순위:
9
+ * 1. 환경변수 CLAWMATE_TELEGRAM_TOKEN
10
+ * 2. 설정 파일 (Store)
11
+ * 3. 둘 다 없으면 조용히 비활성화 (에러 없음)
12
+ *
13
+ * 의존성: node-telegram-bot-api (npm install node-telegram-bot-api)
14
+ */
15
+
16
+ const EventEmitter = require('events');
17
+ const { parseMessage } = require('./file-command-parser');
18
+ const { executeSmartFileOp } = require('./smart-file-ops');
19
+
20
+ // 텔레그램 봇 API 동적 로드 (미설치 시 조용히 무시)
21
+ let TelegramBotAPI = null;
22
+ try {
23
+ TelegramBotAPI = require('node-telegram-bot-api');
24
+ } catch {
25
+ // node-telegram-bot-api 미설치 — 텔레그램 기능 비활성화
26
+ }
27
+
28
+ class TelegramBot extends EventEmitter {
29
+ /**
30
+ * @param {object} bridge - AIBridge 인스턴스
31
+ * @param {object} options - 추가 옵션
32
+ * - token: 봇 토큰 (환경변수보다 우선)
33
+ * - allowedChatIds: 허용된 채팅 ID 목록 (보안)
34
+ */
35
+ constructor(bridge, options = {}) {
36
+ super();
37
+ this.bridge = bridge;
38
+ this.bot = null;
39
+ this.active = false;
40
+ this.allowedChatIds = options.allowedChatIds || null;
41
+ this.activeChatIds = new Set(); // 활성 채팅 ID 추적
42
+
43
+ // 진행 중인 파일 작업 추적
44
+ this._fileOpInProgress = false;
45
+
46
+ // 봇 토큰 결정
47
+ const token = options.token
48
+ || process.env.CLAWMATE_TELEGRAM_TOKEN
49
+ || null;
50
+
51
+ if (!token) {
52
+ console.log('[Telegram] 봇 토큰 없음 — 텔레그램 기능 비활성화');
53
+ return;
54
+ }
55
+
56
+ if (!TelegramBotAPI) {
57
+ console.log('[Telegram] node-telegram-bot-api 미설치 — 텔레그램 기능 비활성화');
58
+ console.log('[Telegram] 설치: npm install node-telegram-bot-api');
59
+ return;
60
+ }
61
+
62
+ this._init(token);
63
+ }
64
+
65
+ /**
66
+ * 봇 초기화 및 메시지 리스너 등록
67
+ */
68
+ _init(token) {
69
+ try {
70
+ this.bot = new TelegramBotAPI(token, { polling: true });
71
+ this.active = true;
72
+ console.log('[Telegram] 봇 초기화 성공 — 메시지 대기 중');
73
+
74
+ // 메시지 수신 핸들러
75
+ this.bot.on('message', (msg) => this._handleMessage(msg));
76
+
77
+ // 에러 핸들러 (연결 끊김 등)
78
+ this.bot.on('polling_error', (err) => {
79
+ // 토큰 오류 등 치명적 에러가 아니면 조용히 재시도
80
+ if (err.code === 'ETELEGRAM' && err.response?.statusCode === 401) {
81
+ console.error('[Telegram] 봇 토큰이 유효하지 않음 — 텔레그램 비활성화');
82
+ this.stop();
83
+ }
84
+ });
85
+
86
+ // AI Bridge에서 펫 이벤트 수신 → 텔레그램으로 전달
87
+ this._setupBridgeListeners();
88
+ } catch (err) {
89
+ console.error('[Telegram] 봇 초기화 실패:', err.message);
90
+ this.active = false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * AI Bridge 이벤트 리스너 설정 (펫 → 텔레그램)
96
+ */
97
+ _setupBridgeListeners() {
98
+ if (!this.bridge) return;
99
+
100
+ // 펫이 말할 때 텔레그램으로 전달
101
+ this.bridge.on('speak', (payload) => {
102
+ this._broadcastToChats(`[Claw] ${payload.text}`);
103
+ });
104
+
105
+ // AI 의사결정에 speech가 있으면 전달
106
+ this.bridge.on('ai_decision', (payload) => {
107
+ if (payload.speech) {
108
+ this._broadcastToChats(`[Claw] ${payload.speech}`);
109
+ }
110
+ });
111
+ }
112
+
113
+ /**
114
+ * 텔레그램 메시지 처리
115
+ */
116
+ async _handleMessage(msg) {
117
+ if (!this.active) return;
118
+ if (!msg.text) return;
119
+
120
+ const chatId = msg.chat.id;
121
+
122
+ // 보안: 허용된 채팅 ID만 처리
123
+ if (this.allowedChatIds && !this.allowedChatIds.includes(chatId)) {
124
+ return;
125
+ }
126
+
127
+ // 활성 채팅 ID 추적 (역전달용)
128
+ this.activeChatIds.add(chatId);
129
+
130
+ const text = msg.text.trim();
131
+ console.log(`[Telegram] 수신 (${chatId}): ${text}`);
132
+
133
+ // 특수 명령 처리
134
+ if (text === '/start') {
135
+ await this.bot.sendMessage(chatId,
136
+ 'ClawMate 연결됨! \n\n' +
137
+ '사용 가능한 명령:\n' +
138
+ '- 아무 메시지: 펫에게 말하기\n' +
139
+ '- 행동 키워드: 점프해, 잠자, 춤춰, 걸어...\n' +
140
+ '- 파일 정리: "바탕화면의 .md 파일을 docs 폴더에 넣어줘"\n' +
141
+ '- 캐릭터 변경: "파란 고양이로 바꿔줘"\n' +
142
+ '- /reset: 원래 캐릭터로 되돌리기\n' +
143
+ '- /status: 펫 상태 확인\n' +
144
+ '- /undo: 마지막 파일 이동 되돌리기'
145
+ );
146
+ return;
147
+ }
148
+
149
+ if (text === '/status') {
150
+ await this._sendStatus(chatId);
151
+ return;
152
+ }
153
+
154
+ if (text === '/undo') {
155
+ await this._undoLastMove(chatId);
156
+ return;
157
+ }
158
+
159
+ if (text === '/reset') {
160
+ this._sendToBridge('reset_character', {});
161
+ await this.bot.sendMessage(chatId, '원래 캐릭터로 되돌렸어!');
162
+ return;
163
+ }
164
+
165
+ // 메시지 파싱 및 처리
166
+ const parsed = parseMessage(text);
167
+ await this._executeCommand(chatId, parsed);
168
+ }
169
+
170
+ /**
171
+ * 파싱된 명령 실행
172
+ */
173
+ async _executeCommand(chatId, command) {
174
+ switch (command.type) {
175
+ case 'speak':
176
+ // 일반 대화 → 펫 말풍선에 표시
177
+ this._sendToBridge('speak', { text: command.text, style: 'normal' });
178
+ this._sendToBridge('ai_decision', {
179
+ speech: command.text,
180
+ emotion: 'happy',
181
+ });
182
+ break;
183
+
184
+ case 'action':
185
+ // 행동 명령 → 펫 행동 변경
186
+ this._sendToBridge('action', { state: command.action });
187
+ await this.bot.sendMessage(chatId, `펫이 "${command.action}" 행동을 합니다!`);
188
+ break;
189
+
190
+ case 'smart_file_op':
191
+ // 파일 조작 명령
192
+ await this._executeFileOp(chatId, command);
193
+ break;
194
+
195
+ case 'character_change':
196
+ // 캐릭터 변경 명령 → AI 생성 요청
197
+ await this._handleCharacterChange(chatId, command.concept);
198
+ break;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 캐릭터 변경 요청 처리
204
+ *
205
+ * 컨셉 텍스트를 AI(OpenClaw 플러그인)에 전달하여
206
+ * 색상 + 프레임 데이터를 생성하고 펫에 적용.
207
+ *
208
+ * AI가 없으면 컨셉에서 색상만 추출하여 기본 변환.
209
+ */
210
+ async _handleCharacterChange(chatId, concept) {
211
+ await this.bot.sendMessage(chatId, `"${concept}" 캐릭터 생성 중...`);
212
+
213
+ // AI Bridge를 통해 OpenClaw 플러그인에 캐릭터 생성 요청
214
+ this._sendToBridge('ai_decision', {
215
+ speech: `${concept}(으)로 변신 준비 중...`,
216
+ emotion: 'curious',
217
+ action: 'excited',
218
+ });
219
+
220
+ // user_event로 캐릭터 변경 요청 전달 (OpenClaw 플러그인이 AI로 생성)
221
+ if (this.bridge) {
222
+ this.bridge.send('user_event', {
223
+ event: 'character_request',
224
+ concept,
225
+ chatId,
226
+ });
227
+ }
228
+
229
+ // 폴백: AI 응답이 3초 내에 없으면 키워드 기반 색상 변환
230
+ this._characterFallbackTimer = setTimeout(() => {
231
+ const colorMap = this._extractColorsFromConcept(concept);
232
+ if (colorMap) {
233
+ this._sendToBridge('set_character', {
234
+ colorMap,
235
+ speech: `${concept} 변신!`,
236
+ });
237
+ this.bot.sendMessage(chatId, `"${concept}" 캐릭터로 바꿨어! (색상 기반)`);
238
+ }
239
+ }, 3000);
240
+
241
+ // AI가 캐릭터를 생성하면 이 타이머를 취소
242
+ this._pendingCharacterChatId = chatId;
243
+ }
244
+
245
+ /**
246
+ * AI가 캐릭터 생성을 완료했을 때 호출
247
+ * 폴백 타이머를 취소하고 텔레그램에 알림
248
+ */
249
+ onCharacterGenerated(concept) {
250
+ if (this._characterFallbackTimer) {
251
+ clearTimeout(this._characterFallbackTimer);
252
+ this._characterFallbackTimer = null;
253
+ }
254
+ if (this._pendingCharacterChatId) {
255
+ this.bot?.sendMessage(this._pendingCharacterChatId,
256
+ `"${concept}" 캐릭터 생성 완료! AI가 만든 커스텀 캐릭터야!`);
257
+ this._pendingCharacterChatId = null;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 컨셉 텍스트에서 색상 추출 (AI 없을 때 폴백)
263
+ * 키워드 매칭으로 색상 팔레트 결정
264
+ */
265
+ _extractColorsFromConcept(concept) {
266
+ const c = concept.toLowerCase();
267
+
268
+ // 색상 키워드 → 팔레트 매핑
269
+ const colorKeywords = {
270
+ // 파란 계열
271
+ '파란': { primary: '#4488ff', secondary: '#6699ff', dark: '#223388', claw: '#4488ff' },
272
+ '파랑': { primary: '#4488ff', secondary: '#6699ff', dark: '#223388', claw: '#4488ff' },
273
+ 'blue': { primary: '#4488ff', secondary: '#6699ff', dark: '#223388', claw: '#4488ff' },
274
+ // 초록 계열
275
+ '초록': { primary: '#44cc44', secondary: '#66dd66', dark: '#226622', claw: '#44cc44' },
276
+ '녹색': { primary: '#44cc44', secondary: '#66dd66', dark: '#226622', claw: '#44cc44' },
277
+ 'green': { primary: '#44cc44', secondary: '#66dd66', dark: '#226622', claw: '#44cc44' },
278
+ // 보라 계열
279
+ '보라': { primary: '#8844cc', secondary: '#aa66dd', dark: '#442266', claw: '#8844cc' },
280
+ 'purple': { primary: '#8844cc', secondary: '#aa66dd', dark: '#442266', claw: '#8844cc' },
281
+ // 노란 계열
282
+ '노란': { primary: '#ffcc00', secondary: '#ffdd44', dark: '#886600', claw: '#ffcc00' },
283
+ '금색': { primary: '#ffd700', secondary: '#ffe44d', dark: '#8B7500', claw: '#ffd700' },
284
+ 'yellow': { primary: '#ffcc00', secondary: '#ffdd44', dark: '#886600', claw: '#ffcc00' },
285
+ 'gold': { primary: '#ffd700', secondary: '#ffe44d', dark: '#8B7500', claw: '#ffd700' },
286
+ // 분홍 계열
287
+ '분홍': { primary: '#ff69b4', secondary: '#ff8cc4', dark: '#8B3060', claw: '#ff69b4' },
288
+ '핑크': { primary: '#ff69b4', secondary: '#ff8cc4', dark: '#8B3060', claw: '#ff69b4' },
289
+ 'pink': { primary: '#ff69b4', secondary: '#ff8cc4', dark: '#8B3060', claw: '#ff69b4' },
290
+ // 하얀 계열
291
+ '하얀': { primary: '#eeeeee', secondary: '#ffffff', dark: '#999999', claw: '#dddddd' },
292
+ '흰': { primary: '#eeeeee', secondary: '#ffffff', dark: '#999999', claw: '#dddddd' },
293
+ 'white': { primary: '#eeeeee', secondary: '#ffffff', dark: '#999999', claw: '#dddddd' },
294
+ // 검정 계열
295
+ '검정': { primary: '#333333', secondary: '#555555', dark: '#111111', claw: '#444444' },
296
+ '까만': { primary: '#333333', secondary: '#555555', dark: '#111111', claw: '#444444' },
297
+ 'black': { primary: '#333333', secondary: '#555555', dark: '#111111', claw: '#444444' },
298
+ // 주황 계열
299
+ '주황': { primary: '#ff8800', secondary: '#ffaa33', dark: '#884400', claw: '#ff8800' },
300
+ 'orange': { primary: '#ff8800', secondary: '#ffaa33', dark: '#884400', claw: '#ff8800' },
301
+ // 틸/민트 계열
302
+ '민트': { primary: '#00BFA5', secondary: '#33D4BC', dark: '#006655', claw: '#00BFA5' },
303
+ '틸': { primary: '#00BFA5', secondary: '#33D4BC', dark: '#006655', claw: '#00BFA5' },
304
+ 'teal': { primary: '#00BFA5', secondary: '#33D4BC', dark: '#006655', claw: '#00BFA5' },
305
+ };
306
+
307
+ for (const [keyword, palette] of Object.entries(colorKeywords)) {
308
+ if (c.includes(keyword)) {
309
+ return {
310
+ ...palette,
311
+ eye: '#ffffff',
312
+ pupil: '#111111',
313
+ };
314
+ }
315
+ }
316
+
317
+ // 생물 키워드 → 특징적 색상
318
+ const creatureColors = {
319
+ '고양이': { primary: '#ff9944', secondary: '#ffbb66', dark: '#663300', claw: '#ff9944' },
320
+ 'cat': { primary: '#ff9944', secondary: '#ffbb66', dark: '#663300', claw: '#ff9944' },
321
+ '강아지': { primary: '#cc8844', secondary: '#ddaa66', dark: '#664422', claw: '#cc8844' },
322
+ 'dog': { primary: '#cc8844', secondary: '#ddaa66', dark: '#664422', claw: '#cc8844' },
323
+ '로봇': { primary: '#888888', secondary: '#aaaaaa', dark: '#444444', claw: '#66aaff' },
324
+ 'robot': { primary: '#888888', secondary: '#aaaaaa', dark: '#444444', claw: '#66aaff' },
325
+ '슬라임': { primary: '#44dd44', secondary: '#88ff88', dark: '#228822', claw: '#44dd44' },
326
+ 'slime': { primary: '#44dd44', secondary: '#88ff88', dark: '#228822', claw: '#44dd44' },
327
+ '유령': { primary: '#ccccff', secondary: '#eeeeff', dark: '#6666aa', claw: '#ccccff' },
328
+ 'ghost': { primary: '#ccccff', secondary: '#eeeeff', dark: '#6666aa', claw: '#ccccff' },
329
+ '드래곤': { primary: '#cc2222', secondary: '#ff4444', dark: '#661111', claw: '#ffaa00' },
330
+ 'dragon': { primary: '#cc2222', secondary: '#ff4444', dark: '#661111', claw: '#ffaa00' },
331
+ '펭귄': { primary: '#222222', secondary: '#ffffff', dark: '#111111', claw: '#ff8800' },
332
+ 'penguin': { primary: '#222222', secondary: '#ffffff', dark: '#111111', claw: '#ff8800' },
333
+ '토끼': { primary: '#ffcccc', secondary: '#ffeeee', dark: '#ff8888', claw: '#ffcccc' },
334
+ 'rabbit': { primary: '#ffcccc', secondary: '#ffeeee', dark: '#ff8888', claw: '#ffcccc' },
335
+ '악마': { primary: '#660066', secondary: '#880088', dark: '#330033', claw: '#ff0000' },
336
+ 'demon': { primary: '#660066', secondary: '#880088', dark: '#330033', claw: '#ff0000' },
337
+ '천사': { primary: '#ffffff', secondary: '#ffffcc', dark: '#ddddaa', claw: '#ffdd00' },
338
+ 'angel': { primary: '#ffffff', secondary: '#ffffcc', dark: '#ddddaa', claw: '#ffdd00' },
339
+ };
340
+
341
+ for (const [keyword, palette] of Object.entries(creatureColors)) {
342
+ if (c.includes(keyword)) {
343
+ return {
344
+ ...palette,
345
+ eye: '#ffffff',
346
+ pupil: '#111111',
347
+ };
348
+ }
349
+ }
350
+
351
+ // 매칭 안 되면 랜덤 색상
352
+ const hue = Math.floor(Math.random() * 360);
353
+ return {
354
+ primary: `hsl(${hue}, 70%, 55%)`,
355
+ secondary: `hsl(${hue}, 70%, 70%)`,
356
+ dark: `hsl(${hue}, 60%, 25%)`,
357
+ eye: '#ffffff',
358
+ pupil: '#111111',
359
+ claw: `hsl(${hue}, 70%, 55%)`,
360
+ };
361
+ }
362
+
363
+ /**
364
+ * 스마트 파일 조작 실행 + 펫 애니메이션 + 텔레그램 피드백
365
+ */
366
+ async _executeFileOp(chatId, command) {
367
+ if (this._fileOpInProgress) {
368
+ await this.bot.sendMessage(chatId, '이미 파일 작업이 진행 중이야! 잠시만 기다려줘.');
369
+ return;
370
+ }
371
+
372
+ this._fileOpInProgress = true;
373
+
374
+ const callbacks = {
375
+ onStart: (totalFiles) => {
376
+ this.bot.sendMessage(chatId, `${totalFiles}개 파일을 발견했어! 나르기 시작할게~`);
377
+ this._sendToBridge('ai_decision', {
378
+ action: 'excited',
379
+ speech: `${totalFiles}개 파일 정리 시작!`,
380
+ emotion: 'happy',
381
+ });
382
+ },
383
+
384
+ onPickUp: (fileName, index) => {
385
+ // 펫이 파일을 집어드는 애니메이션
386
+ this._sendToBridge('smart_file_op', {
387
+ phase: 'pick_up',
388
+ fileName,
389
+ index,
390
+ });
391
+ this._sendToBridge('ai_decision', {
392
+ action: 'carrying',
393
+ speech: `${fileName} 집었다!`,
394
+ emotion: 'focused',
395
+ });
396
+ },
397
+
398
+ onDrop: (fileName, targetName, index) => {
399
+ // 펫이 파일을 내려놓는 애니메이션
400
+ this._sendToBridge('smart_file_op', {
401
+ phase: 'drop',
402
+ fileName,
403
+ targetName,
404
+ index,
405
+ });
406
+ this._sendToBridge('ai_decision', {
407
+ action: 'walking',
408
+ speech: `${fileName} → ${targetName}에 놓았다!`,
409
+ emotion: 'happy',
410
+ });
411
+ },
412
+
413
+ onComplete: (result) => {
414
+ this._fileOpInProgress = false;
415
+
416
+ let message;
417
+ if (result.movedCount === 0) {
418
+ message = '옮길 파일이 없었어!';
419
+ } else {
420
+ message = `${result.movedCount}개 파일 옮겼어!`;
421
+ if (result.errors.length > 0) {
422
+ message += `\n(${result.errors.length}개 실패)`;
423
+ }
424
+ }
425
+
426
+ this.bot.sendMessage(chatId, message);
427
+ this._sendToBridge('ai_decision', {
428
+ action: 'excited',
429
+ speech: message,
430
+ emotion: 'proud',
431
+ });
432
+
433
+ // smart_file_op 완료 이벤트
434
+ this._sendToBridge('smart_file_op', {
435
+ phase: 'complete',
436
+ movedCount: result.movedCount,
437
+ errors: result.errors,
438
+ });
439
+ },
440
+
441
+ onError: (error) => {
442
+ this._fileOpInProgress = false;
443
+ this.bot.sendMessage(chatId, `파일 작업 중 오류 발생: ${error}`);
444
+ this._sendToBridge('ai_decision', {
445
+ action: 'scared',
446
+ speech: '앗, 뭔가 잘못됐어...',
447
+ emotion: 'scared',
448
+ });
449
+ },
450
+ };
451
+
452
+ await executeSmartFileOp(command, callbacks);
453
+ }
454
+
455
+ /**
456
+ * 펫 상태 조회 후 텔레그램으로 전송
457
+ */
458
+ async _sendStatus(chatId) {
459
+ if (!this.bridge) {
460
+ await this.bot.sendMessage(chatId, 'AI Bridge에 연결되지 않았어.');
461
+ return;
462
+ }
463
+
464
+ const state = this.bridge.petState;
465
+ const statusText =
466
+ `상태: ${state.state}\n` +
467
+ `위치: (${state.position.x}, ${state.position.y})\n` +
468
+ `모드: ${state.mode}\n` +
469
+ `감정: ${state.emotion}\n` +
470
+ `진화: ${state.evolutionStage}단계\n` +
471
+ `AI 연결: ${this.bridge.isConnected() ? 'O' : 'X'}`;
472
+
473
+ await this.bot.sendMessage(chatId, statusText);
474
+ }
475
+
476
+ /**
477
+ * 마지막 파일 이동 되돌리기
478
+ */
479
+ async _undoLastMove(chatId) {
480
+ try {
481
+ const { undoAllSmartMoves } = require('./smart-file-ops');
482
+ const result = undoAllSmartMoves();
483
+
484
+ if (result.restoredCount === 0) {
485
+ await this.bot.sendMessage(chatId, '되돌릴 파일 이동이 없어.');
486
+ } else {
487
+ let message = `${result.restoredCount}개 파일을 원래 위치로 되돌렸어!`;
488
+ if (result.errors.length > 0) {
489
+ message += `\n(${result.errors.length}개 복원 실패)`;
490
+ }
491
+ await this.bot.sendMessage(chatId, message);
492
+ this._sendToBridge('ai_decision', {
493
+ action: 'walking',
494
+ speech: '파일들을 원래대로 돌려놨어!',
495
+ emotion: 'happy',
496
+ });
497
+ }
498
+ } catch (err) {
499
+ await this.bot.sendMessage(chatId, `되돌리기 실패: ${err.message}`);
500
+ }
501
+ }
502
+
503
+ /**
504
+ * AI Bridge에 명령 전달
505
+ */
506
+ _sendToBridge(type, payload) {
507
+ if (!this.bridge) return;
508
+
509
+ // bridge의 _handleCommand를 직접 호출 (내부 이벤트 방출)
510
+ // telegram에서 온 명령임을 표시
511
+ payload._fromTelegram = true;
512
+ this.bridge.emit(type, payload);
513
+ }
514
+
515
+ /**
516
+ * 모든 활성 채팅에 메시지 브로드캐스트
517
+ */
518
+ _broadcastToChats(text) {
519
+ if (!this.bot || !this.active) return;
520
+
521
+ for (const chatId of this.activeChatIds) {
522
+ this.bot.sendMessage(chatId, text).catch(() => {
523
+ // 전송 실패 시 해당 채팅 ID 제거
524
+ this.activeChatIds.delete(chatId);
525
+ });
526
+ }
527
+ }
528
+
529
+ /**
530
+ * 특정 채팅에 메시지 전송
531
+ */
532
+ async sendMessage(chatId, text) {
533
+ if (!this.bot || !this.active) return;
534
+ try {
535
+ await this.bot.sendMessage(chatId, text);
536
+ } catch (err) {
537
+ console.error('[Telegram] 메시지 전송 실패:', err.message);
538
+ }
539
+ }
540
+
541
+ /**
542
+ * 봇 중지
543
+ */
544
+ stop() {
545
+ if (this.bot && this.active) {
546
+ this.bot.stopPolling();
547
+ this.active = false;
548
+ console.log('[Telegram] 봇 중지');
549
+ }
550
+ }
551
+
552
+ /**
553
+ * 활성 상태 확인
554
+ */
555
+ isActive() {
556
+ return this.active;
557
+ }
558
+ }
559
+
560
+ module.exports = { TelegramBot };