@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 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 is listed in settings as a placeholder only the Discord adapter is not implemented yet.
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)
@@ -1,6 +1,131 @@
1
- // Discord adapter planned for a future release.
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() { this.name = 'discord' }
4
- async start() { throw new Error('Discord adapter is not implemented yet') }
5
- stop() {}
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
  }
@@ -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
- logger?.warn?.('dsh-messenger-gateway: discord adapter is not implemented yet')
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
+ }
@@ -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 ? replyMarkup : undefined,
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 ? replyMarkup : undefined,
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
+ }