@fuzionx/framework 0.1.70 → 0.1.72

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.
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/player": "^0.1.70",
12
+ "@fuzionx/player": "^0.1.72",
13
13
  "pinia": "^3.0.4",
14
14
  "vue": "^3.5.0",
15
15
  "vue-router": "^4.5.0"
@@ -32,6 +32,7 @@ import CacheManager from '../cache/CacheManager.js';
32
32
  import SqlModel from '../database/SqlModel.js';
33
33
  import MongoModel from '../database/MongoModel.js';
34
34
  import WorkerPool from '../schedule/WorkerPool.js';
35
+ import TelegramManager from '../utilities/TelegramManager.js';
35
36
 
36
37
  // Bridge: lazy-load (테스트 환경에서 native 바인딩 없을 수 있음)
37
38
  let FuzionXApp;
@@ -109,6 +110,8 @@ export default class Application {
109
110
  this._scheduler = null;
110
111
  this._queue = null;
111
112
  this._wsHandlers = new Map();
113
+
114
+ this.telegram = null;
112
115
 
113
116
  // DB 연결 매니저
114
117
  this._connectionManager = new ConnectionManager();
@@ -139,10 +142,20 @@ export default class Application {
139
142
  this._factories.set(name, factory);
140
143
  }
141
144
 
142
- make(name) {
145
+ /**
146
+ * 서비스 인스턴스 생성/조회 (Singleton)
147
+ *
148
+ * @param {string} name - 서비스 이름 (e.g. 'AuthService' 또는 'frontend:AuthService')
149
+ * @param {boolean} [silent=false] - true이면 미등록 시 에러 대신 null 반환
150
+ * @returns {*} 서비스 인스턴스 또는 null (silent=true 시)
151
+ */
152
+ make(name, silent = false) {
143
153
  if (this._services.has(name)) return this._services.get(name);
144
154
  const factory = this._factories.get(name);
145
- if (!factory) throw new Error(`Service '${name}' is not registered`);
155
+ if (!factory) {
156
+ if (silent) return null;
157
+ throw new Error(`Service '${name}' is not registered`);
158
+ }
146
159
  const instance = factory(this);
147
160
  this._services.set(name, instance);
148
161
  return instance;
@@ -285,9 +298,16 @@ export default class Application {
285
298
  // 2. i18n 로드 (04-bootstrap-lifecycle.md)
286
299
  await this.i18n.load();
287
300
 
288
- // 3. Scheduler / Queue / Storage / Cache 초기화
301
+ // 3. Scheduler / Queue / Telegram / Storage / Cache 초기화
289
302
  this._scheduler = new Scheduler(this);
290
303
  this._queue = new Queue(this, { driver: this.config.get('queue.driver', 'memory') });
304
+
305
+ // 텔레그램 매니저 로드 (Queue 기반이므로 _queue 설정 직후 구성)
306
+ const telegramConfig = this.config.get('app.telegram');
307
+ if (telegramConfig && telegramConfig.enabled !== false) {
308
+ this.telegram = new TelegramManager(this, telegramConfig);
309
+ }
310
+
291
311
  this.storage = new Storage({
292
312
  driver: this.config.get('storage.driver', 'local'),
293
313
  basePath: path.resolve(this.baseDir, this.config.get('storage.path', './storage')),
@@ -646,7 +666,9 @@ export default class Application {
646
666
  if (handler?.__handler__ && handler.controller) {
647
667
  const CtrlClass = handler.controller;
648
668
  if (!appEntry.controllerCache.has(CtrlClass)) {
649
- appEntry.controllerCache.set(CtrlClass, new CtrlClass(this));
669
+ const instance = new CtrlClass(this);
670
+ instance._appName = appEntry.name; // 앱별 서비스 격리를 위한 네임스페이스 주입
671
+ appEntry.controllerCache.set(CtrlClass, instance);
650
672
  }
651
673
  }
652
674
  }
@@ -830,7 +852,18 @@ export default class Application {
830
852
  })
831
853
  .catch((err) => {
832
854
  console.error(`[FX] Unhandled controller error: ${ctx.method} ${ctx.path}`, err);
833
- this._handleContextError(err, ctx);
855
+ try {
856
+ this._handleContextError(err, ctx);
857
+ } catch (handlerErr) {
858
+ // 에러 핸들링 자체 실패 → 최소한의 500 응답 보장
859
+ console.error(`[FX] Error handler failed:`, handlerErr);
860
+ try {
861
+ ctx._statusCode = 500;
862
+ ctx._body = JSON.stringify({ error: { message: err.message, status: 500 } });
863
+ ctx._headers['Content-Type'] = 'application/json';
864
+ ctx._sent = true;
865
+ } catch { /* ctx 조작도 실패하면 포기 */ }
866
+ }
834
867
  this._sendContextResponse(rawReq.requestId, ctx);
835
868
  });
836
869
 
@@ -950,6 +983,34 @@ export default class Application {
950
983
  }
951
984
  }
952
985
 
986
+ /**
987
+ * WS 핸들러 수동 등록 — shared/ws 핸들러를 앱별 레지스트리에 추가
988
+ *
989
+ * boot() 이후, listen() 이전에 호출.
990
+ * listen() 내부의 _registerWsHandlers()가 등록된 핸들러를 Bridge에 연결.
991
+ *
992
+ * @example
993
+ * await app.boot();
994
+ * import NotificationHandler from './shared/ws/NotificationHandler.js';
995
+ * app.registerWsHandler(NotificationHandler); // 기본 앱에 등록
996
+ * app.registerWsHandler(NotificationHandler, 'frontend'); // 특정 앱에 등록
997
+ * await app.listen();
998
+ *
999
+ * @param {typeof import('./WsHandler.js').default} HandlerClass - WsHandler 서브클래스
1000
+ * @param {string} [appName] - 등록할 앱 이름 (미지정 시 첫 번째 앱)
1001
+ */
1002
+ registerWsHandler(HandlerClass, appName) {
1003
+ const name = appName || this._getDefaultAppName();
1004
+ const appEntry = this._appRegistry.get(name);
1005
+ if (!appEntry) {
1006
+ this.logger.warn(`[WS] registerWsHandler: 앱 '${name}'을(를) 찾을 수 없습니다.`);
1007
+ return;
1008
+ }
1009
+ const ns = HandlerClass.namespace || '/';
1010
+ appEntry.wsHandlers.set(ns, HandlerClass);
1011
+ this.logger.debug(`[WS] 수동 등록: ${ns} → 앱 '${name}'`);
1012
+ }
1013
+
953
1014
  /**
954
1015
  * Host 기반 디스패치 핸들러 생성 (최적화 v2)
955
1016
  *
@@ -1201,20 +1262,29 @@ export default class Application {
1201
1262
 
1202
1263
  /**
1203
1264
  * Context 에러 핸들링 — ctx에 에러 응답 설정
1265
+ *
1266
+ * 커스텀 에러 핸들러(useError)는 부가 작업(알림 등)용으로,
1267
+ * 기본 에러 응답 설정(ErrorHandler.handle)은 항상 실행된다.
1268
+ *
1204
1269
  * @param {Error} err - 발생한 에러
1205
1270
  * @param {object} ctx - Context 인스턴스
1206
1271
  * @private
1207
1272
  */
1208
1273
  _handleContextError(err, ctx) {
1209
- // 커스텀 에러 핸들러 체크 (useError로 등록)
1274
+ // 커스텀 에러 핸들러 실행 (fire-and-forget — 알림, 로깅 등 부가 작업)
1210
1275
  for (const { path: ePath, handler } of this._errorHandlers) {
1211
1276
  if (!ePath || ctx.path?.startsWith(ePath)) {
1212
1277
  try {
1213
- handler(err, ctx);
1214
- return;
1215
- } catch { /* 커스텀 핸들러 실패 시 기본으로 */ }
1278
+ const result = handler(err, ctx);
1279
+ // async 핸들러 → 에러 무시 (알림 실패가 응답에 영향 X)
1280
+ if (result && typeof result.then === 'function') {
1281
+ result.catch(() => {});
1282
+ }
1283
+ } catch { /* 커스텀 핸들러 sync 에러 무시 */ }
1216
1284
  }
1217
1285
  }
1286
+
1287
+ // 기본 에러 응답 설정 (항상 실행 — ctx에 status/body 세팅)
1218
1288
  this._errorHandler.handle(err, ctx);
1219
1289
 
1220
1290
  // 'error' 이벤트 발행 — 앱 레벨에서 ErrorLog 기록용 (fire-and-forget)
@@ -144,7 +144,13 @@ export default class AutoLoader {
144
144
  }
145
145
  }
146
146
 
147
- /** services/*.js → DI register (앱별 네임스페이스) */
147
+ /**
148
+ * services/*.js → DI register (앱별 네임스페이스)
149
+ *
150
+ * 앱 모드에서는 'appName:ServiceName' 키로 앱별 격리 등록 +
151
+ * 'ServiceName' 키로 글로벌 등록 (backward compatibility).
152
+ * 동일 이름 서비스가 여러 앱에 존재하면 앱별 키로 격리됨.
153
+ */
148
154
  async loadServices() {
149
155
  const files = await scanDir(path.join(this.baseDir, 'services'));
150
156
  for (const file of files) {
@@ -152,7 +158,21 @@ export default class AutoLoader {
152
158
  const ServiceClass = mod.default;
153
159
  if (!ServiceClass) continue;
154
160
  const name = extractName(file, 'Service');
155
- this.app.register(`${name}Service`, (app) => new ServiceClass(app));
161
+ const globalKey = `${name}Service`;
162
+ const appName = this._appContext?.name;
163
+
164
+ /** 앱별 scoped 등록 (e.g. 'frontend:AuthService') */
165
+ if (appName) {
166
+ const scopedKey = `${appName}:${globalKey}`;
167
+ this.app.register(scopedKey, (app) => {
168
+ const instance = new ServiceClass(app);
169
+ instance._appName = appName; // 서비스 내에서 다른 서비스 호출 시에도 앱 격리 유지
170
+ return instance;
171
+ });
172
+ }
173
+
174
+ /** 글로벌 등록 (마지막 앱이 덮어씀 — backward compatibility) */
175
+ this.app.register(globalKey, (app) => new ServiceClass(app));
156
176
  }
157
177
  }
158
178
 
package/lib/core/Base.js CHANGED
@@ -19,14 +19,32 @@ export default class Base {
19
19
  this.config = app.config;
20
20
  /** @type {object} Logger */
21
21
  this.logger = app.logger;
22
+ /**
23
+ * 앱 네임스페이스 — 서비스 resolve 시 앱별 격리에 사용
24
+ * Controller/Service 생성 시 주입됨.
25
+ * @type {string|null}
26
+ */
27
+ this._appName = null;
22
28
  }
23
29
 
24
30
  /**
25
- * 서비스 조회 (DI)
26
- * @param {string} name
31
+ * 서비스 조회 (DI) — 앱별 네임스페이스 자동 적용
32
+ *
33
+ * resolve 순서:
34
+ * 1. _appName이 있으면 'appName:ServiceName' 우선 조회
35
+ * 2. fallback으로 글로벌 'ServiceName' 조회
36
+ *
37
+ * 이 패턴으로 동일 이름의 서비스가 backend/frontend에 각각 존재해도
38
+ * 앱 코드에서는 this.service('AuthService')로 호출 가능.
39
+ *
40
+ * @param {string} name - 서비스 이름 (e.g. 'AuthService')
27
41
  * @returns {*}
28
42
  */
29
43
  service(name) {
44
+ if (this._appName) {
45
+ const scoped = this.app.make(`${this._appName}:${name}`, true);
46
+ if (scoped) return scoped;
47
+ }
30
48
  return this.app.make(name);
31
49
  }
32
50
 
@@ -34,8 +34,14 @@ export default class ErrorHandler {
34
34
  this.logger(`[${status}] ${ctx.method} ${ctx.path} — ${err.message}`);
35
35
  }
36
36
 
37
- // JSON 응답 요청이면 JSON으로
38
- const wantsJson = ctx.get('accept')?.includes('application/json')
37
+ // JSON 응답 판단 — 요청 헤더 기반 (경로 하드코딩 X)
38
+ // 1. Accept 헤더에 application/json 포함 (SPA fetch/axios 기본)
39
+ // 2. Content-Type이 JSON (JSON 요청 본문)
40
+ // 3. X-Requested-With: XMLHttpRequest (AJAX 요청)
41
+ // 4. /api 경로 (fallback — 헤더 없는 경우 대비)
42
+ const accept = ctx.get('accept') || '';
43
+ const wantsJson = accept.includes('application/json')
44
+ || (!accept.includes('text/html') && ctx.get('x-requested-with') === 'XMLHttpRequest')
39
45
  || ctx.is('json')
40
46
  || ctx.path?.startsWith('/api');
41
47
 
@@ -117,22 +123,134 @@ export default class ErrorHandler {
117
123
  } catch {}
118
124
  }
119
125
 
120
- // 3. 프레임워크 내장 에러 페이지 (XSS 방지)
126
+ // 3. 프레임워크 내장 에러 페이지 (모던 디자인 — XSS 방지)
121
127
  const esc = (s) => String(s)
122
128
  .replace(/&/g, '&amp;').replace(/</g, '&lt;')
123
129
  .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
124
130
 
125
- const title = status >= 500 ? 'Server Error' : esc(err.message);
131
+ // 상태 코드별 제목/이모지/색상 분기
132
+ const statusMap = {
133
+ 400: { emoji: '⚠️', label: 'Bad Request', desc: '잘못된 요청입니다.', color: '#f59e0b' },
134
+ 401: { emoji: '🔒', label: 'Unauthorized', desc: '인증이 필요합니다.', color: '#6366f1' },
135
+ 403: { emoji: '🚫', label: 'Forbidden', desc: '접근 권한이 없습니다.', color: '#ef4444' },
136
+ 404: { emoji: '🔍', label: 'Not Found', desc: '요청한 페이지를 찾을 수 없습니다.', color: '#8b5cf6' },
137
+ 500: { emoji: '💥', label: 'Server Error', desc: '서버에서 오류가 발생했습니다.', color: '#ef4444' },
138
+ 502: { emoji: '🌐', label: 'Bad Gateway', desc: '게이트웨이 오류가 발생했습니다.', color: '#f97316' },
139
+ 503: { emoji: '🔧', label: 'Service Unavailable', desc: '서비스를 일시적으로 이용할 수 없습니다.', color: '#f59e0b' },
140
+ };
141
+ const info = statusMap[status] || {
142
+ emoji: status >= 500 ? '💥' : '⚠️',
143
+ label: status >= 500 ? 'Server Error' : 'Error',
144
+ desc: esc(err.message),
145
+ color: status >= 500 ? '#ef4444' : '#f59e0b',
146
+ };
147
+
148
+ // 개발 모드: 스택 트레이스 블록
149
+ const stackBlock = this.isDev && err.stack
150
+ ? `<details class="stack" open>
151
+ <summary>Stack Trace</summary>
152
+ <pre>${esc(err.stack)}</pre>
153
+ </details>` : '';
154
+
155
+ // 개발 모드: 요청 정보
156
+ const reqBlock = this.isDev
157
+ ? `<div class="req-info">
158
+ <span class="method">${esc(ctx.method)}</span>
159
+ <span class="path">${esc(ctx.url)}</span>
160
+ </div>` : '';
126
161
 
127
162
  ctx.status(status).html(
128
- `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${status}</title>` +
129
- `<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:80px auto;` +
130
- `padding:0 20px;color:#333}h1{color:#e74c3c;}pre{background:#f5f5f5;padding:16px;border-radius:8px;` +
131
- `overflow-x:auto;font-size:13px;}</style></head>` +
132
- `<body><h1>${status} ${title}</h1>` +
133
- `<p>요청: ${esc(ctx.method)} ${esc(ctx.url)}</p>` +
134
- `${this.isDev && err.stack ? `<pre>${esc(err.stack)}</pre>` : ''}` +
135
- `</body></html>`
163
+ `<!DOCTYPE html>
164
+ <html lang="ko">
165
+ <head>
166
+ <meta charset="utf-8">
167
+ <meta name="viewport" content="width=device-width,initial-scale=1">
168
+ <title>${status} ${esc(info.label)}</title>
169
+ <style>
170
+ *{margin:0;padding:0;box-sizing:border-box}
171
+ :root{--bg:#0f0f11;--card:#18181b;--border:#27272a;--text:#fafafa;--sub:#a1a1aa;--accent:${info.color}}
172
+ @media(prefers-color-scheme:light){
173
+ :root{--bg:#f8f9fa;--card:#fff;--border:#e5e7eb;--text:#18181b;--sub:#71717a}
174
+ }
175
+ body{
176
+ font-family:'Inter',system-ui,-apple-system,'Segoe UI',sans-serif;
177
+ background:var(--bg);color:var(--text);
178
+ min-height:100vh;display:flex;align-items:center;justify-content:center;
179
+ padding:20px;
180
+ }
181
+ .container{
182
+ max-width:520px;width:100%;text-align:center;
183
+ }
184
+ .status-code{
185
+ font-size:120px;font-weight:800;line-height:1;
186
+ background:linear-gradient(135deg,var(--accent),color-mix(in srgb,var(--accent) 60%,#fff));
187
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;
188
+ background-clip:text;
189
+ letter-spacing:-4px;margin-bottom:8px;
190
+ animation:fadeIn .5s ease-out;
191
+ }
192
+ .emoji{font-size:48px;margin-bottom:16px;display:block;animation:bounce .6s ease-out}
193
+ .card{
194
+ background:var(--card);border:1px solid var(--border);
195
+ border-radius:16px;padding:40px 32px;
196
+ box-shadow:0 4px 24px rgba(0,0,0,.12);
197
+ animation:slideUp .4s ease-out;
198
+ }
199
+ h1{font-size:18px;font-weight:600;margin-bottom:8px;color:var(--text)}
200
+ .desc{color:var(--sub);font-size:14px;line-height:1.6;margin-bottom:24px}
201
+ .home-btn{
202
+ display:inline-flex;align-items:center;gap:6px;
203
+ padding:10px 24px;border-radius:8px;font-size:14px;font-weight:500;
204
+ background:var(--accent);color:#fff;
205
+ text-decoration:none;transition:all .2s;
206
+ border:none;cursor:pointer;
207
+ }
208
+ .home-btn:hover{opacity:.85;transform:translateY(-1px)}
209
+ .req-info{
210
+ margin-top:20px;padding:10px 16px;
211
+ background:color-mix(in srgb,var(--bg) 80%,var(--card));
212
+ border-radius:8px;font-size:12px;font-family:'Menlo','Consolas',monospace;
213
+ color:var(--sub);display:flex;gap:8px;justify-content:center;align-items:center;
214
+ }
215
+ .method{
216
+ background:var(--accent);color:#fff;padding:2px 8px;border-radius:4px;
217
+ font-weight:600;font-size:11px;
218
+ }
219
+ .stack{
220
+ margin-top:20px;text-align:left;
221
+ }
222
+ .stack summary{
223
+ font-size:12px;color:var(--sub);cursor:pointer;margin-bottom:8px;
224
+ font-weight:500;
225
+ }
226
+ .stack pre{
227
+ background:color-mix(in srgb,var(--bg) 80%,var(--card));
228
+ border:1px solid var(--border);border-radius:8px;
229
+ padding:16px;font-size:11px;line-height:1.5;
230
+ overflow-x:auto;color:var(--sub);font-family:'Menlo','Consolas',monospace;
231
+ max-height:300px;overflow-y:auto;
232
+ }
233
+ .footer{margin-top:32px;font-size:11px;color:var(--sub)}
234
+ @keyframes fadeIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}
235
+ @keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
236
+ @keyframes bounce{0%{transform:scale(0)}50%{transform:scale(1.2)}100%{transform:scale(1)}}
237
+ </style>
238
+ </head>
239
+ <body>
240
+ <div class="container">
241
+ <div class="card">
242
+ <span class="emoji">${info.emoji}</span>
243
+ <div class="status-code">${status}</div>
244
+ <h1>${esc(info.label)}</h1>
245
+ <p class="desc">${esc(info.desc)}</p>
246
+ <a href="/" class="home-btn">← 홈으로</a>
247
+ ${reqBlock}
248
+ ${stackBlock}
249
+ </div>
250
+ <p class="footer">FuzionX Framework</p>
251
+ </div>
252
+ </body>
253
+ </html>`
136
254
  );
137
255
  }
138
256
  }
@@ -67,6 +67,19 @@ export default class RoomManager {
67
67
  return this._rooms.has(room) && this._rooms.get(room).size > 0;
68
68
  }
69
69
 
70
+ /**
71
+ * 소켓이 속한 룸 목록 조회
72
+ * @param {string} socketId
73
+ * @returns {string[]}
74
+ */
75
+ roomsOf(socketId) {
76
+ const result = [];
77
+ for (const [room, members] of this._rooms) {
78
+ if (members.has(socketId)) result.push(room);
79
+ }
80
+ return result;
81
+ }
82
+
70
83
  /**
71
84
  * 전체 룸 목록
72
85
  * @returns {string[]}
@@ -0,0 +1,80 @@
1
+ import Task from './Task.js';
2
+
3
+ /**
4
+ * TelegramTask - 텔레그램 메시지 발송을 위한 비동기 백그라운드 큐 작업
5
+ *
6
+ * I/O 바운드 작업으로, 메인 스레드의 블로킹을 방지하고
7
+ * 텔레그램 API의 Rate-Limit(429) 응답에 대비하여 자동 재시도 기능을 제공합니다.
8
+ */
9
+ export default class TelegramTask extends Task {
10
+ /** @type {string} 큐의 고유 이름 */
11
+ static queue = 'telegram';
12
+
13
+ /** @type {number} 실패 시 최대 재시도 횟수 */
14
+ static retries = 3;
15
+
16
+ /** @type {number} 실패 시 다음 재시도 대기 시간 (밀리초) - 텔레그램 속도 제한 방어 */
17
+ static retryDelay = 3000;
18
+
19
+ /** @type {number} API 호출 제한 시간 (밀리초) */
20
+ static timeout = 10000;
21
+
22
+ /**
23
+ * 작업의 실행 본문
24
+ *
25
+ * @param {Object} data 디스패치 될 때 넘어온 데이터
26
+ * @param {string} data.botToken 봇의 인증 토큰
27
+ * @param {string} data.chatId 수신 대상 채팅/채널 ID
28
+ * @param {string} data.text 발송할 메시지 내용
29
+ * @param {string} [data.parseMode='HTML'] 파싱 모드 (HTML, MarkdownV2 등)
30
+ * @returns {Promise<void>}
31
+ */
32
+ async handle(data) {
33
+ const { botToken, chatId, text, parseMode = 'HTML' } = data;
34
+
35
+ if (!botToken || !chatId || !text) {
36
+ throw new Error('텔레그램 발송 누락 데이터: botToken, chatId 또는 text가 없습니다.');
37
+ }
38
+
39
+ const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
40
+
41
+ try {
42
+ // Node.js 환경의 전역 fetch (v18+)
43
+ const response = await fetch(url, {
44
+ method: 'POST',
45
+ headers: {
46
+ 'Content-Type': 'application/json',
47
+ },
48
+ body: JSON.stringify({
49
+ chat_id: chatId,
50
+ text: text,
51
+ parse_mode: parseMode,
52
+ }),
53
+ });
54
+
55
+ if (!response.ok) {
56
+ // Rate-Limit 이나 서버 에러의 경우, 이 에러가 throw 되어 Queue.js 의 catch 블록으로 이동, 재시도 됨
57
+ const errorText = await response.text();
58
+ throw new Error(`텔레그램 API 에러 (${response.status}): ${errorText}`);
59
+ }
60
+ } catch (error) {
61
+ // 네트워크 연결 실패 등 예기치 않은 오류 시 재시도를 위해 상위로 에러 던지기
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 모든 재시도 소진 후 최종적으로 실패했을 때 호출되는 훅
68
+ *
69
+ * @param {Object} data
70
+ * @param {Error} error
71
+ */
72
+ async failed(data, error) {
73
+ // Application 객체(this.app)를 통해 코어 로거에 치명적 에러 기록
74
+ if (this.app && this.app.logger) {
75
+ this.app.logger.error(`[TelegramTask] 메시지 발송 최종 실패 (대상 채널: ${data.chatId}):`, error.message);
76
+ } else {
77
+ console.error(`[TelegramTask] 메시지 발송 최종 실패:`, error.message);
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,120 @@
1
+ import TelegramTask from '../schedule/TelegramTask.js';
2
+
3
+ /**
4
+ * TelegramBot - 개별 텔레그램 봇의 인스턴스를 래핑합니다.
5
+ * 백그라운드 큐 시스템에 전송 요청을 위임하여 병목을 방지합니다.
6
+ */
7
+ class TelegramBot {
8
+ /**
9
+ * @param {import('../core/Application.js').default} app 프레임워크 애플리케이션 인스턴스
10
+ * @param {string} token 봇 API 인증 토큰
11
+ * @param {string} chatId 수신 채팅/채널 ID
12
+ */
13
+ constructor(app, token, chatId) {
14
+ this.app = app;
15
+ this.token = token;
16
+ this.chatId = chatId;
17
+ }
18
+
19
+ /**
20
+ * 비동기로 텔레그램 메시지를 발송 (Queue에 적재)
21
+ *
22
+ * @param {string} text 보낼 메시지 내용
23
+ * @param {Object} [options] 전송 옵션
24
+ * @param {string} [options.parseMode='HTML'] 파싱 포맷 (HTML, MarkdownV2 등)
25
+ */
26
+ sendMessage(text, options = {}) {
27
+ const parseMode = options.parseMode || 'HTML';
28
+ if (this.app._queue) {
29
+ this.app._queue.dispatch('TelegramTask', {
30
+ botToken: this.token,
31
+ chatId: this.chatId,
32
+ text: text,
33
+ parseMode: parseMode
34
+ });
35
+ } else {
36
+ this.app.logger.warn('[TelegramBot] 앱 큐(Queue) 시스템이 비활성화 되어 있어 메시지가 전송되지 않았습니다.');
37
+ }
38
+ }
39
+
40
+ /**
41
+ * HTML 파싱을 지원하는 전송 헬퍼 메서드
42
+ * @param {string} html
43
+ */
44
+ sendHtml(html) {
45
+ return this.sendMessage(html, { parseMode: 'HTML' });
46
+ }
47
+
48
+ /**
49
+ * MarkdownV2 파싱을 지원하는 전송 헬퍼 메서드
50
+ * @param {string} markdown
51
+ */
52
+ sendMarkdown(markdown) {
53
+ return this.sendMessage(markdown, { parseMode: 'MarkdownV2' });
54
+ }
55
+ }
56
+
57
+ /**
58
+ * TelegramManager - yaml 설정에 등록된 복수 개의 텔레그램 봇 인스턴스를 관리합니다.
59
+ * 프레임워크 코어 컨테이너에 의해 싱글톤으로 유지됩니다.
60
+ */
61
+ export default class TelegramManager {
62
+ /**
63
+ * @param {import('../core/Application.js').default} app
64
+ * @param {Object} config fuzionx.yaml 의 app.telegram 설정 객체
65
+ */
66
+ constructor(app, config) {
67
+ this.app = app;
68
+ this.config = config || {};
69
+ this._bots = new Map();
70
+
71
+ this._initialize();
72
+ }
73
+
74
+ /**
75
+ * 큐에 환경을 구성하고 봇 목록을 구성합니다.
76
+ * @private
77
+ */
78
+ _initialize() {
79
+ // 백그라운드 워커에 텔레그램 전송 작업을 담당할 클래스 명세 등록
80
+ if (this.app._queue) {
81
+ this.app._queue.register('TelegramTask', TelegramTask);
82
+ }
83
+
84
+ // yaml 설정 파일 기반 봇 초기화
85
+ const botsConfig = this.config.bots || {};
86
+ for (const [name, info] of Object.entries(botsConfig)) {
87
+ if (info.token && info.chat_id) {
88
+ // 숫자로 된 채널 ID도 문자열로 변환하여 보존 처리
89
+ const chatIdStr = String(info.chat_id);
90
+ this._bots.set(name, new TelegramBot(this.app, info.token, chatIdStr));
91
+ } else {
92
+ this.app.logger.warn(`[TelegramManager] '${name}' 봇 설정 오류: token 또는 chat_id가 확인되지 않습니다.`);
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 등록된 특정 이름의 봇 인스턴스를 가져옵니다.
99
+ * @param {string} name 봇의 고유 명칭 (예: 'system', 'billing')
100
+ * @returns {TelegramBot|null} 봇 인스턴스 또는 null
101
+ */
102
+ get(name) {
103
+ return this._bots.get(name) || null;
104
+ }
105
+
106
+ /**
107
+ * 원하는 대상 봇에게 체인 없이 한 번에 직접 메시지를 보냅니다.
108
+ * @param {string} name 발송할 봇 명칭
109
+ * @param {string} text 메시지 내용
110
+ * @param {Object} [options] 파싱 모드 등 옵션 (get(name) 이후 sendMessage 와 동일)
111
+ */
112
+ send(name, text, options) {
113
+ const bot = this.get(name);
114
+ if (bot) {
115
+ bot.sendMessage(text, options);
116
+ } else {
117
+ this.app.logger.warn(`[TelegramManager] 설정 파일에 명명된 봇 '${name}'을 찾을 수 없습니다.`);
118
+ }
119
+ }
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "type": "module",
5
5
  "description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
6
6
  "main": "index.js",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@aws-sdk/client-s3": "^3.1028.0",
38
- "@fuzionx/core": "^0.1.70",
38
+ "@fuzionx/core": "^0.1.72",
39
39
  "better-sqlite3": "^12.8.0",
40
40
  "knex": "^3.2.5",
41
41
  "mongoose": "^9.3.2",