@goodandready/dsh-messenger-gateway 0.3.1 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -1
- package/README.ru.md +125 -0
- package/README.zh.md +76 -0
- package/lib/adapters/discord.js +129 -4
- package/lib/adapters/index.js +17 -1
- package/lib/adapters/slack.js +91 -0
- package/lib/adapters/telegram.js +27 -2
- package/lib/alerts.js +64 -0
- package/lib/artifacts.js +118 -0
- package/lib/ask.js +89 -0
- package/lib/client.js +12 -0
- package/lib/commands.js +12 -0
- package/lib/config.js +20 -1
- package/lib/documents.js +145 -3
- package/lib/file-manager.js +162 -0
- package/lib/gateway.js +391 -13
- package/lib/index.js +66 -10
- package/lib/messenger-api.js +19 -1
- package/lib/personas.js +98 -0
- package/lib/scheduler.js +135 -0
- package/lib/session-ops.js +95 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -7,9 +7,20 @@ Talk to your Harness agent from Telegram: text, voice, photos, documents, inline
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- Long-poll or webhook Telegram bot
|
|
10
|
+
- Multi-transport support: Telegram, Discord (Webhooks & Bot API), and Slack (Webhooks & Bot API)
|
|
10
11
|
- Allowlist + pairing codes
|
|
11
12
|
- Per-user or per-chat sessions (`sessionScope`)
|
|
12
13
|
- Forum topics as separate sessions
|
|
14
|
+
- Quick actions reply keyboard (`/keyboard on|off`)
|
|
15
|
+
- Multi-select interactive ask forms with checkboxes and pagination (`messenger_ask`)
|
|
16
|
+
- Artifact & Mermaid diagram rendering (SVG cards) and monospace table formatting
|
|
17
|
+
- Agent roles & personas (`/role coder`, `/role architect`, `@role` tags in group chats)
|
|
18
|
+
- Agent tools & skills inspection (`/skills`, `/tools`)
|
|
19
|
+
- Session management: dialogue history export to Markdown (`/export`), turn rewind (`/rewind`), session branching (`/fork`)
|
|
20
|
+
- Workspace file manager (`/files [dir]`) and file download (`/get <path>`) with directory traversal protection
|
|
21
|
+
- Admin alert channel for pairing requests, model errors and monitoring (`/setalert`, `/alert`)
|
|
22
|
+
- Persistent scheduled reminders (`/remind <time> <text>`, `/remind list`, `/remind cancel`)
|
|
23
|
+
- Inbound webhook event dispatcher (`POST /dsh-messenger-gateway/events`) for CI/CD and external alerts
|
|
13
24
|
- Steer: follow-up messages while the agent is busy (instead of aborting)
|
|
14
25
|
- `/stop`, `/new`, `/model`, `/status`, `/voice`, `/sethome`, `/home`
|
|
15
26
|
- Agent tool `messenger_ask` (inline keyboard answers return to the agent)
|
|
@@ -18,7 +29,7 @@ Talk to your Harness agent from Telegram: text, voice, photos, documents, inline
|
|
|
18
29
|
- Inbound voice → STT (`dsh-voice`), photos → vision (`dsh-vision-bridge`)
|
|
19
30
|
- Optional TTS replies (`dsh-tts`); mp3 is converted to OGG/Opus via `ffmpeg` for Telegram voice notes
|
|
20
31
|
|
|
21
|
-
Discord
|
|
32
|
+
Discord and Slack adapters are available as outbound transports (Webhooks or Bot REST APIs).
|
|
22
33
|
|
|
23
34
|
## Install
|
|
24
35
|
|
|
@@ -55,10 +66,27 @@ Spoken replies use Telegram `sendVoice`. If TTS returns MP3 (or other non-Opus a
|
|
|
55
66
|
| `/new` | New agent session |
|
|
56
67
|
| `/stop` | Abort the current turn |
|
|
57
68
|
| `/model` `/status` | Model / gateway status |
|
|
69
|
+
| `/role [name]` | Switch agent persona (`coder`, `architect`, `reviewer`, `writer`, `translator`, `concise`) |
|
|
70
|
+
| `/skills` `/tools` | List active agent tools and capabilities |
|
|
71
|
+
| `/fork` | Fork current session into a new independent session |
|
|
72
|
+
| `/export` | Export session dialogue history to Markdown |
|
|
73
|
+
| `/rewind [N]` | Rewind last N conversation turns |
|
|
74
|
+
| `/files [dir]` | Workspace file manager |
|
|
75
|
+
| `/get <path>` | Download file from workspace |
|
|
76
|
+
| `/remind <time> <text>` | Set a reminder (e.g. `/remind 15m Call client`) |
|
|
77
|
+
| `/setalert` `/alert` | Configure admin alert channel and send test alert |
|
|
78
|
+
| `/keyboard on\|off` | Toggle quick actions reply keyboard |
|
|
58
79
|
| `/voice on\|off` | Per-user spoken replies |
|
|
80
|
+
| `/tts on\|off\|status` | Per-chat spoken replies |
|
|
59
81
|
| `/sethome [name]` | Bind current chat/topic as a named home |
|
|
60
82
|
| `/home` | List homes |
|
|
61
83
|
|
|
84
|
+
## Multi-Transport Support (Discord & Slack)
|
|
85
|
+
|
|
86
|
+
In addition to Telegram, outbound messages can be dispatched to Discord and Slack via Webhooks or Bot APIs:
|
|
87
|
+
- **Discord:** set `discord.enabled: true`, provide either `webhookUrl` or `botToken`. Target channels via `chatId: "<channel_id>"`.
|
|
88
|
+
- **Slack:** set `slack.enabled: true`, provide either `webhookUrl` or `botToken`. Target channels via `chatId: "<channel_id>"` or threads via `threadId: "<thread_ts>"`.
|
|
89
|
+
|
|
62
90
|
## Agent tools & HTTP
|
|
63
91
|
|
|
64
92
|
- Tool: `messenger_ask` — ask the user with inline buttons; choice is fed back into the turn
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# 📦 @goodandready/dsh-messenger-gateway
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<h3>Шлюз Telegram с интерактивными кнопками управления, темами форумов и голосовыми ответами для DeepSeek Harness</h3>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/@goodandready/dsh-messenger-gateway"><img src="https://img.shields.io/npm/v/@goodandready/dsh-messenger-gateway.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
|
|
10
|
+
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
|
|
11
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://goodandready.app/"><img src="https://img.shields.io/badge/Все_проекты_автора-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="Все проекты автора"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="README.md"><b>🇬🇧 English</b></a> •
|
|
20
|
+
<a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
|
|
21
|
+
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ⚡ Обзор
|
|
29
|
+
|
|
30
|
+
**`dsh-messenger-gateway`** предоставляет полнофункциональный Telegram-мост корпоративного уровня для агентов **DeepSeek Harness**.
|
|
31
|
+
|
|
32
|
+
В отличие от простых текстовых релеев, плагин переносит всю интерактивность агента прямо в Telegram: **интерактивные кнопки (Inline Keyboard)** для уточняющих вопросов (`ask_question`), изоляцию **тем форумов (Forum Topics)**, персональные **рабочие папки пользователей**, авторизацию по **6-значным кодам сопряжения** и голосовые **аудио-ответы (TTS)**.
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
graph LR
|
|
36
|
+
subgraph TelegramClient [Пользователь Telegram / Группа / Тема]
|
|
37
|
+
User[👤 Пользователь / Тема форума] --> TG[Шлюз Telegram Bot API Long-Poll / Webhook]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
subgraph GatewayCore [Диспетчер шлюза]
|
|
41
|
+
TG --> Auth{Проверка сопряжения и доступа}
|
|
42
|
+
Auth -->|Авторизован| Router{Маршрутизатор тем и папок}
|
|
43
|
+
Router --> Home[Рабочая папка: /homes/user_id]
|
|
44
|
+
Home --> Thread[Изолированный контекст сессии DSH]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
subgraph AgentLoop [Цикл работы агента DSH]
|
|
48
|
+
Thread --> Agent[Выполнение действий агента]
|
|
49
|
+
Agent -->|ask_question выбор опций| Ask[Интерактивные кнопки Inline Keyboard]
|
|
50
|
+
Agent -->|Озвучивание ответов| TTS[Синтез голосового сообщения TTS]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
subgraph Feedback [Доставка ответа]
|
|
54
|
+
Ask --> TG
|
|
55
|
+
TTS --> TG
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
style TelegramClient fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
59
|
+
style GatewayCore fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
|
|
60
|
+
style AgentLoop fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
61
|
+
style Feedback fill:#181825,stroke:#f38ba8,stroke-width:2px,color:#cdd6f4
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## ✨ Ключевые возможности
|
|
67
|
+
|
|
68
|
+
### 1. 🎮 Интерактивные кнопки управления агентом (`ask.js`)
|
|
69
|
+
Когда агент вызывает инструмент `ask_question` со списком вариантов ответа, плагин отправляет нативные **Inline-кнопки** в Telegram. Ход агента безопасно приостанавливается, а при нажатии на кнопку выполнение мгновенно возобновляется.
|
|
70
|
+
|
|
71
|
+
### 2. 🧵 Темы форумов Telegram (Forum Topics) (`topics.js`, `groups.js`)
|
|
72
|
+
Полная поддержка тем в супергруппах Telegram:
|
|
73
|
+
* Каждая тема (Topic) автоматически привязывается к отдельной сессии DSH;
|
|
74
|
+
* Контексты разных тем не смешиваются между собой;
|
|
75
|
+
* Режим ответов по тегу (`@bot_name`) или постоянное прослушивание.
|
|
76
|
+
|
|
77
|
+
### 3. 🔊 Голосовые ответы ассистента (TTS) (`tts.js`, `voice-prefs.js`)
|
|
78
|
+
Агент может отвечать голосовыми сообщениями (`sendVoice`):
|
|
79
|
+
* Интеграция с [`dsh-tts`](https://github.com/GooDAnDReaDY/dsh-tts) или отдельными TTS-движками;
|
|
80
|
+
* Индивидуальное переключение пользователем через команды `/voice on` и `/voice off`.
|
|
81
|
+
|
|
82
|
+
### 4. 📁 Изоляция рабочих папок пользователей (`homes.js`)
|
|
83
|
+
Каждому пользователю Telegram выделяется отдельная изолированная директория на сервере (`workspaces/users/{userId}`):
|
|
84
|
+
* Файловые операции и запуск команд изолированы;
|
|
85
|
+
* Исключен риск перезаписи чужих файлов или доступа к системным ресурсам.
|
|
86
|
+
|
|
87
|
+
### 5. 🔐 Безопасность и 6-значные коды сопряжения (`pairing.js`)
|
|
88
|
+
* Защита от несанкционированного доступа: новые пользователи должны ввести одноразовый 6-значный код, сгенерированный в Web UI;
|
|
89
|
+
* Белые списки разрешенных ID (`allowedUsers`, `allowedChats`).
|
|
90
|
+
|
|
91
|
+
### 6. 🤖 Команды бота (`commands.js`)
|
|
92
|
+
* `/start` — приветствие и запуск сопряжения;
|
|
93
|
+
* `/clear` — сброс контекста текущего диалога без удаления рабочих файлов;
|
|
94
|
+
* `/voice [on|off]` — включение/отключение голосовых ответов;
|
|
95
|
+
* `/model` — просмотр и переключение активной модели агента;
|
|
96
|
+
* `/help` — список возможностей.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 📦 Быстрая установка
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
dsh plugin --profile web add @goodandready/dsh-messenger-gateway
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## ⚙️ Пример конфигурации (`settings.yaml`)
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
dsh-messenger-gateway:
|
|
112
|
+
tokenEnv: TELEGRAM_BOT_TOKEN
|
|
113
|
+
requirePairing: true
|
|
114
|
+
enableVoiceReplies: true
|
|
115
|
+
enableForumTopics: true
|
|
116
|
+
homeBaseDir: data/workspaces/users
|
|
117
|
+
allowedUsers: []
|
|
118
|
+
allowedChats: []
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 📄 Лицензия
|
|
124
|
+
|
|
125
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# 📦 @goodandready/dsh-messenger-gateway
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<h3>DeepSeek Harness Telegram 专属网关(支持交互按钮调度、论坛话题隔离与语音条回复)</h3>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/@goodandready/dsh-messenger-gateway"><img src="https://img.shields.io/npm/v/@goodandready/dsh-messenger-gateway.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
|
|
10
|
+
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
|
|
11
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://goodandready.app/"><img src="https://img.shields.io/badge/作者全部项目-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="作者全部项目"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="README.md"><b>🇬🇧 English</b></a> •
|
|
20
|
+
<a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
|
|
21
|
+
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ⚡ 插件概览
|
|
29
|
+
|
|
30
|
+
**`dsh-messenger-gateway`** 为 **DeepSeek Harness** 智能体提供企业级 Telegram 接入桥梁。
|
|
31
|
+
|
|
32
|
+
除常规对话转发外,本插件全面打通了智能体深度交互能力:**问答交互式内联按钮 (Inline Keyboard)**、**Telegram 论坛话题 (Forum Topics) 独立会话隔离**、**单用户工作区目录安全隔离**、**6 位配对码鉴权防护**以及**语音条朗读回复 (TTS)**。
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
graph LR
|
|
36
|
+
subgraph TelegramClient [Telegram 客户端 / 群组 / 话题]
|
|
37
|
+
User[👤 用户 / 论坛话题] --> TG[Telegram Bot API 轮询 / Webhook]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
subgraph GatewayCore [网关调度核心]
|
|
41
|
+
TG --> Auth{配对码与权限校验}
|
|
42
|
+
Auth -->|已配对用户| Router{话题与目录路由器}
|
|
43
|
+
Router --> Home[独立工作目录: /homes/user_id]
|
|
44
|
+
Home --> Thread[DSH 隔离会话上下文]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
subgraph AgentLoop [DSH 智能体执行流]
|
|
48
|
+
Thread --> Agent[智能体逻辑流]
|
|
49
|
+
Agent -->|ask_question 工具调用| Ask[Telegram 原生内联交互按钮]
|
|
50
|
+
Agent -->|语音回复合成| TTS[TTS 语音条消息下发]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
subgraph Feedback [交互下发]
|
|
54
|
+
Ask --> TG
|
|
55
|
+
TTS --> TG
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
style TelegramClient fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
59
|
+
style GatewayCore fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
|
|
60
|
+
style AgentLoop fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
61
|
+
style Feedback fill:#181825,stroke:#f38ba8,stroke-width:2px,color:#cdd6f4
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 📦 安装指南
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
dsh plugin --profile web add @goodandready/dsh-messenger-gateway
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 📄 开源协议
|
|
75
|
+
|
|
76
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/lib/adapters/discord.js
CHANGED
|
@@ -1,6 +1,131 @@
|
|
|
1
|
-
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const DISCORD_API = 'https://discord.com/api/v10'
|
|
5
|
+
const DISCORD_MAX_LENGTH = 2000
|
|
6
|
+
|
|
7
|
+
export function splitDiscordText(text, limit = DISCORD_MAX_LENGTH) {
|
|
8
|
+
if (!text) return []
|
|
9
|
+
if (text.length <= limit) return [text]
|
|
10
|
+
const chunks = []
|
|
11
|
+
let rem = text
|
|
12
|
+
while (rem.length > limit) {
|
|
13
|
+
let cut = rem.lastIndexOf('\n', limit)
|
|
14
|
+
if (cut <= 0) cut = rem.lastIndexOf(' ', limit)
|
|
15
|
+
if (cut <= 0) cut = limit
|
|
16
|
+
chunks.push(rem.slice(0, cut))
|
|
17
|
+
rem = rem.slice(cut).trimStart()
|
|
18
|
+
}
|
|
19
|
+
if (rem.length > 0) chunks.push(rem)
|
|
20
|
+
return chunks
|
|
21
|
+
}
|
|
22
|
+
|
|
2
23
|
export class DiscordAdapter {
|
|
3
|
-
constructor(
|
|
4
|
-
|
|
5
|
-
|
|
24
|
+
constructor(opts = {}) {
|
|
25
|
+
this.name = 'discord'
|
|
26
|
+
this.botToken = String(opts.botToken || '').trim()
|
|
27
|
+
this.webhookUrl = String(opts.webhookUrl || '').trim()
|
|
28
|
+
this.logger = opts.logger
|
|
29
|
+
this.stopped = false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async start() {
|
|
33
|
+
this.stopped = false
|
|
34
|
+
if (!this.botToken && !this.webhookUrl) {
|
|
35
|
+
this.logger?.warn?.('dsh-messenger-gateway: discord adapter enabled but neither botToken nor webhookUrl provided')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
stop() {
|
|
40
|
+
this.stopped = true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async sendTo(channelId, payload, opts = {}) {
|
|
44
|
+
if (this.stopped) throw new Error('discord adapter stopped')
|
|
45
|
+
const body = typeof payload === 'string' ? { text: payload } : (payload || {})
|
|
46
|
+
const text = String(body.text || '')
|
|
47
|
+
const files = Array.isArray(body.files) ? body.files : []
|
|
48
|
+
|
|
49
|
+
const chunks = splitDiscordText(text)
|
|
50
|
+
if (!chunks.length && !files.length) return { ok: true }
|
|
51
|
+
|
|
52
|
+
// Case 1: Webhook sending
|
|
53
|
+
const isWebhookTarget = !channelId || channelId === 'default' || channelId === 'webhook'
|
|
54
|
+
if (this.webhookUrl && isWebhookTarget) {
|
|
55
|
+
for (const chunk of (chunks.length ? chunks : [''])) {
|
|
56
|
+
const res = await fetch(this.webhookUrl, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ content: chunk }),
|
|
60
|
+
})
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const errText = await res.text().catch(() => '')
|
|
63
|
+
throw new Error(`discord webhook error ${res.status}: ${errText}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { ok: true }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Case 2: Bot REST API
|
|
70
|
+
if (!this.botToken) {
|
|
71
|
+
throw new Error('discord botToken required to send to specific channels')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const targetChannel = String(channelId || '').trim()
|
|
75
|
+
if (!targetChannel) throw new Error('discord channelId required')
|
|
76
|
+
|
|
77
|
+
// Handle files if any on the first chunk
|
|
78
|
+
if (files.length > 0 && typeof FormData !== 'undefined') {
|
|
79
|
+
const form = new FormData()
|
|
80
|
+
form.append('payload_json', JSON.stringify({
|
|
81
|
+
content: chunks[0] || '',
|
|
82
|
+
}))
|
|
83
|
+
for (let i = 0; i < files.length; i++) {
|
|
84
|
+
const file = files[i]
|
|
85
|
+
const bytes = file.bytes || (file.path ? await readFile(file.path) : null)
|
|
86
|
+
if (bytes) {
|
|
87
|
+
const name = file.name || (file.path ? basename(file.path) : `file-${i}`)
|
|
88
|
+
const mime = file.mime || 'application/octet-stream'
|
|
89
|
+
form.append(`files[${i}]`, new Blob([bytes], { type: mime }), name)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const res = await fetch(`${DISCORD_API}/channels/${targetChannel}/messages`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bot ${this.botToken}`,
|
|
96
|
+
},
|
|
97
|
+
body: form,
|
|
98
|
+
})
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
const errText = await res.text().catch(() => '')
|
|
101
|
+
throw new Error(`discord send error ${res.status}: ${errText}`)
|
|
102
|
+
}
|
|
103
|
+
// Send remaining text chunks if any
|
|
104
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
105
|
+
await this._sendRestMessage(targetChannel, chunks[i])
|
|
106
|
+
}
|
|
107
|
+
return { ok: true }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Pure text chunks
|
|
111
|
+
for (const chunk of chunks) {
|
|
112
|
+
await this._sendRestMessage(targetChannel, chunk)
|
|
113
|
+
}
|
|
114
|
+
return { ok: true }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async _sendRestMessage(channelId, content) {
|
|
118
|
+
const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: {
|
|
121
|
+
Authorization: `Bot ${this.botToken}`,
|
|
122
|
+
'Content-Type': 'application/json',
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({ content }),
|
|
125
|
+
})
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const errText = await res.text().catch(() => '')
|
|
128
|
+
throw new Error(`discord send error ${res.status}: ${errText}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
6
131
|
}
|
package/lib/adapters/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { TelegramAdapter } from './telegram.js'
|
|
2
|
+
import { DiscordAdapter } from './discord.js'
|
|
3
|
+
import { SlackAdapter } from './slack.js'
|
|
2
4
|
|
|
3
5
|
export default function createAdapters(deps) {
|
|
4
6
|
const { config, onMessage, onCallback, onUnauthorized, isUserAllowed, logger } = deps
|
|
@@ -21,6 +23,8 @@ export default function createAdapters(deps) {
|
|
|
21
23
|
transport: tg.transport,
|
|
22
24
|
webhookUrl: tg.webhookUrl,
|
|
23
25
|
webhookSecret: tg.webhookSecret,
|
|
26
|
+
quickActions: tg.quickActions !== false,
|
|
27
|
+
artifactPreviews: tg.artifactPreviews !== false,
|
|
24
28
|
media: config.media,
|
|
25
29
|
onMessage,
|
|
26
30
|
onCallback,
|
|
@@ -31,7 +35,19 @@ export default function createAdapters(deps) {
|
|
|
31
35
|
}
|
|
32
36
|
const dc = config.discord || {}
|
|
33
37
|
if (config.enabled !== false && dc.enabled) {
|
|
34
|
-
|
|
38
|
+
list.push(new DiscordAdapter({
|
|
39
|
+
botToken: dc.botToken,
|
|
40
|
+
webhookUrl: dc.webhookUrl,
|
|
41
|
+
logger,
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
const sl = config.slack || {}
|
|
45
|
+
if (config.enabled !== false && sl.enabled) {
|
|
46
|
+
list.push(new SlackAdapter({
|
|
47
|
+
botToken: sl.botToken,
|
|
48
|
+
webhookUrl: sl.webhookUrl,
|
|
49
|
+
logger,
|
|
50
|
+
}))
|
|
35
51
|
}
|
|
36
52
|
return list
|
|
37
53
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const SLACK_API = 'https://slack.com/api'
|
|
2
|
+
const SLACK_MAX_LENGTH = 4000
|
|
3
|
+
|
|
4
|
+
export function splitSlackText(text, limit = SLACK_MAX_LENGTH) {
|
|
5
|
+
if (!text) return []
|
|
6
|
+
if (text.length <= limit) return [text]
|
|
7
|
+
const chunks = []
|
|
8
|
+
let rem = text
|
|
9
|
+
while (rem.length > limit) {
|
|
10
|
+
let cut = rem.lastIndexOf('\n', limit)
|
|
11
|
+
if (cut <= 0) cut = rem.lastIndexOf(' ', limit)
|
|
12
|
+
if (cut <= 0) cut = limit
|
|
13
|
+
chunks.push(rem.slice(0, cut))
|
|
14
|
+
rem = rem.slice(cut).trimStart()
|
|
15
|
+
}
|
|
16
|
+
if (rem.length > 0) chunks.push(rem)
|
|
17
|
+
return chunks
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SlackAdapter {
|
|
21
|
+
constructor(opts = {}) {
|
|
22
|
+
this.name = 'slack'
|
|
23
|
+
this.botToken = String(opts.botToken || '').trim()
|
|
24
|
+
this.webhookUrl = String(opts.webhookUrl || '').trim()
|
|
25
|
+
this.logger = opts.logger
|
|
26
|
+
this.stopped = false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async start() {
|
|
30
|
+
this.stopped = false
|
|
31
|
+
if (!this.botToken && !this.webhookUrl) {
|
|
32
|
+
this.logger?.warn?.('dsh-messenger-gateway: slack adapter enabled but neither botToken nor webhookUrl provided')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
stop() {
|
|
37
|
+
this.stopped = true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async sendTo(channelId, payload, opts = {}) {
|
|
41
|
+
if (this.stopped) throw new Error('slack adapter stopped')
|
|
42
|
+
const body = typeof payload === 'string' ? { text: payload } : (payload || {})
|
|
43
|
+
const text = String(body.text || '')
|
|
44
|
+
const chunks = splitSlackText(text)
|
|
45
|
+
if (!chunks.length) return { ok: true }
|
|
46
|
+
|
|
47
|
+
const isWebhookTarget = !channelId || channelId === 'default' || channelId === 'webhook'
|
|
48
|
+
if (this.webhookUrl && isWebhookTarget) {
|
|
49
|
+
for (const chunk of chunks) {
|
|
50
|
+
const res = await fetch(this.webhookUrl, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ text: chunk }),
|
|
54
|
+
})
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const errText = await res.text().catch(() => '')
|
|
57
|
+
throw new Error(`slack webhook error ${res.status}: ${errText}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { ok: true }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!this.botToken) {
|
|
64
|
+
throw new Error('slack botToken required to send to specific channels')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const targetChannel = String(channelId || '').trim()
|
|
68
|
+
if (!targetChannel) throw new Error('slack channelId required')
|
|
69
|
+
|
|
70
|
+
const threadTs = opts.threadId || undefined
|
|
71
|
+
for (const chunk of chunks) {
|
|
72
|
+
const res = await fetch(`${SLACK_API}/chat.postMessage`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${this.botToken}`,
|
|
76
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
channel: targetChannel,
|
|
80
|
+
text: chunk,
|
|
81
|
+
thread_ts: threadTs,
|
|
82
|
+
}),
|
|
83
|
+
})
|
|
84
|
+
const json = await res.json().catch(() => ({}))
|
|
85
|
+
if (!res.ok || json.ok === false) {
|
|
86
|
+
throw new Error(`slack chat.postMessage error: ${json.error || res.status}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ok: true }
|
|
90
|
+
}
|
|
91
|
+
}
|
package/lib/adapters/telegram.js
CHANGED
|
@@ -16,6 +16,19 @@ import { isResendSafeNetworkError, isPollingConflict } from '../telegram-errors.
|
|
|
16
16
|
const API = 'https://api.telegram.org'
|
|
17
17
|
const TELEGRAM_MAX = 4096
|
|
18
18
|
|
|
19
|
+
export function buildQuickActionsKeyboard() {
|
|
20
|
+
return {
|
|
21
|
+
keyboard: [
|
|
22
|
+
[{ text: '🔄 /new' }, { text: '🛑 /stop' }],
|
|
23
|
+
[{ text: '🎙️ /voice' }, { text: '📊 /status' }],
|
|
24
|
+
],
|
|
25
|
+
resize_keyboard: true,
|
|
26
|
+
is_persistent: true,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const REMOVE_REPLY_KEYBOARD = { remove_keyboard: true }
|
|
31
|
+
|
|
19
32
|
export class TelegramAdapter {
|
|
20
33
|
constructor(opts) {
|
|
21
34
|
this.name = 'telegram'
|
|
@@ -34,6 +47,8 @@ export class TelegramAdapter {
|
|
|
34
47
|
this.groupsEnabled = opts.groupsEnabled !== false
|
|
35
48
|
this.groupRequireMention = opts.groupRequireMention !== false
|
|
36
49
|
this.reactionsEnabled = opts.reactionsEnabled !== false
|
|
50
|
+
this.quickActions = opts.quickActions !== false
|
|
51
|
+
this.artifactPreviews = opts.artifactPreviews !== false
|
|
37
52
|
this.transport = opts.transport === 'webhook' ? 'webhook' : 'poll'
|
|
38
53
|
this.statusIndicator = opts.statusIndicator === true
|
|
39
54
|
this.statusOnline = String(opts.statusOnline || 'Online')
|
|
@@ -225,6 +240,13 @@ export class TelegramAdapter {
|
|
|
225
240
|
return this.call('editMessageText', { chat_id: chatId, message_id: messageId, text, reply_markup: replyMarkup })
|
|
226
241
|
}
|
|
227
242
|
},
|
|
243
|
+
editReplyMarkup: async (replyMarkup) => {
|
|
244
|
+
return this.call('editMessageReplyMarkup', {
|
|
245
|
+
chat_id: chatId,
|
|
246
|
+
message_id: messageId,
|
|
247
|
+
reply_markup: replyMarkup,
|
|
248
|
+
})
|
|
249
|
+
},
|
|
228
250
|
}
|
|
229
251
|
}
|
|
230
252
|
|
|
@@ -487,12 +509,15 @@ export class TelegramAdapter {
|
|
|
487
509
|
const { text: formatted, parseMode } = this.formatOutgoingText(text, payload)
|
|
488
510
|
const chunks = splitText(formatted, TELEGRAM_MAX)
|
|
489
511
|
const plainChunks = splitText(text, TELEGRAM_MAX)
|
|
512
|
+
const effectiveMarkup = (replyMarkup === undefined && this.quickActions && !threadId)
|
|
513
|
+
? buildQuickActionsKeyboard()
|
|
514
|
+
: replyMarkup
|
|
490
515
|
for (let i = 0; i < chunks.length; i++) {
|
|
491
516
|
const params = {
|
|
492
517
|
chat_id: chatId,
|
|
493
518
|
text: chunks[i],
|
|
494
519
|
reply_to_message_id: replyTo,
|
|
495
|
-
reply_markup: i === 0 ?
|
|
520
|
+
reply_markup: i === 0 ? effectiveMarkup : undefined,
|
|
496
521
|
...telegramThreadParams(threadId),
|
|
497
522
|
}
|
|
498
523
|
if (parseMode) params.parse_mode = parseMode
|
|
@@ -505,7 +530,7 @@ export class TelegramAdapter {
|
|
|
505
530
|
chat_id: chatId,
|
|
506
531
|
text: plainChunks[i] ?? chunks[i],
|
|
507
532
|
reply_to_message_id: replyTo,
|
|
508
|
-
reply_markup: i === 0 ?
|
|
533
|
+
reply_markup: i === 0 ? effectiveMarkup : undefined,
|
|
509
534
|
...telegramThreadParams(threadId),
|
|
510
535
|
})
|
|
511
536
|
}
|
package/lib/alerts.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { escapeHtml } from './telegram-format.js'
|
|
2
|
+
import { normalizeThreadId } from './topics.js'
|
|
3
|
+
|
|
4
|
+
export function formatAlertMessage(type, payload = {}) {
|
|
5
|
+
const timestamp = new Date().toLocaleTimeString()
|
|
6
|
+
|
|
7
|
+
if (type === 'pairing') {
|
|
8
|
+
const { userId, username, code } = payload
|
|
9
|
+
const userStr = username ? `@${username} (id: <code>${userId}</code>)` : `id: <code>${userId}</code>`
|
|
10
|
+
return [
|
|
11
|
+
`🔐 <b>[Запрос сопряжения]</b> <i>(${timestamp})</i>`,
|
|
12
|
+
'',
|
|
13
|
+
`Пользователь: ${userStr}`,
|
|
14
|
+
`Код доступа: <code>${code}</code>`,
|
|
15
|
+
'',
|
|
16
|
+
`Для одобрения отправьте боту:`,
|
|
17
|
+
`<code>/pair ${code}</code>`,
|
|
18
|
+
].join('\n')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (type === 'error') {
|
|
22
|
+
const { message, code, sessionId, chatId, threadId } = payload
|
|
23
|
+
const location = chatId ? `Чат: <code>${chatId}</code>${threadId ? ` / топик <code>${threadId}</code>` : ''}` : ''
|
|
24
|
+
const sess = sessionId ? `Сессия: <code>${sessionId}</code>` : ''
|
|
25
|
+
const meta = [location, sess].filter(Boolean).join('\n')
|
|
26
|
+
|
|
27
|
+
return [
|
|
28
|
+
`🚨 <b>[Ошибка шлюза]</b> <i>(${timestamp})</i>`,
|
|
29
|
+
meta ? `\n${meta}` : '',
|
|
30
|
+
`Ошибка: <b>${escapeHtml(String(code || 'error'))}</b>`,
|
|
31
|
+
`<code>${escapeHtml(String(message || 'unknown error'))}</code>`,
|
|
32
|
+
].filter(Boolean).join('\n')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (type === 'status') {
|
|
36
|
+
const { title, details } = payload
|
|
37
|
+
return [
|
|
38
|
+
`⚡ <b>[Шлюз: ${escapeHtml(title || 'Статус')}]</b> <i>(${timestamp})</i>`,
|
|
39
|
+
details ? `\n${escapeHtml(details)}` : '',
|
|
40
|
+
].filter(Boolean).join('\n')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return `🔔 <b>[Алерт: ${type}]</b> <i>(${timestamp})</i>\n${escapeHtml(JSON.stringify(payload))}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveAlertTarget(gateway) {
|
|
47
|
+
const alertsCfg = gateway?.config?.telegram?.alerts
|
|
48
|
+
if (!alertsCfg || alertsCfg.enabled === false) return null
|
|
49
|
+
|
|
50
|
+
// If a named home is specified
|
|
51
|
+
if (alertsCfg.home) {
|
|
52
|
+
const home = gateway.resolveHomeTarget('telegram', alertsCfg.home)
|
|
53
|
+
if (home) return home
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const chatId = alertsCfg.chatId
|
|
57
|
+
if (!chatId) return null
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
platform: 'telegram',
|
|
61
|
+
chatId,
|
|
62
|
+
threadId: normalizeThreadId(alertsCfg.threadId),
|
|
63
|
+
}
|
|
64
|
+
}
|