@deadragdoll/tellymcp 0.0.5 → 0.0.7
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/.env.example.client +2 -0
- package/.env.example.gateway +2 -1
- package/README-ru.md +159 -6
- package/README.md +148 -88
- package/dist/cli.js +1 -1
- package/dist/lib/mixins/session.errors.js +15 -11
- package/dist/lib/pinoTargets.js +29 -0
- package/dist/moleculer.config.js +20 -66
- package/dist/services/features/telegram-mcp/src/app/config/env.js +12 -1
- package/dist/services/features/telegram-mcp/src/app/http.js +1 -1
- package/dist/services/features/telegram-mcp/src/app/providers/mcp/server.js +2 -2
- package/dist/services/features/telegram-mcp/src/features/distributed-gateway/model/gatewayHttpService.js +1 -1
- package/dist/services/features/telegram-mcp/src/shared/lib/logger/logger.js +7 -26
- package/package.json +2 -4
- package/dist/lib/middlewares/tracer.js +0 -172
- package/dist/lib/trace.js +0 -147
- package/dist/lib/traceContext.js +0 -116
package/.env.example.client
CHANGED
package/.env.example.gateway
CHANGED
package/README-ru.md
CHANGED
|
@@ -7,16 +7,73 @@
|
|
|
7
7
|
[](https://nodejs.org/)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
|
-
TellyMCP — это Telegram
|
|
10
|
+
TellyMCP — это self-hosted Telegram control plane для coding agents.
|
|
11
11
|
|
|
12
|
-
Он
|
|
12
|
+
Он привязывает реальные agent-сессии к Telegram, делает их доступными с телефона и даёт им работать вместе между локальными и удалёнными машинами.
|
|
13
|
+
|
|
14
|
+
Он не завязан на одного вендора или один coding assistant. Если агент умеет работать с MCP server, он может использовать TellyMCP.
|
|
15
|
+
|
|
16
|
+
## Зачем он нужен
|
|
17
|
+
|
|
18
|
+
Coding agents полезны ровно до того момента, пока они не остаются одни в терминале:
|
|
19
|
+
|
|
20
|
+
- им нужно уточнение, пока тебя нет за компьютером
|
|
21
|
+
- им нужен approval перед рискованным действием
|
|
22
|
+
- им нужно передать скриншот, файл или note между сессиями
|
|
23
|
+
- им нужно быстро подключить человека или другого агента, не ломая общий workflow
|
|
24
|
+
|
|
25
|
+
TellyMCP даёт каждой сессии мобильную панель управления и collaboration layer:
|
|
26
|
+
|
|
27
|
+
- `Live` tmux view и лёгкое управление из Telegram
|
|
28
|
+
- session-scoped inbox и уведомления
|
|
29
|
+
- workspace-aware handoff для файлов и note
|
|
30
|
+
- локальную и удалённую коллаборацию между сессиями
|
|
31
|
+
- поддержку mixed agent setups, если они умеют говорить по MCP
|
|
32
|
+
|
|
33
|
+
## Ключевые идеи продукта
|
|
34
|
+
|
|
35
|
+
- `Live` tmux view и управление внутри Telegram Mini App
|
|
36
|
+
- `Collab`-сценарии для локальных и удалённых agent-сессий
|
|
37
|
+
- `.mcp-xchange` как workspace-level handoff шина для note, файлов и скриншотов
|
|
38
|
+
- MCP-native pairing сессий и session-scoped tools
|
|
39
|
+
- optional gateway mode для multi-machine и multi-bot проектов
|
|
40
|
+
|
|
41
|
+
## Human-in-the-loop — это только один слой системы
|
|
42
|
+
|
|
43
|
+
Telegram HITL здесь тоже есть, но он не исчерпывает продукт:
|
|
13
44
|
|
|
14
45
|
- задавать человеку уточняющие вопросы через Telegram
|
|
15
46
|
- получать несвязанные входящие сообщения позже через inbox
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
47
|
+
- уведомлять человека о прогрессе, blockers и approvals
|
|
48
|
+
|
|
49
|
+
## Что отличает TellyMCP от простого Telegram bot bridge
|
|
50
|
+
|
|
51
|
+
- он завязан на сессии, а не только на чат
|
|
52
|
+
- он понимает локальные и удалённые collaboration targets
|
|
53
|
+
- у него есть live terminal surface, а не только обмен сообщениями
|
|
54
|
+
- он передаёт файлы через workspace-aware exchange paths, а не просто через ad hoc upload
|
|
55
|
+
- он может работать как standalone node или как gateway-backed control plane
|
|
56
|
+
|
|
57
|
+
## Типовые сценарии
|
|
58
|
+
|
|
59
|
+
- держать долгоживущего агента доступным с телефона
|
|
60
|
+
- запускать рядом разных агентов, если каждый умеет подключаться по MCP
|
|
61
|
+
- подруливать tmux-сессией без ноутбука
|
|
62
|
+
- маршрутизировать работу между `frontend`, `backend`, `review` и другими локальными сессиями
|
|
63
|
+
- работать с удалёнными сессиями через gateway-backed project
|
|
64
|
+
- передавать note, скриншоты и реальные файлы через `.mcp-xchange`
|
|
65
|
+
- проверять локальный веб-интерфейс через `browser_*` tools и отправлять результат обратно в Telegram
|
|
66
|
+
|
|
67
|
+
## Группы инструментов
|
|
68
|
+
|
|
69
|
+
- pairing и session context
|
|
70
|
+
- Telegram ask/notify/inbox
|
|
71
|
+
- `Live` tmux control
|
|
72
|
+
- browser inspection и screenshots
|
|
73
|
+
- partner notes и partner files
|
|
74
|
+
- tools sync и version checks
|
|
75
|
+
|
|
76
|
+
Полный список MCP tools лучше держать ниже по README и в самом MCP server, а не на первом экране.
|
|
20
77
|
|
|
21
78
|
## Prerequisites
|
|
22
79
|
|
|
@@ -172,6 +229,99 @@ tellymcp run --env .env
|
|
|
172
229
|
|
|
173
230
|
- `https://your-host.example/api/mcp`
|
|
174
231
|
|
|
232
|
+
## Docker: только для инфраструктуры или для `gateway`-only
|
|
233
|
+
|
|
234
|
+
Docker больше не является основным способом запуска TellyMCP, но один container path поддерживается:
|
|
235
|
+
|
|
236
|
+
- запуск только `gateway`-ноды в контейнере
|
|
237
|
+
|
|
238
|
+
Это вариант именно для чистого control-plane узла:
|
|
239
|
+
|
|
240
|
+
- без локальных agent sessions
|
|
241
|
+
- без локального `tmux`
|
|
242
|
+
- не для `client`
|
|
243
|
+
- не для `both`
|
|
244
|
+
|
|
245
|
+
Плюс `docker-compose.yml` по-прежнему может поднимать локальную инфраструктуру:
|
|
246
|
+
|
|
247
|
+
- `redis` для всех режимов
|
|
248
|
+
- `postgres` для `gateway` / `both`
|
|
249
|
+
- `rabbitmq` только если нужен durable fanout на шлюзе
|
|
250
|
+
|
|
251
|
+
Только Redis, для `standalone` или `client`:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
docker compose up -d redis
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Redis + Postgres, для `gateway` или `both`:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
docker compose --profile gateway up -d
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Добавить RabbitMQ при необходимости:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
docker compose --profile gateway --profile rmq up -d
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Полный gateway stack в Docker:
|
|
270
|
+
|
|
271
|
+
1. Скопировать пример:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
cp .env.example.gateway .env-gateway
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
2. Отредактировать `.env-gateway` и задать минимум:
|
|
278
|
+
|
|
279
|
+
- `TELEGRAM_BOT_TOKEN`
|
|
280
|
+
- `TELEGRAM_BOT_USERNAME`
|
|
281
|
+
- `WEBAPP_PUBLIC_URL`
|
|
282
|
+
- `GATEWAY_PUBLIC_URL`
|
|
283
|
+
- `GATEWAY_WS_URL`
|
|
284
|
+
- `MCP_HTTP_BEARER_TOKEN`
|
|
285
|
+
|
|
286
|
+
3. Запустить:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
docker compose up -d
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Это поднимет:
|
|
293
|
+
|
|
294
|
+
- `redis`
|
|
295
|
+
- `postgres`
|
|
296
|
+
- `tellymcp-gateway`
|
|
297
|
+
|
|
298
|
+
Внутри Docker compose сам переопределяет:
|
|
299
|
+
|
|
300
|
+
- `MCP_HTTP_HOST=0.0.0.0`
|
|
301
|
+
- `REDIS_HOST=redis`
|
|
302
|
+
- `DB_HOST=postgres`
|
|
303
|
+
|
|
304
|
+
Ожидаемые endpoint'ы:
|
|
305
|
+
|
|
306
|
+
- `http://127.0.0.1:8080/api/healthz`
|
|
307
|
+
- `http://127.0.0.1:8080/api/mcp`
|
|
308
|
+
- `http://127.0.0.1:8080/api/webapp`
|
|
309
|
+
- `http://127.0.0.1:8080/api/gateway`
|
|
310
|
+
|
|
311
|
+
Остановить всё:
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
docker compose down
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Сам TellyMCP при этом запускается напрямую на хосте:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
tellymcp run --env .env
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Для `client` и `both` по-прежнему рекомендуется именно хостовый запуск.
|
|
324
|
+
|
|
175
325
|
## Как начать работу с ботом изнутри агента
|
|
176
326
|
|
|
177
327
|
После подключения MCP можно просто написать агенту обычной фразой, что нужно привязаться к Telegram.
|
|
@@ -275,6 +425,9 @@ tellymcp mcp --help
|
|
|
275
425
|
- `PROXY_USE=http|socks5`
|
|
276
426
|
- `HTTP_PROXY`
|
|
277
427
|
- `SOCKS5_PROXY`
|
|
428
|
+
- `LOG_LEVEL`
|
|
429
|
+
- `LOG_FILE_ENABLED`
|
|
430
|
+
- `LOG_FILE_PATH`
|
|
278
431
|
|
|
279
432
|
Только client:
|
|
280
433
|
|
package/README.md
CHANGED
|
@@ -7,50 +7,73 @@
|
|
|
7
7
|
[](https://nodejs.org/)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
|
-
TellyMCP is a Telegram
|
|
10
|
+
TellyMCP is a self-hosted Telegram control plane for coding agents.
|
|
11
11
|
|
|
12
|
-
It lets
|
|
12
|
+
It pairs real agent sessions with Telegram, keeps them reachable from mobile, and lets them collaborate across local and remote machines.
|
|
13
|
+
|
|
14
|
+
It is not tied to one vendor or one coding assistant. If your agent can talk to an MCP server, it can use TellyMCP.
|
|
15
|
+
|
|
16
|
+
## Why it exists
|
|
17
|
+
|
|
18
|
+
Coding agents are useful until they leave the terminal:
|
|
19
|
+
|
|
20
|
+
- they need clarification while you are away from the desk
|
|
21
|
+
- they need approval before doing something risky
|
|
22
|
+
- they need screenshots, files, or notes passed between sessions
|
|
23
|
+
- they need a human or another agent to unblock work without breaking flow
|
|
24
|
+
|
|
25
|
+
TellyMCP gives each session a mobile control surface and a collaboration layer:
|
|
26
|
+
|
|
27
|
+
- `Live` tmux view and light control from Telegram
|
|
28
|
+
- session-scoped inbox and notifications
|
|
29
|
+
- workspace-aware file and note handoffs
|
|
30
|
+
- local and remote session collaboration
|
|
31
|
+
- support for mixed agent setups, as long as they speak MCP
|
|
32
|
+
|
|
33
|
+
## Core ideas
|
|
34
|
+
|
|
35
|
+
- `Live` tmux view and control inside Telegram Mini App
|
|
36
|
+
- `Collab` flows for local and remote agent sessions
|
|
37
|
+
- `.mcp-xchange` as a workspace-level handoff bus for notes, files, and screenshots
|
|
38
|
+
- MCP-native session pairing and session-scoped tools
|
|
39
|
+
- optional gateway mode for cross-machine and cross-bot projects
|
|
40
|
+
|
|
41
|
+
## Human-in-the-loop is one layer, not the whole product
|
|
42
|
+
|
|
43
|
+
Telegram HITL is still supported, but it is not the whole story:
|
|
13
44
|
|
|
14
45
|
- ask a human for clarification through Telegram
|
|
15
46
|
- receive unsolicited Telegram messages later through an inbox
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
- `
|
|
32
|
-
-
|
|
33
|
-
- `
|
|
34
|
-
- `
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
- `
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
- `browser_clear_logs`
|
|
47
|
-
- `browser_dom`
|
|
48
|
-
- `browser_computed_style`
|
|
49
|
-
- `browser_screenshot`
|
|
50
|
-
- `browser_close`
|
|
51
|
-
- `refresh_tools_markdown`
|
|
52
|
-
- `send_partner_note`
|
|
53
|
-
- `send_partner_file`
|
|
47
|
+
- notify a human about progress, blockers, and approvals
|
|
48
|
+
|
|
49
|
+
## What makes it different from a simple Telegram bot bridge
|
|
50
|
+
|
|
51
|
+
- it is session-based, not just chat-based
|
|
52
|
+
- it understands local and remote collaboration targets
|
|
53
|
+
- it has a live terminal surface, not only message exchange
|
|
54
|
+
- it moves files through workspace-aware exchange paths, not just ad hoc uploads
|
|
55
|
+
- it can run as a standalone node or as a gateway-backed control plane
|
|
56
|
+
|
|
57
|
+
## Typical use cases
|
|
58
|
+
|
|
59
|
+
- keep a long-running agent reachable from your phone
|
|
60
|
+
- run different agents side by side, as long as each one can connect over MCP
|
|
61
|
+
- steer a tmux-based session without opening a laptop
|
|
62
|
+
- route work between `frontend`, `backend`, `review`, or other local sessions
|
|
63
|
+
- collaborate with remote sessions through a gateway-backed project
|
|
64
|
+
- send notes, screenshots, and real files through `.mcp-xchange`
|
|
65
|
+
- inspect or screenshot a local web app with `browser_*` tools and send results back to Telegram
|
|
66
|
+
|
|
67
|
+
## Tool groups
|
|
68
|
+
|
|
69
|
+
- session pairing and context
|
|
70
|
+
- Telegram ask/notify/inbox
|
|
71
|
+
- `Live` tmux control
|
|
72
|
+
- browser inspection and screenshots
|
|
73
|
+
- partner notes and partner files
|
|
74
|
+
- tools sync and version checks
|
|
75
|
+
|
|
76
|
+
The full MCP tool surface is documented later in this README and through the MCP server itself.
|
|
54
77
|
|
|
55
78
|
## Prerequisites
|
|
56
79
|
|
|
@@ -389,10 +412,11 @@ Canonical instructions:
|
|
|
389
412
|
- the `TOOLS.md` version marker
|
|
390
413
|
- the file content itself
|
|
391
414
|
|
|
392
|
-
Logs
|
|
415
|
+
Logs use one runtime model:
|
|
393
416
|
|
|
394
|
-
- pretty console output to `stderr`
|
|
395
|
-
- JSONL file
|
|
417
|
+
- `pino-pretty` console output to `stderr`
|
|
418
|
+
- optional JSONL file sink via `LOG_FILE_ENABLED=true` and `LOG_FILE_PATH=...`
|
|
419
|
+
- optional in-app `LogFeed` buffer for Telegram/UI diagnostics when `ENABLE_LOGFEED=1`
|
|
396
420
|
|
|
397
421
|
If Telegram access requires a proxy, the bot transport can use:
|
|
398
422
|
|
|
@@ -529,10 +553,10 @@ Recommended local dev settings:
|
|
|
529
553
|
- install browser binaries once with `npx playwright install chromium`
|
|
530
554
|
- install browser binaries once with `tellymcp browser install`
|
|
531
555
|
|
|
532
|
-
Recommended
|
|
556
|
+
Recommended headless server settings:
|
|
533
557
|
|
|
534
558
|
- `BROWSER_HEADLESS=true`
|
|
535
|
-
- target the host
|
|
559
|
+
- target the app through a reachable host or LAN address, for example `http://127.0.0.1:3000` or `http://192.168.x.x:3000`
|
|
536
560
|
|
|
537
561
|
Current browser tools:
|
|
538
562
|
|
|
@@ -806,75 +830,111 @@ If `MCP_HTTP_BEARER_TOKEN` is configured:
|
|
|
806
830
|
`yarn dev:gw:telegram` is still available, but it only starts the `telegram_mcp` feature node.
|
|
807
831
|
It does not expose HTTP by itself anymore. `/mcp`, `/webapp`, and `/healthz` are now served only through the Moleculer API gateway aliases in the full `dev:gw` / `start:gw` runtime, or through a separate gateway node in the same namespace.
|
|
808
832
|
|
|
809
|
-
## Optional Docker
|
|
833
|
+
## Optional Docker infrastructure
|
|
834
|
+
|
|
835
|
+
Docker is no longer the default way to run TellyMCP, but there is one supported container path:
|
|
810
836
|
|
|
811
|
-
|
|
837
|
+
- `gateway`-only container deployment
|
|
812
838
|
|
|
813
|
-
This
|
|
839
|
+
This is intended for a pure control-plane node:
|
|
814
840
|
|
|
815
|
-
|
|
841
|
+
- no local agent sessions
|
|
842
|
+
- no local `tmux`
|
|
843
|
+
- no `client` mode
|
|
844
|
+
- no `both` mode
|
|
816
845
|
|
|
817
|
-
|
|
818
|
-
- `redis-server` runs on `127.0.0.1:6379`
|
|
819
|
-
- the application itself serves:
|
|
820
|
-
- `/mcp`
|
|
821
|
-
- `/webapp`
|
|
822
|
-
- `/healthz`
|
|
823
|
-
- `/sessions`
|
|
824
|
-
- `/prune`
|
|
846
|
+
The repository also keeps Docker for local infrastructure:
|
|
825
847
|
|
|
826
|
-
|
|
848
|
+
- `redis` for all modes
|
|
849
|
+
- `postgres` for `gateway` / `both`
|
|
850
|
+
- `rabbitmq` only if you want durable fanout on the gateway
|
|
827
851
|
|
|
828
|
-
|
|
852
|
+
Start Redis only, for `standalone` or `client` mode:
|
|
829
853
|
|
|
830
854
|
```bash
|
|
831
|
-
docker compose
|
|
855
|
+
docker compose up -d redis
|
|
832
856
|
```
|
|
833
857
|
|
|
834
|
-
|
|
858
|
+
Start Redis + Postgres, for `gateway` or `both` mode:
|
|
835
859
|
|
|
836
860
|
```bash
|
|
837
|
-
docker compose up -d
|
|
861
|
+
docker compose --profile gateway up -d
|
|
838
862
|
```
|
|
839
863
|
|
|
840
|
-
|
|
864
|
+
Add RabbitMQ only when you need it:
|
|
841
865
|
|
|
842
866
|
```bash
|
|
843
|
-
docker compose
|
|
867
|
+
docker compose --profile gateway --profile rmq up -d
|
|
844
868
|
```
|
|
845
869
|
|
|
846
|
-
|
|
870
|
+
Run a full gateway container stack with Redis and Postgres:
|
|
847
871
|
|
|
848
|
-
|
|
849
|
-
- injects `.env`
|
|
850
|
-
- overrides runtime networking so the app talks to local in-container Redis and listens on `0.0.0.0:8787`
|
|
851
|
-
- publishes only `8787:8787`
|
|
852
|
-
- keeps `host.docker.internal` available for optional host-side development integrations
|
|
853
|
-
- persists Redis state in `./data/redis`
|
|
872
|
+
1. Copy the example:
|
|
854
873
|
|
|
855
|
-
|
|
874
|
+
```bash
|
|
875
|
+
cp .env.example.gateway .env-gateway
|
|
876
|
+
```
|
|
856
877
|
|
|
857
|
-
-
|
|
858
|
-
- Mini App static/API routes are reachable under `http://<host>:8787/webapp/`
|
|
859
|
-
- health check is at `http://<host>:8787/healthz`
|
|
878
|
+
2. Edit `.env-gateway` and set at minimum:
|
|
860
879
|
|
|
861
|
-
|
|
880
|
+
- `TELEGRAM_BOT_TOKEN`
|
|
881
|
+
- `TELEGRAM_BOT_USERNAME`
|
|
882
|
+
- `WEBAPP_PUBLIC_URL`
|
|
883
|
+
- `GATEWAY_PUBLIC_URL`
|
|
884
|
+
- `GATEWAY_WS_URL`
|
|
885
|
+
- `MCP_HTTP_BEARER_TOKEN`
|
|
862
886
|
|
|
863
|
-
|
|
864
|
-
- external proxy forwards `/webapp/` to `http://<container-host>:8787/webapp/`
|
|
865
|
-
- or, if you prefer, the external proxy can forward a wider prefix directly to `http://<container-host>:8787`
|
|
866
|
-
- no direct external access is needed to in-container Redis
|
|
867
|
-
- `tmux` access is expected to be direct from the running `tellymcp` process
|
|
887
|
+
3. Start the stack:
|
|
868
888
|
|
|
869
|
-
|
|
889
|
+
```bash
|
|
890
|
+
docker compose up -d
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
This starts:
|
|
894
|
+
|
|
895
|
+
- `redis`
|
|
896
|
+
- `postgres`
|
|
897
|
+
- `tellymcp-gateway`
|
|
898
|
+
|
|
899
|
+
Inside Docker, compose overrides:
|
|
900
|
+
|
|
901
|
+
- `MCP_HTTP_HOST=0.0.0.0`
|
|
902
|
+
- `REDIS_HOST=redis`
|
|
903
|
+
- `DB_HOST=postgres`
|
|
904
|
+
|
|
905
|
+
Public endpoint expectations stay the same:
|
|
906
|
+
|
|
907
|
+
- `http://127.0.0.1:8080/api/healthz`
|
|
908
|
+
- `http://127.0.0.1:8080/api/mcp`
|
|
909
|
+
- `http://127.0.0.1:8080/api/webapp`
|
|
910
|
+
- `http://127.0.0.1:8080/api/gateway`
|
|
911
|
+
|
|
912
|
+
Stop everything:
|
|
913
|
+
|
|
914
|
+
```bash
|
|
915
|
+
docker compose down
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
Default published ports:
|
|
919
|
+
|
|
920
|
+
- Redis: `6379`
|
|
921
|
+
- Postgres: `5432`
|
|
922
|
+
- RabbitMQ AMQP: `5672`
|
|
923
|
+
- RabbitMQ UI: `15672`
|
|
924
|
+
|
|
925
|
+
The TellyMCP process itself should run directly on the host:
|
|
926
|
+
|
|
927
|
+
```bash
|
|
928
|
+
tellymcp run --env .env
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
This keeps:
|
|
870
932
|
|
|
871
|
-
-
|
|
872
|
-
-
|
|
873
|
-
-
|
|
874
|
-
- menu payload buffers
|
|
875
|
-
- WebApp launch/session state
|
|
933
|
+
- direct `tmux` access
|
|
934
|
+
- simpler debugging
|
|
935
|
+
- the same runtime model for `standalone`, `client`, `gateway`, and `both`
|
|
876
936
|
|
|
877
|
-
|
|
937
|
+
For `client` and `both`, host execution is still the recommended model.
|
|
878
938
|
|
|
879
939
|
Optional if the local tmux server uses a non-default socket:
|
|
880
940
|
|
package/dist/cli.js
CHANGED
|
@@ -82,7 +82,7 @@ async function getPlaywrightBrowserStatus(browserEnabled) {
|
|
|
82
82
|
}
|
|
83
83
|
function printHelp() {
|
|
84
84
|
const tmux = getTmuxStatus();
|
|
85
|
-
printBanner("CLI", "Telegram
|
|
85
|
+
printBanner("CLI", "Telegram control plane for MCP-connected coding agents");
|
|
86
86
|
printSection("Usage", [
|
|
87
87
|
" tellymcp init <client|gateway|both> [directory]",
|
|
88
88
|
" tellymcp run [--env <file>]",
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PlaygroundDisabledError = exports.SessionIdleTimeoutExceededError = exports.TokenInvalidError = exports.TokenNotFoundError = exports.SessionTokenSignatureInvalidError = exports.SessionTokenInvalidFormatError = exports.SessionTokenMismatchError = exports.SessionTokenNotFoundError = exports.SessionTokenRevokedError = exports.SessionTokenNotActiveError = exports.SessionTokenExpiredError = exports.SessionTokenInvalidError = exports.SessionValidationError = exports.SessionRefreshError = exports.SessionMaxLifetimeExceededError = exports.SessionStealedError = exports.SessionInvalidError = exports.SessionExpiredError = exports.SessionNotFoundError = exports.ForbiddenError = exports.UnauthorizedError = exports.BackendError = void 0;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
exports.PlaygroundDisabledError = exports.SessionIdleTimeoutExceededError = exports.TokenInvalidError = exports.TokenNotFoundError = exports.SessionTokenSignatureInvalidError = exports.SessionTokenInvalidFormatError = exports.SessionTokenMismatchError = exports.SessionTokenNotFoundError = exports.SessionTokenRevokedError = exports.SessionTokenNotActiveError = exports.SessionTokenExpiredError = exports.SessionTokenInvalidError = exports.SessionValidationError = exports.SessionRefreshError = exports.SessionMaxLifetimeExceededError = exports.SessionStealedError = exports.SessionInvalidError = exports.SessionExpiredError = exports.SessionNotFoundError = exports.ForbiddenError = exports.UnauthorizedError = exports.wrapUnhandledBackendError = exports.buildUnhandledBackendErrorCode = exports.BackendError = void 0;
|
|
4
|
+
class BackendError extends Error {
|
|
5
|
+
statusCode;
|
|
6
|
+
code;
|
|
7
|
+
data;
|
|
8
|
+
constructor(message, statusCode = 500, code, data) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "BackendError";
|
|
11
|
+
this.statusCode = statusCode;
|
|
12
|
+
this.code = code || String(statusCode);
|
|
13
|
+
this.data = data;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
exports.BackendError = BackendError;
|
|
17
|
+
const buildUnhandledBackendErrorCode = (rawName) => `XC_${rawName.toUpperCase()}`;
|
|
18
|
+
exports.buildUnhandledBackendErrorCode = buildUnhandledBackendErrorCode;
|
|
19
|
+
const wrapUnhandledBackendError = (err, rawName) => new BackendError(err.message, 502, (0, exports.buildUnhandledBackendErrorCode)(rawName));
|
|
20
|
+
exports.wrapUnhandledBackendError = wrapUnhandledBackendError;
|
|
17
21
|
class UnauthorizedError extends BackendError {
|
|
18
22
|
constructor(message = "Unauthorized", data) {
|
|
19
23
|
super(message, 401, "UNAUTHORIZED", data);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPinoTargets = createPinoTargets;
|
|
4
|
+
function createPinoTargets(config) {
|
|
5
|
+
const targets = [
|
|
6
|
+
{
|
|
7
|
+
target: "pino-pretty",
|
|
8
|
+
level: config.level,
|
|
9
|
+
options: {
|
|
10
|
+
destination: 2,
|
|
11
|
+
colorize: true,
|
|
12
|
+
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
|
|
13
|
+
ignore: "pid,hostname",
|
|
14
|
+
singleLine: false,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
if (config.fileEnabled) {
|
|
19
|
+
targets.unshift({
|
|
20
|
+
target: "pino/file",
|
|
21
|
+
level: config.level,
|
|
22
|
+
options: {
|
|
23
|
+
destination: config.filePath,
|
|
24
|
+
mkdir: true,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return targets;
|
|
29
|
+
}
|
package/dist/moleculer.config.js
CHANGED
|
@@ -8,11 +8,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
8
8
|
const moleculer_1 = require("moleculer");
|
|
9
9
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
10
10
|
require("module-alias/register");
|
|
11
|
-
const
|
|
12
|
-
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const pino_1 = __importDefault(require("pino"));
|
|
13
12
|
const logfeed_1 = require("./lib/mixins/logfeed");
|
|
14
13
|
const session_errors_1 = require("./lib/mixins/session.errors");
|
|
15
|
-
const
|
|
14
|
+
const pinoTargets_1 = require("./lib/pinoTargets");
|
|
16
15
|
dotenv_1.default.config({ path: process.env.ENV_FILE ?? ".env" });
|
|
17
16
|
/**
|
|
18
17
|
* Moleculer ServiceBroker configuration file 1
|
|
@@ -40,72 +39,34 @@ dotenv_1.default.config({ path: process.env.ENV_FILE ?? ".env" });
|
|
|
40
39
|
* }
|
|
41
40
|
*/
|
|
42
41
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
42
|
+
const pinoTransport = pino_1.default.transport({
|
|
43
|
+
targets: (0, pinoTargets_1.createPinoTargets)({
|
|
44
|
+
level: process.env.LOG_LEVEL || "info",
|
|
45
|
+
fileEnabled: process.env.LOG_FILE_ENABLED === "true",
|
|
46
|
+
filePath: process.env.LOG_FILE_PATH || ".tellymcp/log.jsonl",
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
43
49
|
const logger = [
|
|
44
50
|
{
|
|
45
|
-
type: "
|
|
51
|
+
type: "Pino",
|
|
46
52
|
options: {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
pino: {
|
|
54
|
+
options: {
|
|
55
|
+
name: "tellymcp-broker",
|
|
56
|
+
level: process.env.LOG_LEVEL || "info",
|
|
57
|
+
timestamp: pino_1.default.stdTimeFunctions.isoTime,
|
|
58
|
+
},
|
|
59
|
+
destination: pinoTransport,
|
|
60
|
+
},
|
|
52
61
|
},
|
|
53
62
|
},
|
|
54
63
|
];
|
|
55
64
|
const metricsEnabled = process.env.MOLECULER_METRICS === "true";
|
|
56
65
|
const metricsPort = +(process.env.METRICS_PORT || 3030);
|
|
57
66
|
const metricsPath = process.env.METRICS_PATH || "/metrics";
|
|
58
|
-
const logFileEnabled = process.env.LOG_FILE_ENABLED === "true";
|
|
59
67
|
const logFeedEnabled = process.env.ENABLE_LOGFEED != null
|
|
60
68
|
? !["0", "false", "no", "off"].includes(process.env.ENABLE_LOGFEED.toLowerCase())
|
|
61
69
|
: process.env.LOGFEED_ENABLED !== "false";
|
|
62
|
-
const logFileFolder = process.env.LOG_FILE_FOLDER || "./logs";
|
|
63
|
-
const logFileName = process.env.LOG_FILE_NAME || "moleculer-{date}.log";
|
|
64
|
-
const logFileRetentionDays = +(process.env.LOG_FILE_RETENTION_DAYS || 14);
|
|
65
|
-
const cleanupLogFiles = async () => {
|
|
66
|
-
if (!logFileEnabled || !Number.isFinite(logFileRetentionDays) || logFileRetentionDays <= 0) {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const logFolder = node_path_1.default.resolve(logFileFolder);
|
|
70
|
-
const now = Date.now();
|
|
71
|
-
const maxAgeMs = logFileRetentionDays * 24 * 60 * 60 * 1000;
|
|
72
|
-
const filenamePattern = new RegExp(`^${logFileName
|
|
73
|
-
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
74
|
-
.replace(/\\\{date\\\}/g, "\\d{4}-\\d{2}-\\d{2}")
|
|
75
|
-
.replace(/\\\{nodeID\\\}/g, "[^/]+")
|
|
76
|
-
.replace(/\\\{namespace\\\}/g, "[^/]+")}$`);
|
|
77
|
-
try {
|
|
78
|
-
const entries = await promises_1.default.readdir(logFolder, { withFileTypes: true });
|
|
79
|
-
await Promise.all(entries
|
|
80
|
-
.filter(entry => entry.isFile() && filenamePattern.test(entry.name))
|
|
81
|
-
.map(async (entry) => {
|
|
82
|
-
const filePath = node_path_1.default.join(logFolder, entry.name);
|
|
83
|
-
const stats = await promises_1.default.stat(filePath);
|
|
84
|
-
if (now - stats.mtimeMs > maxAgeMs) {
|
|
85
|
-
await promises_1.default.unlink(filePath);
|
|
86
|
-
}
|
|
87
|
-
}));
|
|
88
|
-
}
|
|
89
|
-
catch (error) {
|
|
90
|
-
if (error.code !== "ENOENT") {
|
|
91
|
-
console.warn("LOG_FILE cleanup failed:", error);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
if (logFileEnabled) {
|
|
96
|
-
void cleanupLogFiles();
|
|
97
|
-
logger.push({
|
|
98
|
-
type: "File",
|
|
99
|
-
options: {
|
|
100
|
-
level: process.env.LOG_FILE_LEVEL || process.env.LOG_LEVEL || "info",
|
|
101
|
-
folder: logFileFolder,
|
|
102
|
-
filename: logFileName,
|
|
103
|
-
formatter: process.env.LOG_FILE_FORMATTER || "json",
|
|
104
|
-
eol: "\n",
|
|
105
|
-
interval: +(process.env.LOG_FILE_INTERVAL_MS || 1000),
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
70
|
if (logFeedEnabled) {
|
|
110
71
|
logger.push(new logfeed_1.LogFeedLogger({
|
|
111
72
|
level: process.env.LOGFEED_LEVEL || process.env.LOG_LEVEL || "info",
|
|
@@ -206,18 +167,15 @@ const brokerConfig = {
|
|
|
206
167
|
// Enable action & event parameter validation. More info: https://moleculer.services/docs/0.14/validating.html
|
|
207
168
|
validator: true,
|
|
208
169
|
errorHandler: (err, { ctx, service }) => {
|
|
209
|
-
// ctx.service.logger.error("errorHandler", err);
|
|
210
170
|
if (err instanceof session_errors_1.BackendError) {
|
|
211
|
-
// ctx.meta.$statusCode = err.extensions.code;
|
|
212
171
|
return err;
|
|
213
172
|
}
|
|
214
173
|
else if (err instanceof Error) {
|
|
215
|
-
// ctx.meta.$statusCode = 502;
|
|
216
174
|
const rawName = ctx?.action?.rawName ||
|
|
217
175
|
ctx?.action?.name ||
|
|
218
176
|
service?.name ||
|
|
219
177
|
"UNKNOWN";
|
|
220
|
-
return
|
|
178
|
+
return (0, session_errors_1.wrapUnhandledBackendError)(err, String(rawName));
|
|
221
179
|
}
|
|
222
180
|
return err;
|
|
223
181
|
},
|
|
@@ -259,15 +217,11 @@ const brokerConfig = {
|
|
|
259
217
|
},
|
|
260
218
|
},
|
|
261
219
|
// Register custom middlewares
|
|
262
|
-
middlewares: [
|
|
220
|
+
middlewares: [],
|
|
263
221
|
// Register custom REPL commands.
|
|
264
222
|
replCommands: null,
|
|
265
223
|
// Called after broker created.
|
|
266
224
|
// created(broker: ServiceBroker): void {},
|
|
267
|
-
// Called after broker started.
|
|
268
|
-
started() {
|
|
269
|
-
console.log("ALLOWED ORIGINS", (process.env.ORIGINS ?? "").split(","));
|
|
270
|
-
},
|
|
271
225
|
// Called after broker stopped.
|
|
272
226
|
// async stopped(broker: ServiceBroker): Promise<void> {},
|
|
273
227
|
};
|
|
@@ -165,9 +165,18 @@ const envSchema = z.object({
|
|
|
165
165
|
LOG_LEVEL: z
|
|
166
166
|
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
|
|
167
167
|
.default("info"),
|
|
168
|
+
LOG_FILE_ENABLED: z
|
|
169
|
+
.string()
|
|
170
|
+
.optional()
|
|
171
|
+
.transform((value) => value === "true"),
|
|
172
|
+
LOG_FILE_PATH: z.string().min(1).default(".tellymcp/log.jsonl"),
|
|
168
173
|
});
|
|
169
174
|
function loadConfig() {
|
|
170
|
-
|
|
175
|
+
const explicitEnvFile = process.env.ENV_FILE?.trim();
|
|
176
|
+
if (explicitEnvFile) {
|
|
177
|
+
process.loadEnvFile(explicitEnvFile);
|
|
178
|
+
}
|
|
179
|
+
else if ((0, node_fs_1.existsSync)(".env")) {
|
|
171
180
|
process.loadEnvFile(".env");
|
|
172
181
|
}
|
|
173
182
|
const parsed = envSchema.parse(process.env);
|
|
@@ -312,6 +321,8 @@ function loadConfig() {
|
|
|
312
321
|
},
|
|
313
322
|
logging: {
|
|
314
323
|
level: parsed.LOG_LEVEL,
|
|
324
|
+
fileEnabled: parsed.LOG_FILE_ENABLED,
|
|
325
|
+
filePath: parsed.LOG_FILE_PATH,
|
|
315
326
|
},
|
|
316
327
|
};
|
|
317
328
|
}
|
|
@@ -5,8 +5,8 @@ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
5
5
|
const registry_1 = require("../../../shared/api/tool-registry/registry");
|
|
6
6
|
function createMcpServer(tools) {
|
|
7
7
|
const server = new mcp_js_1.McpServer({
|
|
8
|
-
name: "
|
|
9
|
-
version: "
|
|
8
|
+
name: "tellymcp",
|
|
9
|
+
version: "0.0.6",
|
|
10
10
|
});
|
|
11
11
|
(0, registry_1.registerTools)(server, tools);
|
|
12
12
|
return server;
|
|
@@ -241,7 +241,7 @@ class GatewayHttpService {
|
|
|
241
241
|
if (pathname === "/gateway/healthz") {
|
|
242
242
|
writeJson(res, 200, {
|
|
243
243
|
ok: true,
|
|
244
|
-
service: "
|
|
244
|
+
service: "tellymcp-gateway",
|
|
245
245
|
mode: this.config.distributed.mode,
|
|
246
246
|
databaseConfigured: Boolean(process.env.DB_HOST && process.env.DB_NAME),
|
|
247
247
|
s3Configured: Boolean(this.config.distributed.gatewayS3Bucket),
|
|
@@ -4,10 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.createLogger = createLogger;
|
|
7
|
-
const node_fs_1 = require("node:fs");
|
|
8
|
-
const node_path_1 = require("node:path");
|
|
9
7
|
const pino_1 = __importDefault(require("pino"));
|
|
10
|
-
const
|
|
8
|
+
const pinoTargets_1 = require("../../../../../../../lib/pinoTargets");
|
|
11
9
|
function write(logger, level, message, meta) {
|
|
12
10
|
if (meta) {
|
|
13
11
|
logger[level](meta, message);
|
|
@@ -16,32 +14,15 @@ function write(logger, level, message, meta) {
|
|
|
16
14
|
logger[level](message);
|
|
17
15
|
}
|
|
18
16
|
function createLogger(config) {
|
|
19
|
-
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(DEFAULT_LOG_FILE_PATH), { recursive: true });
|
|
20
17
|
const transport = pino_1.default.transport({
|
|
21
|
-
targets:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
destination: DEFAULT_LOG_FILE_PATH,
|
|
27
|
-
mkdir: true,
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
target: "pino-pretty",
|
|
32
|
-
level: config.logging.level,
|
|
33
|
-
options: {
|
|
34
|
-
destination: 2,
|
|
35
|
-
colorize: true,
|
|
36
|
-
translateTime: "SYS:standard",
|
|
37
|
-
ignore: "pid,hostname",
|
|
38
|
-
singleLine: false,
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
],
|
|
18
|
+
targets: (0, pinoTargets_1.createPinoTargets)({
|
|
19
|
+
level: config.logging.level,
|
|
20
|
+
fileEnabled: config.logging.fileEnabled,
|
|
21
|
+
filePath: config.logging.filePath,
|
|
22
|
+
}),
|
|
42
23
|
});
|
|
43
24
|
const baseLogger = (0, pino_1.default)({
|
|
44
|
-
name: "
|
|
25
|
+
name: "tellymcp",
|
|
45
26
|
level: config.logging.level,
|
|
46
27
|
timestamp: pino_1.default.stdTimeFunctions.isoTime,
|
|
47
28
|
}, transport);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deadragdoll/tellymcp",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "TellyMCP - Telegram
|
|
3
|
+
"version": "0.0.7",
|
|
4
|
+
"description": "TellyMCP - Telegram control plane for MCP-connected coding agents",
|
|
5
5
|
"main": "dist/services/features/telegram-mcp/runtime.service.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tellymcp": "dist/cli.js"
|
|
@@ -75,7 +75,6 @@
|
|
|
75
75
|
"amqplib": "0.10.9",
|
|
76
76
|
"dotenv": "^17.4.2",
|
|
77
77
|
"grammy": "1.43.0",
|
|
78
|
-
"graphql": "^16.14.0",
|
|
79
78
|
"https-proxy-agent": "^7.0.6",
|
|
80
79
|
"ioredis": "5.10.1",
|
|
81
80
|
"knex": "^3.2.10",
|
|
@@ -88,7 +87,6 @@
|
|
|
88
87
|
"pino": "^10.3.1",
|
|
89
88
|
"pino-pretty": "^13.1.3",
|
|
90
89
|
"playwright": "^1.60.0",
|
|
91
|
-
"redis": "5.12.1",
|
|
92
90
|
"socks-proxy-agent": "^8.0.5",
|
|
93
91
|
"ws": "8.20.1",
|
|
94
92
|
"zod": "4.4.3"
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createTracerMiddleware = void 0;
|
|
4
|
-
const traceContext_1 = require("../traceContext");
|
|
5
|
-
const trace_1 = require("../trace");
|
|
6
|
-
const shouldSkipTrace = (actionName, ctx) => Boolean(ctx?.meta?.$traceInternal ||
|
|
7
|
-
!actionName ||
|
|
8
|
-
actionName === "context" ||
|
|
9
|
-
actionName === "graphql.publish" ||
|
|
10
|
-
(actionName === "rest" && ctx?.params?.req?.url === "/api/graphql"));
|
|
11
|
-
const createTracerMiddleware = () => {
|
|
12
|
-
let broker = null;
|
|
13
|
-
const resolveTracer = (action) => {
|
|
14
|
-
const rawName = String(action?.rawName || action?.name || "");
|
|
15
|
-
if (action?.tracer)
|
|
16
|
-
return { tracer: action.tracer, source: "action.tracer" };
|
|
17
|
-
if (action?.schema?.tracer)
|
|
18
|
-
return { tracer: action.schema.tracer, source: "action.schema.tracer" };
|
|
19
|
-
if (action?.service?.schema?.actions?.[rawName]?.tracer) {
|
|
20
|
-
return {
|
|
21
|
-
tracer: action.service.schema.actions[rawName].tracer,
|
|
22
|
-
source: "service.schema.actions[rawName].tracer",
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
return {};
|
|
26
|
-
};
|
|
27
|
-
const callTrace = async (ctx, actionName, params, silent = false) => {
|
|
28
|
-
if (!broker) {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
try {
|
|
32
|
-
return await broker.call(actionName, params, {
|
|
33
|
-
requestID: ctx.requestID,
|
|
34
|
-
meta: {
|
|
35
|
-
user: ctx.meta?.user,
|
|
36
|
-
$traceInternal: true,
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
catch (error) {
|
|
41
|
-
if (!silent) {
|
|
42
|
-
broker.logger.warn("Tracer call failed", {
|
|
43
|
-
actionName,
|
|
44
|
-
sourceAction: ctx.action?.name || null,
|
|
45
|
-
error: error instanceof Error ? error.message : String(error),
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
return {
|
|
52
|
-
created(localBroker) {
|
|
53
|
-
broker = localBroker;
|
|
54
|
-
},
|
|
55
|
-
localAction(next, action) {
|
|
56
|
-
const actionName = String(action?.rawName || action?.name || "");
|
|
57
|
-
const serviceName = String(action?.service?.name || "");
|
|
58
|
-
return async function traceAction(ctx) {
|
|
59
|
-
if (serviceName === "trace" || shouldSkipTrace(actionName, ctx)) {
|
|
60
|
-
return next(ctx);
|
|
61
|
-
}
|
|
62
|
-
const resolvedTracer = resolveTracer(ctx?.action || action);
|
|
63
|
-
const tracer = resolvedTracer.tracer;
|
|
64
|
-
let traceMeta = null;
|
|
65
|
-
const startSessionName = tracer?.startSession;
|
|
66
|
-
const tracerTag = tracer?.tag;
|
|
67
|
-
const shouldStartRootSession = Boolean(startSessionName);
|
|
68
|
-
if (!shouldStartRootSession) {
|
|
69
|
-
traceMeta = await (0, traceContext_1.loadTraceContext)();
|
|
70
|
-
}
|
|
71
|
-
let startedSession = false;
|
|
72
|
-
if (shouldStartRootSession) {
|
|
73
|
-
await (0, traceContext_1.deleteTraceContext)().catch(() => null);
|
|
74
|
-
const session = (await callTrace(ctx, "trace.startSession", {
|
|
75
|
-
name: startSessionName,
|
|
76
|
-
tag: tracerTag || null,
|
|
77
|
-
source: actionName,
|
|
78
|
-
meta: (0, trace_1.sanitizeTraceValue)({
|
|
79
|
-
action: actionName,
|
|
80
|
-
params: ctx.params,
|
|
81
|
-
}),
|
|
82
|
-
}, false));
|
|
83
|
-
if (session?.session_id) {
|
|
84
|
-
traceMeta = {
|
|
85
|
-
sessionId: String(session.session_id),
|
|
86
|
-
name: startSessionName ?? null,
|
|
87
|
-
tag: tracerTag || null,
|
|
88
|
-
rootAction: actionName,
|
|
89
|
-
startedBy: actionName,
|
|
90
|
-
};
|
|
91
|
-
await (0, traceContext_1.saveTraceContext)(traceMeta);
|
|
92
|
-
startedSession = true;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
const sessionId = String(traceMeta?.sessionId || "").trim();
|
|
96
|
-
const hasSession = Boolean(sessionId);
|
|
97
|
-
const startedAt = Date.now();
|
|
98
|
-
if (hasSession) {
|
|
99
|
-
await (0, traceContext_1.touchTraceContext)().catch(() => null);
|
|
100
|
-
await callTrace(ctx, "trace.log", {
|
|
101
|
-
session_id: sessionId,
|
|
102
|
-
level: (0, trace_1.normalizeTraceLevel)(tracer?.level || "debug"),
|
|
103
|
-
action: actionName,
|
|
104
|
-
state: "started",
|
|
105
|
-
marker: tracer?.marker || null,
|
|
106
|
-
step: tracer?.step || "action",
|
|
107
|
-
message: actionName,
|
|
108
|
-
data: (0, trace_1.buildTraceStartData)(ctx, actionName, tracer, startedSession),
|
|
109
|
-
}, false);
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
const result = await next(ctx);
|
|
113
|
-
const durationMs = Date.now() - startedAt;
|
|
114
|
-
if (hasSession) {
|
|
115
|
-
await callTrace(ctx, "trace.log", {
|
|
116
|
-
session_id: sessionId,
|
|
117
|
-
level: (0, trace_1.normalizeTraceLevel)(tracer?.level || "debug"),
|
|
118
|
-
action: actionName,
|
|
119
|
-
state: "succeeded",
|
|
120
|
-
marker: tracer?.marker || null,
|
|
121
|
-
step: tracer?.step || "action",
|
|
122
|
-
message: actionName,
|
|
123
|
-
data: (0, trace_1.buildTraceSuccessData)(ctx, actionName, result, durationMs, tracer),
|
|
124
|
-
}, false);
|
|
125
|
-
if (tracer?.stopSession) {
|
|
126
|
-
await callTrace(ctx, "trace.endSession", {
|
|
127
|
-
session_id: sessionId,
|
|
128
|
-
status: "succeeded",
|
|
129
|
-
summary: `${actionName} completed`,
|
|
130
|
-
meta: (0, trace_1.sanitizeTraceValue)({
|
|
131
|
-
action: actionName,
|
|
132
|
-
durationMs,
|
|
133
|
-
}),
|
|
134
|
-
}, false);
|
|
135
|
-
await (0, traceContext_1.deleteTraceContext)();
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return result;
|
|
139
|
-
}
|
|
140
|
-
catch (error) {
|
|
141
|
-
const durationMs = Date.now() - startedAt;
|
|
142
|
-
if (hasSession) {
|
|
143
|
-
await callTrace(ctx, "trace.log", {
|
|
144
|
-
session_id: sessionId,
|
|
145
|
-
level: "error",
|
|
146
|
-
action: actionName,
|
|
147
|
-
state: "failed",
|
|
148
|
-
marker: tracer?.marker || null,
|
|
149
|
-
step: tracer?.step || "action",
|
|
150
|
-
message: actionName,
|
|
151
|
-
data: (0, trace_1.buildTraceErrorData)(ctx, actionName, error, durationMs, tracer),
|
|
152
|
-
}, false);
|
|
153
|
-
if (tracer?.stopSession || startedSession) {
|
|
154
|
-
await callTrace(ctx, "trace.endSession", {
|
|
155
|
-
session_id: sessionId,
|
|
156
|
-
status: "failed",
|
|
157
|
-
summary: error instanceof Error ? error.message : `Action failed: ${actionName}`,
|
|
158
|
-
meta: (0, trace_1.sanitizeTraceValue)({
|
|
159
|
-
action: actionName,
|
|
160
|
-
durationMs,
|
|
161
|
-
}),
|
|
162
|
-
}, false);
|
|
163
|
-
await (0, traceContext_1.deleteTraceContext)();
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
throw error;
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
};
|
|
172
|
-
exports.createTracerMiddleware = createTracerMiddleware;
|
package/dist/lib/trace.js
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.buildTraceErrorData = exports.buildTraceSuccessData = exports.buildTraceStartData = exports.buildTraceMetaSummary = exports.sanitizeTraceValue = exports.normalizeTraceLevel = void 0;
|
|
4
|
-
const TRACE_MAX_DEPTH = 4;
|
|
5
|
-
const TRACE_MAX_ARRAY = 25;
|
|
6
|
-
const TRACE_MAX_KEYS = 40;
|
|
7
|
-
const TRACE_MAX_STRING = 1000;
|
|
8
|
-
const truncateString = (value) => value.length > TRACE_MAX_STRING ? `${value.slice(0, TRACE_MAX_STRING)}...` : value;
|
|
9
|
-
const normalizeTraceLevel = (value) => {
|
|
10
|
-
const level = String(value || "info").toLowerCase();
|
|
11
|
-
if (["fatal", "error", "warn", "info", "debug", "trace"].includes(level)) {
|
|
12
|
-
return level;
|
|
13
|
-
}
|
|
14
|
-
return "info";
|
|
15
|
-
};
|
|
16
|
-
exports.normalizeTraceLevel = normalizeTraceLevel;
|
|
17
|
-
const sanitizeError = (error, depth, seen) => {
|
|
18
|
-
if (!(error instanceof Error)) {
|
|
19
|
-
return (0, exports.sanitizeTraceValue)(error, depth, seen);
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
22
|
-
name: error.name,
|
|
23
|
-
message: truncateString(error.message || ""),
|
|
24
|
-
stack: truncateString(error.stack || ""),
|
|
25
|
-
};
|
|
26
|
-
};
|
|
27
|
-
const sanitizeTraceValue = (value, depth = 0, seen = new WeakSet()) => {
|
|
28
|
-
if (value == null || typeof value === "boolean" || typeof value === "number") {
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
|
-
if (typeof value === "string") {
|
|
32
|
-
return truncateString(value);
|
|
33
|
-
}
|
|
34
|
-
if (typeof value === "bigint") {
|
|
35
|
-
return value.toString();
|
|
36
|
-
}
|
|
37
|
-
if (typeof value === "symbol") {
|
|
38
|
-
return value.toString();
|
|
39
|
-
}
|
|
40
|
-
if (typeof value === "function") {
|
|
41
|
-
return `[Function ${value.name || "anonymous"}]`;
|
|
42
|
-
}
|
|
43
|
-
if (value instanceof Date) {
|
|
44
|
-
return value.toISOString();
|
|
45
|
-
}
|
|
46
|
-
if (value instanceof Error) {
|
|
47
|
-
return sanitizeError(value, depth, seen);
|
|
48
|
-
}
|
|
49
|
-
if (Buffer.isBuffer(value)) {
|
|
50
|
-
return `[Buffer ${value.length}]`;
|
|
51
|
-
}
|
|
52
|
-
if (value instanceof Set) {
|
|
53
|
-
return {
|
|
54
|
-
type: "Set",
|
|
55
|
-
size: value.size,
|
|
56
|
-
values: Array.from(value.values())
|
|
57
|
-
.slice(0, TRACE_MAX_ARRAY)
|
|
58
|
-
.map(item => (0, exports.sanitizeTraceValue)(item, depth + 1, seen)),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
if (value instanceof Map) {
|
|
62
|
-
return {
|
|
63
|
-
type: "Map",
|
|
64
|
-
size: value.size,
|
|
65
|
-
entries: Array.from(value.entries())
|
|
66
|
-
.slice(0, TRACE_MAX_ARRAY)
|
|
67
|
-
.map(([key, item]) => [
|
|
68
|
-
(0, exports.sanitizeTraceValue)(key, depth + 1, seen),
|
|
69
|
-
(0, exports.sanitizeTraceValue)(item, depth + 1, seen),
|
|
70
|
-
]),
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
if (Array.isArray(value)) {
|
|
74
|
-
if (depth >= TRACE_MAX_DEPTH) {
|
|
75
|
-
return `[Array(${value.length})]`;
|
|
76
|
-
}
|
|
77
|
-
return value
|
|
78
|
-
.slice(0, TRACE_MAX_ARRAY)
|
|
79
|
-
.map(item => (0, exports.sanitizeTraceValue)(item, depth + 1, seen));
|
|
80
|
-
}
|
|
81
|
-
if (typeof value === "object") {
|
|
82
|
-
const objectValue = value;
|
|
83
|
-
if (seen.has(objectValue)) {
|
|
84
|
-
return "[Circular]";
|
|
85
|
-
}
|
|
86
|
-
seen.add(objectValue);
|
|
87
|
-
if (depth >= TRACE_MAX_DEPTH) {
|
|
88
|
-
return `[${objectValue?.constructor?.name || "Object"}]`;
|
|
89
|
-
}
|
|
90
|
-
const keys = Object.keys(objectValue).slice(0, TRACE_MAX_KEYS);
|
|
91
|
-
const result = {};
|
|
92
|
-
for (const key of keys) {
|
|
93
|
-
result[key] = (0, exports.sanitizeTraceValue)(objectValue[key], depth + 1, seen);
|
|
94
|
-
}
|
|
95
|
-
if (Object.keys(objectValue).length > TRACE_MAX_KEYS) {
|
|
96
|
-
result.__truncatedKeys = Object.keys(objectValue).length - TRACE_MAX_KEYS;
|
|
97
|
-
}
|
|
98
|
-
return result;
|
|
99
|
-
}
|
|
100
|
-
return String(value);
|
|
101
|
-
};
|
|
102
|
-
exports.sanitizeTraceValue = sanitizeTraceValue;
|
|
103
|
-
const pickFields = (source, fields) => {
|
|
104
|
-
if (fields === false) {
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
if (fields === true || fields == null) {
|
|
108
|
-
return (0, exports.sanitizeTraceValue)(source);
|
|
109
|
-
}
|
|
110
|
-
if (!source || typeof source !== "object") {
|
|
111
|
-
return (0, exports.sanitizeTraceValue)(source);
|
|
112
|
-
}
|
|
113
|
-
const result = {};
|
|
114
|
-
for (const key of fields) {
|
|
115
|
-
result[key] = (0, exports.sanitizeTraceValue)(source[key]);
|
|
116
|
-
}
|
|
117
|
-
return result;
|
|
118
|
-
};
|
|
119
|
-
const buildTraceMetaSummary = (ctx) => (0, exports.sanitizeTraceValue)({
|
|
120
|
-
requestID: ctx.requestID || null,
|
|
121
|
-
userSub: ctx.meta?.user?.sub || null,
|
|
122
|
-
});
|
|
123
|
-
exports.buildTraceMetaSummary = buildTraceMetaSummary;
|
|
124
|
-
const buildTraceStartData = (ctx, actionName, tracer, startedSession = false) => (0, exports.sanitizeTraceValue)({
|
|
125
|
-
action: actionName,
|
|
126
|
-
startedSession,
|
|
127
|
-
params: pickFields(ctx.params, tracer?.captureParams),
|
|
128
|
-
meta: (0, exports.buildTraceMetaSummary)(ctx),
|
|
129
|
-
});
|
|
130
|
-
exports.buildTraceStartData = buildTraceStartData;
|
|
131
|
-
const buildTraceSuccessData = (ctx, actionName, result, durationMs, tracer) => (0, exports.sanitizeTraceValue)({
|
|
132
|
-
action: actionName,
|
|
133
|
-
durationMs,
|
|
134
|
-
result: tracer?.captureResult === undefined
|
|
135
|
-
? undefined
|
|
136
|
-
: pickFields(result, tracer.captureResult),
|
|
137
|
-
});
|
|
138
|
-
exports.buildTraceSuccessData = buildTraceSuccessData;
|
|
139
|
-
const buildTraceErrorData = (ctx, actionName, error, durationMs, tracer) => (0, exports.sanitizeTraceValue)({
|
|
140
|
-
action: actionName,
|
|
141
|
-
durationMs,
|
|
142
|
-
error: tracer?.captureError === undefined
|
|
143
|
-
? sanitizeError(error, 0, new WeakSet())
|
|
144
|
-
: pickFields(error, tracer.captureError),
|
|
145
|
-
meta: (0, exports.buildTraceMetaSummary)(ctx),
|
|
146
|
-
});
|
|
147
|
-
exports.buildTraceErrorData = buildTraceErrorData;
|
package/dist/lib/traceContext.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.deleteTraceContext = exports.touchTraceContext = exports.loadTraceContext = exports.saveTraceContext = void 0;
|
|
37
|
-
const redis = __importStar(require("redis"));
|
|
38
|
-
const trace_1 = require("./trace");
|
|
39
|
-
const TRACE_CONTEXT_TTL_SEC = Math.max(1, Number(process.env.TRACE_CONTEXT_TTL_SEC || 2));
|
|
40
|
-
const TRACE_CONTEXT_KEY = "trace:active";
|
|
41
|
-
let traceRedisClient = null;
|
|
42
|
-
let traceRedisConnectPromise = null;
|
|
43
|
-
const createTraceRedisClient = () => redis.createClient({
|
|
44
|
-
socket: {
|
|
45
|
-
host: process.env.REDIS_HOST || "localhost",
|
|
46
|
-
port: +(process.env.REDIS_PORT || 6379),
|
|
47
|
-
},
|
|
48
|
-
database: +(process.env.REDIS_DB || 0),
|
|
49
|
-
name: `${process.env.APP_NAME || process.env.APPNAME || "app"}:trace`,
|
|
50
|
-
});
|
|
51
|
-
const getTraceRedisClient = async () => {
|
|
52
|
-
if (traceRedisClient?.isOpen) {
|
|
53
|
-
return traceRedisClient;
|
|
54
|
-
}
|
|
55
|
-
if (!traceRedisClient) {
|
|
56
|
-
traceRedisClient = createTraceRedisClient();
|
|
57
|
-
}
|
|
58
|
-
if (!traceRedisConnectPromise) {
|
|
59
|
-
traceRedisConnectPromise = traceRedisClient.connect().then(() => traceRedisClient);
|
|
60
|
-
}
|
|
61
|
-
try {
|
|
62
|
-
return await traceRedisConnectPromise;
|
|
63
|
-
}
|
|
64
|
-
finally {
|
|
65
|
-
traceRedisConnectPromise = null;
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
const saveTraceContext = async (trace) => {
|
|
69
|
-
if (!trace?.sessionId) {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
const client = await getTraceRedisClient();
|
|
73
|
-
await client.setEx(TRACE_CONTEXT_KEY, TRACE_CONTEXT_TTL_SEC, JSON.stringify((0, trace_1.sanitizeTraceValue)(trace)));
|
|
74
|
-
return true;
|
|
75
|
-
};
|
|
76
|
-
exports.saveTraceContext = saveTraceContext;
|
|
77
|
-
const loadTraceContext = async () => {
|
|
78
|
-
const client = await getTraceRedisClient();
|
|
79
|
-
const raw = await client.get(TRACE_CONTEXT_KEY);
|
|
80
|
-
if (!raw) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
const parsed = JSON.parse(Buffer.isBuffer(raw) ? raw.toString("utf-8") : String(raw));
|
|
85
|
-
if (!parsed || typeof parsed !== "object") {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
return {
|
|
89
|
-
sessionId: String(parsed.sessionId || ""),
|
|
90
|
-
name: parsed.name || null,
|
|
91
|
-
tag: parsed.tag || null,
|
|
92
|
-
rootAction: parsed.rootAction || null,
|
|
93
|
-
startedBy: parsed.startedBy || null,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
exports.loadTraceContext = loadTraceContext;
|
|
101
|
-
const touchTraceContext = async () => {
|
|
102
|
-
const client = await getTraceRedisClient();
|
|
103
|
-
const exists = await client.exists(TRACE_CONTEXT_KEY);
|
|
104
|
-
if (!exists) {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
await client.expire(TRACE_CONTEXT_KEY, TRACE_CONTEXT_TTL_SEC);
|
|
108
|
-
return true;
|
|
109
|
-
};
|
|
110
|
-
exports.touchTraceContext = touchTraceContext;
|
|
111
|
-
const deleteTraceContext = async () => {
|
|
112
|
-
const client = await getTraceRedisClient();
|
|
113
|
-
await client.del(TRACE_CONTEXT_KEY);
|
|
114
|
-
return true;
|
|
115
|
-
};
|
|
116
|
-
exports.deleteTraceContext = deleteTraceContext;
|