@soffits/dsh-telegram-bot 0.0.0-stage → 0.4.1
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/CREDITS.md +24 -0
- package/LICENSE +22 -0
- package/MAINTAINING.md +39 -0
- package/README.md +293 -2
- package/bin/dsh-telegram-send.js +110 -0
- package/client.js +809 -0
- package/cordis.patch.yml +8 -0
- package/docs/bot-setting.gif +0 -0
- package/icon.svg +12 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/package.json +72 -4
- package/src/admission.js +21 -0
- package/src/app.js +6371 -0
- package/src/client.js +809 -0
- package/src/config.js +73 -0
- package/src/constants.js +80 -0
- package/src/errors.js +16 -0
- package/src/final-text.js +45 -0
- package/src/formatting.js +390 -0
- package/src/index.js +24 -0
- package/src/message-context.js +339 -0
- package/src/models.js +242 -0
- package/src/outbox.js +160 -0
- package/src/sent-log.js +112 -0
- package/src/state.js +49 -0
- package/src/status.js +43 -0
- package/src/steer.js +104 -0
- package/src/stickers.js +185 -0
- package/src/topics.js +211 -0
- package/src/update-ledger.js +58 -0
- package/src/utils.js +139 -0
- package/src/voice.js +151 -0
package/CREDITS.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Credits
|
|
2
|
+
|
|
3
|
+
## telegramify-markdown
|
|
4
|
+
|
|
5
|
+
Markdown → Telegram MarkdownV2 conversion is provided by
|
|
6
|
+
[telegramify-markdown](https://github.com/skoropadas/telegramify-markdown),
|
|
7
|
+
licensed under the MIT License.
|
|
8
|
+
|
|
9
|
+
It is built on [Unified](https://github.com/unifiedjs/unified) and
|
|
10
|
+
[Remark](https://github.com/remarkjs/remark/).
|
|
11
|
+
|
|
12
|
+
## soffits/dsh-telegram-bot
|
|
13
|
+
|
|
14
|
+
Parts of the Telegram bridge are adapted from the
|
|
15
|
+
[soffits/dsh-telegram-bot](https://github.com/soffits/dsh-telegram-bot) fork,
|
|
16
|
+
licensed under the MIT License:
|
|
17
|
+
|
|
18
|
+
- reply/quote/forward context — `src/message-context.js` and its tests;
|
|
19
|
+
- sending into topics by thread id — `src/topics.js`, `src/outbox.js` and the
|
|
20
|
+
`dsh-telegram-send` command;
|
|
21
|
+
- chat-wide command handling in All — the command-topic helper.
|
|
22
|
+
|
|
23
|
+
The cherry-picked commits keep their original authorship and include the
|
|
24
|
+
`(cherry picked from commit ...)` line.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boobaw
|
|
4
|
+
Copyright (c) 2026 Sakina
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/MAINTAINING.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Maintaining this fork
|
|
2
|
+
|
|
3
|
+
This repository is a maintained fork of [Boobaw/dsh-telegram-bot](https://github.com/Boobaw/dsh-telegram-bot). It adds:
|
|
4
|
+
|
|
5
|
+
- `/new`: a fresh session in the same topic and workspace.
|
|
6
|
+
- User-created topics: each new topic asks for its workspace and a new or existing session; `/workspace` switches; topic names are never changed after session titles.
|
|
7
|
+
- Two-way sync: turns started in the Web UI, by scheduled tasks or by other plugins also appear in the bound topic (`mirrorTurns`).
|
|
8
|
+
- Sending into topics by thread id: the `telegramTopics` Host service, the `telegram_send` tool and the `dsh-telegram-send` command, with the sender (job, session) recorded and remembered, so replies to those messages carry the full original. Every send, the bound session's own included, is recorded in the bound session as a `user/message` (`telegram-post:` request id, which the mirror skips) with its pictures, files and sticker image as attachments, because DSH has no non-model entry a plugin may append (new event types make a session unreadable) and a plain user message is what the Web UI draws with images. `recordMessage` appends it to an idle session and queues it otherwise.
|
|
9
|
+
- Sessions the bot creates are never left blank (DSH's New conversation would reuse them): see `claimForTopic` in `src/app.js`.
|
|
10
|
+
- Topic session titles are `<topic name> · <DSH title>`: DSH has no title hook, so `syncTopicTitle` re-appends each generated `session/title` with the prefix and the same source (automatic titling keeps running), and `startTopicTitle` calls `sessionTitle.refresh` at the first model request, because the bare-name placeholder is a user title that stops automatic titling. Topic renames record a `telegram-topic` notice as a `user/message` (`recordNote`): session search indexes only message, tool and todo events, not titles or queued inbox items.
|
|
11
|
+
- Deleted topics are forgotten as soon as any Bot API call reports them gone (`forgetGoneTopic`).
|
|
12
|
+
- Chat-wide commands sent in All are answered in All; the topic Telegram made for them is deleted.
|
|
13
|
+
- Sender labels: each Telegram prompt names who wrote it.
|
|
14
|
+
- Voice messages, audio and round videos: downloaded, transcribed (DSH speech-to-text or a configured command) and passed with their file path.
|
|
15
|
+
- Messages sent during a running turn are steered into it (`src/steer.js`): text through `sessionController.prompt({ mode: 'steer' })`, the Web UI's own call, and prompts with stored attachments through `agent.steer`, the method that mode uses. DSH keeps the turn going while its next-step inbox holds input and starts a new turn when input is left after `turn/end`, so a message that lands as the turn ends is still answered; `chat.steerFollow` shows that turn in the topic even with `mirrorTurns` off, and is cleared at the first `turn/end` that leaves the inbox empty. Prompts reach DSH in order (`admitInOrder`). `busyMessages: queue` keeps upstream's bot-side queue.
|
|
16
|
+
- Stickers: received ones are attached as images (thumbnail or first frame for animated and video stickers) and kept in a library (`src/stickers.js`) with the agent's meaning notes; the `telegram_sticker` tool finds, views, notes, loads sets and sends them through `sendToTopic`.
|
|
17
|
+
- Goal rounds use upstream's `adoptGoalTurn` path (thinking, tools, answer and the goal panel, also with `mirrorTurns` off). The Host-wide mirror can see a goal round's `turn/start` before its goal prompt; `adoptGoalTurn` then takes the turn over, so it is shown once.
|
|
18
|
+
- Models (`src/models.js`): the session's agent owns its model (`agent.model.current()`); the bot keeps no copy, because a copy drifted on fallback switches and Web UI picks and was carried into `/new`. Sessions are created and resumed through `sessionController.create` / `resolveAgent` (the Web UI's own composition); the raw `agents` registry is only the headless fallback. The picker reads `sessionController.modelCatalog()` and applies picks with `sessionController.selectModel`, which also saves the default, as a Web pick does. `/new` starts the new session on the previous session's current model.
|
|
19
|
+
- Lifecycle notices go to a chat fixed by config (`controlChatOf` in `src/utils.js`: `controlChat`, else the first allowed user's private chat). Upstream moves its control chat to whoever wrote last and stores it in `topics.json`; the fork dropped that file.
|
|
20
|
+
- Final replies keep only the answer (`src/final-text.js`): text written between tool calls streams as a live draft, but at the turn's end the reply is rebuilt from the session's durable messages, keeping the last text block of each message that called no tool. Mirrored turns use the same rule.
|
|
21
|
+
- Reply, quote and forward context: the agent sees what a message replies to, quotes or forwards, and forwards sent together become one prompt.
|
|
22
|
+
|
|
23
|
+
Upstream changes are merged in, not rebased, so the history of both lines stays intact.
|
|
24
|
+
|
|
25
|
+
## Merging upstream
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git remote add upstream https://github.com/Boobaw/dsh-telegram-bot.git # once
|
|
29
|
+
git fetch upstream
|
|
30
|
+
git switch main
|
|
31
|
+
git merge upstream/main
|
|
32
|
+
npm install
|
|
33
|
+
npm test
|
|
34
|
+
git push origin main
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Conflicts usually land in `src/app.js` (most of the bridge lives there), `README.md` and the `name` and `version` lines of `package.json`. Keep the fork's own name and version: it is published on npm as `@soffits/dsh-telegram-bot` with its own semver line (0.4.0 was the first release, on top of upstream 0.3.0), so bump the minor version for features and upstream merges and the patch version for fixes, and name the merged upstream version in the merge or release commit. Make sure `client.js` still matches `src/client.js` after the merge (`cmp client.js src/client.js`).
|
|
38
|
+
|
|
39
|
+
Fork-only code is kept in separate files where that is natural (`src/topics.js`, `src/outbox.js`, `src/message-context.js`, `src/sent-log.js`, `src/voice.js`, `src/steer.js`, `src/models.js`, `src/final-text.js`, `bin/dsh-telegram-send.js`, `test/fake-dsh.js`, `test/fork-features.test.js`, `test/message-context.test.js`, `test/sent-log.test.js`, `test/steer.test.js`, `test/models.test.js`, `test/final-text.test.js`, `test/voice.test.js`), so most upstream edits apply without touching it.
|
package/README.md
CHANGED
|
@@ -1,3 +1,294 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-telegram-bot
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
> This is a maintained fork of [Boobaw/dsh-telegram-bot](https://github.com/Boobaw/dsh-telegram-bot) with `/new`, user-created topics with a workspace picker, two-way sync, sending into topics by thread id, and reply, quote and forward context. See [MAINTAINING.md](MAINTAINING.md) for what differs and how upstream changes are merged in.
|
|
4
|
+
|
|
5
|
+
Telegram client for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). It connects a DSH profile to a Telegram bot and keeps the same DSH sessions available in both interfaces.
|
|
6
|
+
|
|
7
|
+
## Demo
|
|
8
|
+
|
|
9
|
+
First setup: https://youtu.be/QEeVucfBsWk
|
|
10
|
+
|
|
11
|
+
Installation, bot token, the allowlist, `/start`, `/sessions` and a test prompt.
|
|
12
|
+
|
|
13
|
+
### Enable Threaded mode
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="docs/bot-setting.gif">
|
|
17
|
+
<img src="docs/bot-setting.gif" alt="BotFather: Bot Settings → Threaded mode → Enable" width="320">
|
|
18
|
+
</a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
BotFather: **Bot Settings → Threaded mode → Enable**. Leave **Disallow users to create new threads** off: you create a topic for each conversation yourself.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- One Telegram topic per DSH session; Web and Telegram use the same session.
|
|
26
|
+
- You create the topics. A new topic asks which workspace to use, then whether to start a new session or continue an existing one from that workspace. The message that opened the topic runs once the session is chosen.
|
|
27
|
+
- Topic names are yours: the bot never renames a topic after the session title. `/rename` renames it on request.
|
|
28
|
+
- A topic's session is titled `<topic name> · <what the conversation is about>` in DSH, for example `Chat-1 · Fix empty Telegram sessions`. The second part is DSH's own title (its title model, or the first words of the first message); until it exists the title is just the topic name. Renaming the topic changes only the first part. A title someone types for the session is left alone.
|
|
29
|
+
- Renaming a topic also records a short note in its session, such as `Telegram topic renamed: "Old" -> "New" (thread 123)`, so searching earlier sessions finds it under either name.
|
|
30
|
+
- A session the bot creates for a topic is never handed out as a blank session. DSH's **New conversation** reuses a session that has not started a turn yet; the bot therefore starts one empty turn as soon as it creates a session, and stops it before any model request. Without this, a topic session waiting for its first message could be picked up by the Web UI, and that Web chat would be mirrored into the topic.
|
|
31
|
+
- Deleted topics clean themselves up: when Telegram answers any call for a topic with "message thread not found", `TOPIC_ID_INVALID` or `TOPIC_DELETED`, the bot drops that topic's session binding and name and logs it. The session stays in DSH. A closed topic keeps its binding, since it can be reopened.
|
|
32
|
+
- `/workspace` moves the topic to another workspace: pick the workspace, then a new or existing session there. A session's working directory is fixed when it is created, so the previous session is not continued in this topic; it stays in DSH and can be bound again later as an existing session.
|
|
33
|
+
- Two-way sync: turns started anywhere else (Web UI, scheduled tasks, other plugins) also appear in the session's topic. The prompt is shown with a **From Web** or **Scheduled task** label, followed by the streamed answer. Messages sent from Telegram are not echoed back, and context that plugins add on their own (time, runtime notes) is not shown. A Telegram message sent while such a turn runs waits for it. Approvals and questions from those turns are answered where the turn started. Turn this off with **Show turns from other clients**.
|
|
34
|
+
- `/sessions` lists workspaces and existing sessions, and can create a new session in a selected workspace.
|
|
35
|
+
- Assistant answers stream into the topic with Telegram Markdown formatting. A message sent while the agent is working (text, photos, files, voice, forwards, replies) goes into the running turn, the way a message typed into the DSH Web UI during a turn does: the agent sees it at its next step, and the message gets a 👀 reaction (a short note where reactions are unavailable). If the turn ends just as the message arrives, DSH starts a new turn for it, and that turn's answer appears in the topic. `/clear` removes Telegram messages the agent has not picked up yet without stopping the current answer. Set `busyMessages: queue` to hold such messages until the turn ends instead.
|
|
36
|
+
- Files the agent shows in the Web UI also reach the topic: files it hands over with `present`, and local images in its final answer (``), are uploaded as photos (JPEG, PNG, WebP up to 10 MB) or documents (up to 50 MB) and logged like `telegram_send` messages. A file already sent to the topic earlier in the same turn (by `telegram_send` from that session, by `present`, or in the answer) is not uploaded again. This follows **Show turns from other clients** for turns started elsewhere.
|
|
37
|
+
- Automatic goal rounds of a topic's session are shown in the topic like a Telegram turn, with thinking, tool calls and the final answer, also when **Show turns from other clients** is off. Their approvals and questions are answered in the topic.
|
|
38
|
+
- While a turn runs in a topic's session (started from Telegram or shown from another client), that topic shows "typing…". Each topic shows its own; it stops when the turn ends, fails or is stopped.
|
|
39
|
+
- Reasoning and tool calls are shown as temporary messages and removed after the answer. Each tool-call entry is capped at two lines.
|
|
40
|
+
- Inline controls for model, reasoning effort, permission mode and context usage. The model picker offers every provider and model of the Web UI's model picker (provider first, then its models, with paging) and switches the session the way the Web UI does, which also makes the pick the default for new sessions. The panel always shows the model the session runs now; `/new` starts the next session on that model.
|
|
41
|
+
- Tool approvals with **Deny** / **Allow** buttons.
|
|
42
|
+
- `ask_user_question` support: options, multi-select, custom answers and plan review.
|
|
43
|
+
- Documents and photos sent in a session topic are saved as DSH attachments and passed to the agent.
|
|
44
|
+
- Voice messages, audio files and round video messages are saved as files and transcribed; the agent gets the transcript and the file's path, so it can check a doubtful transcript against the recording. See [Voice messages](#voice-messages).
|
|
45
|
+
- Every prompt from Telegram starts with a line naming the sender, such as `[Telegram message from Ana Silva (@ana, user id 123)]`, so the agent knows who is writing when several allowed users share the bot. Prompts typed in the Web UI are not labelled.
|
|
46
|
+
- Replies, quotes and forwards keep their context. The agent sees the text of the replied-to message (or only the quoted part), who wrote it and when; a replied-to photo or document is attached too. A forwarded message is labelled with its origin (person, hidden account, group or channel with a message link) and original date. Several messages forwarded together become one prompt, and forwarded text never runs as a command. Long quoted text is cut at about 2,000 characters with a note.
|
|
47
|
+
- Replies to messages sent into a topic by a script, job, plugin or another session (see [Send to a topic](#send-to-a-topic)) also carry the full original text, when it was sent, and the sending session and job, even if the topic has moved to a new session since.
|
|
48
|
+
- `/new`, `/workspace`, `/last`, `/goal`, `/rename`, `/topic`, `/fork`, `/clear`, `/stop`, `/restart`, `/status` and `/menu`.
|
|
49
|
+
- `/goal` shows the current DSH goal and the command hints. `/goal <objective>` creates or replaces the goal and starts working immediately; `/goal edit <objective>` updates the objective and resumes a paused, blocked or disarmed goal; `/goal pause`, `/goal resume` and `/goal clear` manage the current goal. `/goal <objective>` and `/goal edit <objective>` replace the current panel with one fresh message; later goal changes edit that message in place. The panel is never pinned.
|
|
50
|
+
- `/new` in a session topic starts a fresh DSH session in the same workspace and binds it to the same topic; the previous session stays in DSH. In All it opens a new session topic.
|
|
51
|
+
- One session is bound to at most one topic: binding it to a new topic releases the old one.
|
|
52
|
+
- `/fork [name]` creates a DSH session fork in a new Telegram topic; without a name it uses the previous title plus the branch number.
|
|
53
|
+
- The session picker also has **🔀 Continue in Telegram fork**; automatic fork topics end with `(N)`.
|
|
54
|
+
- **▶️ Continue in Telegram** from All opens a fresh topic for the session. From inside a topic it continues in that topic. The previous topic stays in Telegram and can be deleted manually; reusing it from All is unsafe because Telegram can keep accepting messages for a topic deleted in the client.
|
|
55
|
+
- English and Russian bot messages.
|
|
56
|
+
- Bot token stays in the DSH credential store.
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- DSH `0.2.1-rc.2` or newer, Node.js 22+.
|
|
61
|
+
- A Telegram bot token from [@BotFather](https://t.me/BotFather).
|
|
62
|
+
- **Threaded mode** enabled in BotFather, **Topics** enabled in the bot chat, and **Disallow users to create new threads** left off.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
From npm:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx @deepseek-ai/dsh plugin --profile web add @soffits/dsh-telegram-bot@latest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
From a clone of this repository:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx @deepseek-ai/dsh plugin --profile web add .
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The unscoped `dsh-telegram-bot` package on npm is the upstream release without this fork's features.
|
|
79
|
+
|
|
80
|
+
Restart `dsh web` after installing or updating the plugin. Then open **Settings → Telegram**.
|
|
81
|
+
|
|
82
|
+
## Configure
|
|
83
|
+
|
|
84
|
+
| Field | Meaning |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| Bot token | Token from @BotFather; stored in the credential store |
|
|
87
|
+
| `tokenRef` | Credential/env name for the token; default `TELEGRAM_BOT_TOKEN` |
|
|
88
|
+
| `cwd` | Working directory for Telegram-created sessions |
|
|
89
|
+
| `agentPreset` | Preset mounted for Telegram sessions; default `standard` |
|
|
90
|
+
| Allowed users | Telegram user ids or `@usernames` |
|
|
91
|
+
| Allowed chats | Telegram chat ids or `@channelnames` |
|
|
92
|
+
| `controlChat` | Chat id or `@channelname` for lifecycle notices ("Telegram bot enabled", Threaded mode warnings); empty means the private chat of the first allowed user |
|
|
93
|
+
| Show model thinking | Show reasoning in a temporary message |
|
|
94
|
+
| Show tool calls | Show tool calls in a temporary message |
|
|
95
|
+
| Show turns from other clients (`mirrorTurns`) | Mirror turns started outside Telegram into the bound topic; default on |
|
|
96
|
+
| `busyMessages` | `steer` (default): messages sent during a turn go into it; `queue`: they wait until the turn ends |
|
|
97
|
+
| `transcriber` | `dsh` (default), `command` or `off`; see [Voice messages](#voice-messages) |
|
|
98
|
+
| `transcribeLanguage` | Language hint for DSH speech-to-text, such as `zh` or `en`; empty uses its own setting |
|
|
99
|
+
| `transcribeCommand` | Program and arguments for `transcriber: command` |
|
|
100
|
+
| `ffmpegPath` | ffmpeg used to decode recordings for DSH speech-to-text; default `ffmpeg` |
|
|
101
|
+
| Bot language | `en` (default) or `ru` |
|
|
102
|
+
| Enable bot | Start or pause Telegram polling |
|
|
103
|
+
|
|
104
|
+
The voice fields, `controlChat` and `busyMessages` have no controls on the settings page yet; set them in the profile's `cordis.patch.yml` under the `telegram-bot` row's `config`.
|
|
105
|
+
|
|
106
|
+
The token field only reports whether a token is configured. A token supplied through the environment cannot be overwritten from the page.
|
|
107
|
+
|
|
108
|
+
## First run
|
|
109
|
+
|
|
110
|
+
1. Create a bot with @BotFather and save its token in **Settings → Telegram**.
|
|
111
|
+
2. In @BotFather open **Bot Settings → Threaded mode** and enable it. Leave **Disallow users to create new threads** off.
|
|
112
|
+
3. Open the bot chat, tap the bot name and enable **Topics**.
|
|
113
|
+
4. Send `/start` in **All**. With an empty allowlist the bot replies with your user and chat ids.
|
|
114
|
+
5. Add your user id to **Allowed users** and press **Save**. List your own id first: lifecycle notices go to the first allowed user's private chat unless `controlChat` names another chat. If the first entry is a `@username`, set `controlChat`, since a username has no chat id the bot can send to.
|
|
115
|
+
6. Create a topic and send a message in it. Choose a workspace, then **New session** or one of the listed sessions. Your message runs in that session.
|
|
116
|
+
|
|
117
|
+
A message sent in **All** makes Telegram create a topic for it; the bot treats it like any new topic and asks for the workspace (if Telegram delivers it to All instead, the bot opens a new topic in the default workspace). Chat-wide commands are the exception: when `/start`, `/help`, `/sessions`, `/status`, `/menu`, `/id`, `/ping` or `/restart` is sent in **All** and Telegram makes a topic for it, the bot answers in **All** and deletes that topic. If the topic cannot be deleted, it leaves a short note there. `/sessions` in **All** still opens existing sessions in new topics, and commands such as `/fork` and `/last` work as before.
|
|
118
|
+
|
|
119
|
+
Topics bound by an earlier version keep their sessions after an upgrade.
|
|
120
|
+
|
|
121
|
+
## Delete topics
|
|
122
|
+
|
|
123
|
+
- `/topic delete <name or thread id>` deletes one known topic and its Telegram messages. Numeric targets must be real topic thread ids; ids larger than 2^31 (for example a user id) are rejected. Send `/id` inside the topic to read its id.
|
|
124
|
+
- `/topic wipe` asks for confirmation first; send `/topic wipe confirm` to delete every topic known to the bot in this chat. DSH sessions themselves are not deleted.
|
|
125
|
+
|
|
126
|
+
Topics are known after the bot creates them, after `/rename`, or after Telegram reports a topic creation/rename. Unknown topics cannot be listed through the Bot API.
|
|
127
|
+
|
|
128
|
+
## Troubleshooting topics
|
|
129
|
+
|
|
130
|
+
The bot checks `has_topics_enabled` and `allows_users_to_create_topics` on startup, writes them to `~/.dsh/telegram/status.json`, and posts a warning in the control chat when Threaded mode is off or users cannot create topics.
|
|
131
|
+
|
|
132
|
+
A correct configuration contains:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
"threadedMode": true,
|
|
136
|
+
"usersCanCreateTopics": true
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Topic creation is single-shot: a network timeout is never retried, because retrying a `createForumTopic` that Telegram already committed creates a duplicate topic. Concurrent open requests for the same session are coalesced.
|
|
140
|
+
|
|
141
|
+
Private-chat topics send with `message_thread_id`. If Telegram answers `message thread not found`, the bot retries once with `direct_messages_topic_id` (the Bot API 10 direct-message-topic field). A send that Telegram accepts without returning any topic field is treated as undelivered, so a deleted topic is recreated instead of silently receiving messages in All.
|
|
142
|
+
|
|
143
|
+
## Send to a topic
|
|
144
|
+
|
|
145
|
+
Scripts, scheduled jobs, other plugins and agents can post into a topic by its numeric thread id (`/id` in the topic shows it). The message (text, files, a sticker) appears in the topic and is also recorded in the session bound to that topic, without starting a turn, labelled with who sent it. This holds for the bound session's own sends too, so a Web UI reader of that session sees the same pictures, files and stickers: pictures and stickers (as their still image) are recorded as images, other files as file attachments. An idle session gets the record at once; a busy one gets it at its next step. Thread ids belong to one chat, so the message goes to the chat the bot saw that thread in (its binding or known name), not the chat that last wrote to the bot. A thread id the bot does not know in any chat is an error; nothing is created. In the rare case two chats have a topic with the same thread id, the send fails until the caller names the chat (`chat`, `--chat`). Topic names are only labels, never addresses. A known topic with no session gets a new one first, made the way `/new` makes it (the configured working directory and preset, titled after the topic), so the message is recorded; only a topic whose user is still choosing its session just shows the message, and the result says so (`note`). If no session can take the record, the message is still sent and the result carries `recordError`.
|
|
146
|
+
|
|
147
|
+
The bot also remembers each such send (`$DSH_HOME/telegram/sent-messages.json`: the last 1,000 sends from the past 180 days, text up to 20,000 characters). When someone replies to or quotes one of those messages, the agent gets the whole original text, however many Telegram messages it was split into, with its send time, job name and sending session, and a hint to open that session for more context. This works after `/new` too, when the current session never saw the message.
|
|
148
|
+
|
|
149
|
+
The bot learns a topic's name when it creates the topic, when Telegram reports a topic being created or renamed, and on `/rename`, and uses it in the labels of recorded messages and in session titles.
|
|
150
|
+
|
|
151
|
+
From a shell (no bot token needed):
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
dsh-telegram-send --thread 12345 "Backup finished"
|
|
155
|
+
some-report | dsh-telegram-send --thread 12345 --job nightly-report
|
|
156
|
+
dsh-telegram-send --thread 12345 --file /tmp/report.pdf "Monthly report"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Who sent it: `--job <name>` names the job or script, and `--from-session <id>` the DSH session. Inside a DSH session `--from-session` defaults to `$DSH_SESSION_ID`; pass `--from-session ''` to leave it out. `--from <label>` is a free-form label for senders that are neither. When the sending session is the one bound to the topic, it gets no second copy.
|
|
160
|
+
|
|
161
|
+
Long text is split into several Telegram messages. Every `--file` is sent after the text, one message per file.
|
|
162
|
+
|
|
163
|
+
The command writes the request into `$DSH_HOME/telegram/outbox/` and waits up to `--timeout` seconds (default 30) for the plugin's answer. Exit codes: `0` sent, `1` failed (the error is printed, for example an unknown thread id), `2` bad arguments, `3` still queued (the bot sends it once it runs). `--no-wait` returns right after queueing. The spool keeps requests while DSH is stopped, which is why it is a directory and not a socket; a request interrupted by a restart is sent again. The command is installed as the package's `bin`; link it onto your `PATH` if needed (`node_modules/.bin/dsh-telegram-send` in the profile).
|
|
164
|
+
|
|
165
|
+
From another Host plugin:
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
const result = await ctx.get('telegramTopics').send({
|
|
169
|
+
to: '12345', // the topic's thread id
|
|
170
|
+
text: 'Backup **finished**',
|
|
171
|
+
files: ['/tmp/report.pdf'], // absolute paths, optional
|
|
172
|
+
from: { job: 'backup', sessionId: 'session-…' }, // optional; `label` for other senders
|
|
173
|
+
})
|
|
174
|
+
// { chatId, threadId, topic, sessionId, recorded, messageIds }, plus note or recordError when it was not recorded
|
|
175
|
+
ctx.get('telegramTopics').topics() // [{ chatId, threadId, name, sessionId }]: known topics in every chat, name may be null
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`send` rejects when the message was not sent. Errors that a retry cannot fix (an unknown thread id, a topic that was deleted, a missing or oversized file, nothing to send, or a failure after part of a long message already went out) carry `retryable: false`; others, such as the bot not running yet, are worth trying again. This is the shape [dsh-cron-jobs](https://github.com/soffits/dsh-cron-jobs) expects from a delivery channel, so `deliver: { via: telegramTopics, to: 12345 }` sends scheduled job results into the topic, with the job and its session named as the sender.
|
|
179
|
+
|
|
180
|
+
From an agent: the `telegram_send` tool takes `thread` (the thread id), `text`, `files` (absolute paths) and, only when two chats share the thread id, `chat`. The calling session is named as the sender and does not get a copy of its own message.
|
|
181
|
+
|
|
182
|
+
Text is Markdown, converted for Telegram like assistant answers. JPEG, PNG and WebP files up to 10 MB are sent as photos, other files as documents (up to 50 MB).
|
|
183
|
+
|
|
184
|
+
## Stickers
|
|
185
|
+
|
|
186
|
+
A sticker sent to the bot (also when replied to) reaches the agent as an image with a note naming its emoji, set and id, plus the meaning saved for it, if any. Static stickers are passed as they are; animated (`.tgs`) and video (`.webm`) stickers use Telegram's thumbnail, or for a video sticker without one, its first frame (ffmpeg, `ffmpegPath`). An animated sticker without a thumbnail comes as the note only.
|
|
187
|
+
|
|
188
|
+
Every received sticker is kept in a sticker library (`$DSH_HOME/telegram/stickers.json`, previews in `sticker-previews/`), keyed by Telegram's `file_unique_id`, with the `file_id` needed to send it again. The agent's `telegram_sticker` tool works with it:
|
|
189
|
+
|
|
190
|
+
| Action | Does |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `find` | Search by words in the saved meaning, emoji, set name or title (`query`, `emoji`, `set`, `limit`) |
|
|
193
|
+
| `view` | Show one sticker (`id`) as an image |
|
|
194
|
+
| `note` | Save what a sticker means and when to use it (`id`, `meaning`) |
|
|
195
|
+
| `add_set` | Add every sticker of a set (`set`: its name or `t.me/addstickers/` link) |
|
|
196
|
+
| `send` | Send a sticker (`id`) into a topic (`thread`); recorded in the bound session and the send log like `telegram_send` |
|
|
197
|
+
|
|
198
|
+
A Telegram turn that already sent its own topic a sticker, file or message and ends with no text posts nothing more, so a sticker can be the whole reply. A turn that sent nothing and has no text still ends with **✅ Done.**
|
|
199
|
+
|
|
200
|
+
Models often can't end a turn with no text at all (DSH counts an empty answer as a failed `EMPTY_RESPONSE` call and retries or falls back), so a final answer of exactly `[SILENT]` also posts nothing: after a sticker, or alone when the agent chooses to stay quiet. Text that may still turn into `[SILENT]` is held back while it streams, so the marker never shows up in the topic.
|
|
201
|
+
|
|
202
|
+
## Voice messages
|
|
203
|
+
|
|
204
|
+
Voice messages, audio files and round video messages (also when replied to or forwarded) are downloaded, stored as a DSH file attachment and transcribed. The agent receives a note with the transcript, who transcribed it and the stored file's path, followed by the file itself. When transcription fails or is off, the file is still passed on and the note says why. A recording sent without text gets no "analyze the file" prompt: its transcript is the message.
|
|
205
|
+
|
|
206
|
+
- `transcriber: dsh` (default) uses DSH's own speech-to-text service, the one behind voice input in the Web UI (the `@deepseek-ai/dsh-experimental-voice-input-bundle` bundle with a provider such as the local SenseVoice model). Its default provider and language apply; `transcribeLanguage` overrides the language for Telegram. Recordings are decoded to 16 kHz mono WAV with ffmpeg, which must be installed (`ffmpegPath`).
|
|
207
|
+
- `transcriber: command` runs a program of your choice without a shell, for example a Whisper script or a client for a cloud service. `{file}` in `transcribeCommand` is replaced by the recording's path (it is appended when missing), and the transcript is read from standard output. Keys for a cloud service belong in that program's environment or the DSH credential store, not in the plugin config.
|
|
208
|
+
- `transcriber: off` passes only the file.
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
- id: telegram-bot
|
|
212
|
+
config:
|
|
213
|
+
transcriber: command
|
|
214
|
+
transcribeCommand: ["/usr/local/bin/transcribe", "--language", "auto", "{file}"]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Plugins page
|
|
218
|
+
|
|
219
|
+
The plugin page is informational. It points to **Settings → Telegram** and lists the manual configuration files. Use the bundle switch there to enable or disable the plugin; **Enable bot** is a soft pause that keeps the plugin loaded but stops polling.
|
|
220
|
+
|
|
221
|
+
## Headless profile
|
|
222
|
+
|
|
223
|
+
The bot can run without the Web UI:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
mkdir -p ~/.dsh/profiles/tg
|
|
227
|
+
cd ~/.dsh/profiles/tg
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`package.json`:
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"name": "dsh-profile-tg",
|
|
235
|
+
"private": true,
|
|
236
|
+
"dependencies": { "@soffits/dsh-telegram-bot": "latest" },
|
|
237
|
+
"dsh": {
|
|
238
|
+
"profile": {
|
|
239
|
+
"bundles": ["@deepseek-ai/dsh-base", "@soffits/dsh-telegram-bot"]
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`cordis.patch.yml`:
|
|
246
|
+
|
|
247
|
+
```yaml
|
|
248
|
+
- id: telegram-bot
|
|
249
|
+
name: '@soffits/dsh-telegram-bot'
|
|
250
|
+
config:
|
|
251
|
+
enabled: true
|
|
252
|
+
allowedUsers: ["<user-id>"]
|
|
253
|
+
allowedChats: ["<chat-id>"]
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Install and run:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
npm install
|
|
260
|
+
printf 'TELEGRAM_BOT_TOKEN=%s\n' '<token>' >> ~/.dsh/.env
|
|
261
|
+
chmod 600 ~/.dsh/.env
|
|
262
|
+
npx @deepseek-ai/dsh --profile tg
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Do not put the token in `cordis.patch.yml`.
|
|
266
|
+
|
|
267
|
+
## Security
|
|
268
|
+
|
|
269
|
+
- The bot token lives in the credential store, not in the profile patch.
|
|
270
|
+
- Empty **Allowed users** answers only in private chats with the setup hint.
|
|
271
|
+
- Group and channel chats require an entry in **Allowed chats**.
|
|
272
|
+
- Do not run two pollers with the same token; Telegram returns `409 Conflict`.
|
|
273
|
+
- A DSH plugin runs in-process with the Host. Install only source you trust.
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
No build step is needed.
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
npm install
|
|
281
|
+
node --check index.js
|
|
282
|
+
node --check client.js
|
|
283
|
+
npm test
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`src/app.js` is the Host runtime: Telegram polling, topic routing and the session bridge. `src/update-ledger.js` persists Telegram update cursors, `src/admission.js` uses the native DSH session queue. `src/formatting.js`, `src/state.js`, `src/status.js`, `src/utils.js`, `src/errors.js`, `src/config.js` and `src/constants.js` hold the supporting layers. `src/index.js` is the public entry point; root `index.js` is the loader-compatible wrapper. Root `client.js` is the loader-compatible client bundle. `src/client.js` contains the client source. `src/message-context.js` turns replies, quotes and forwards into the labelled context the agent reads. `src/topics.js` resolves send targets to thread ids, picks the topic field for private-chat topics, checks delivery and recognises deleted topics, `src/voice.js` decodes and transcribes recordings, `src/outbox.js` is the file spool behind `bin/dsh-telegram-send.js`, `src/sent-log.js` remembers sent messages for later replies, and `src/steer.js` sends messages into a running turn, and `src/stickers.js` is the sticker library, and `src/models.js` reads a session's live model and the Web model catalog for the model picker. The plugin adds two model-facing tools, `telegram_send` and `telegram_sticker`, and no system-prompt text.
|
|
287
|
+
|
|
288
|
+
## License
|
|
289
|
+
|
|
290
|
+
MIT
|
|
291
|
+
|
|
292
|
+
## Credits
|
|
293
|
+
|
|
294
|
+
Markdown to Telegram formatting uses [telegramify-markdown](https://github.com/skoropadas/telegramify-markdown) (MIT).
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Send a message into a topic of the running DSH Telegram bot.
|
|
4
|
+
*
|
|
5
|
+
* The request goes through the plugin's file spool under $DSH_HOME/telegram,
|
|
6
|
+
* so it needs no bot token, and it waits there while DSH is not running.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { resolve } from 'node:path'
|
|
10
|
+
import { parseArgs } from 'node:util'
|
|
11
|
+
import { enqueue, waitForResult } from '../src/outbox.js'
|
|
12
|
+
|
|
13
|
+
const usage = `Usage: dsh-telegram-send --thread <thread-id> [--chat <chat-id>] [--file <path>]...
|
|
14
|
+
[--job <name>] [--from-session <id>] [--from <label>]
|
|
15
|
+
[--no-wait] [--timeout <seconds>] [text...]
|
|
16
|
+
|
|
17
|
+
Sends text (from the arguments, or from stdin when none are given) and files
|
|
18
|
+
into a topic of the running DSH Telegram bot, addressed by its numeric thread
|
|
19
|
+
id (/id in the topic shows it). The message is also recorded in the session
|
|
20
|
+
bound to that topic, without starting a turn. Long text is split into several
|
|
21
|
+
Telegram messages.
|
|
22
|
+
|
|
23
|
+
--chat <id> chat to send to; only needed when several chats have a
|
|
24
|
+
topic with that thread id (the bot otherwise uses the
|
|
25
|
+
chat it saw the thread in)
|
|
26
|
+
--job <name> name of the job or script sending it
|
|
27
|
+
--from-session <id> DSH session sending it; defaults to $DSH_SESSION_ID
|
|
28
|
+
(set inside DSH sessions), --from-session '' for none
|
|
29
|
+
--from <label> free-form sender label, used when neither is given
|
|
30
|
+
|
|
31
|
+
Exit codes: 0 sent, 1 failed (for example an unknown thread id), 2 bad arguments,
|
|
32
|
+
3 still queued after the timeout (the bot sends it once it runs).`
|
|
33
|
+
|
|
34
|
+
let options
|
|
35
|
+
try {
|
|
36
|
+
options = parseArgs({
|
|
37
|
+
allowPositionals: true,
|
|
38
|
+
options: {
|
|
39
|
+
thread: { type: 'string', short: 't' },
|
|
40
|
+
chat: { type: 'string', short: 'c' },
|
|
41
|
+
file: { type: 'string', short: 'f', multiple: true },
|
|
42
|
+
from: { type: 'string' },
|
|
43
|
+
job: { type: 'string', short: 'j' },
|
|
44
|
+
'from-session': { type: 'string' },
|
|
45
|
+
'no-wait': { type: 'boolean' },
|
|
46
|
+
timeout: { type: 'string' },
|
|
47
|
+
help: { type: 'boolean', short: 'h' },
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
} catch (error) {
|
|
51
|
+
process.stderr.write(`${error.message}\n\n${usage}\n`)
|
|
52
|
+
process.exit(2)
|
|
53
|
+
}
|
|
54
|
+
const { values, positionals } = options
|
|
55
|
+
if (values.help) {
|
|
56
|
+
process.stdout.write(`${usage}\n`)
|
|
57
|
+
process.exit(0)
|
|
58
|
+
}
|
|
59
|
+
if (values.thread === undefined || !/^\d+$/u.test(values.thread.trim())) {
|
|
60
|
+
process.stderr.write(`--thread takes the topic's numeric thread id.\n\n${usage}\n`)
|
|
61
|
+
process.exit(2)
|
|
62
|
+
}
|
|
63
|
+
if (values.chat !== undefined && !/^-?\d+$/u.test(values.chat.trim())) {
|
|
64
|
+
process.stderr.write(`--chat takes a numeric chat id.\n\n${usage}\n`)
|
|
65
|
+
process.exit(2)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let text = positionals.join(' ')
|
|
69
|
+
if (positionals.length === 0 && !process.stdin.isTTY) text = readFileSync(0, 'utf8')
|
|
70
|
+
const files = (values.file ?? []).map((path) => resolve(path))
|
|
71
|
+
if (text.trim().length === 0 && files.length === 0) {
|
|
72
|
+
process.stderr.write(`Nothing to send: give text or --file.\n\n${usage}\n`)
|
|
73
|
+
process.exit(2)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** DSH session ids carry a `session-` prefix; DSH_SESSION_ID may not. */
|
|
77
|
+
function sessionIdOf(value) {
|
|
78
|
+
const id = (value ?? '').trim()
|
|
79
|
+
if (id.length === 0) return undefined
|
|
80
|
+
return id.startsWith('session-') ? id : `session-${id}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const from = {}
|
|
84
|
+
if (values.from !== undefined && values.from.trim().length > 0) from.label = values.from.trim()
|
|
85
|
+
if (values.job !== undefined && values.job.trim().length > 0) from.job = values.job.trim()
|
|
86
|
+
const sessionId = sessionIdOf(values['from-session'] ?? process.env.DSH_SESSION_ID)
|
|
87
|
+
if (sessionId !== undefined) from.sessionId = sessionId
|
|
88
|
+
|
|
89
|
+
const id = await enqueue({
|
|
90
|
+
to: values.thread.trim(),
|
|
91
|
+
...(values.chat === undefined ? {} : { chat: values.chat.trim() }),
|
|
92
|
+
text,
|
|
93
|
+
files,
|
|
94
|
+
...(Object.keys(from).length === 0 ? {} : { from }),
|
|
95
|
+
})
|
|
96
|
+
if (values['no-wait']) {
|
|
97
|
+
process.stdout.write(`${JSON.stringify({ queued: id })}\n`)
|
|
98
|
+
process.exit(0)
|
|
99
|
+
}
|
|
100
|
+
const seconds = Number(values.timeout ?? 30)
|
|
101
|
+
const result = await waitForResult(id, (Number.isFinite(seconds) && seconds > 0 ? seconds : 30) * 1000)
|
|
102
|
+
if (result === undefined) {
|
|
103
|
+
process.stderr.write(`Still queued as ${id}: the Telegram bot did not pick it up in time. It is sent once the bot runs.\n`)
|
|
104
|
+
process.exit(3)
|
|
105
|
+
}
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
process.stderr.write(`${result.error}\n`)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
process.stdout.write(`${JSON.stringify(result.result)}\n`)
|