@sympoies/dsh-telegram 0.0.0-bootstrap.0 → 0.6.0
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/LICENSE +1 -2
- package/NOTICE +19 -0
- package/README.md +589 -2
- package/cordis.patch.yml +14 -0
- package/lib/access.d.ts +76 -0
- package/lib/access.d.ts.map +1 -0
- package/lib/access.js +137 -0
- package/lib/access.js.map +1 -0
- package/lib/client.js +835 -0
- package/lib/commands.d.ts +51 -0
- package/lib/commands.d.ts.map +1 -0
- package/lib/commands.js +83 -0
- package/lib/commands.js.map +1 -0
- package/lib/config.d.ts +125 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +97 -0
- package/lib/config.js.map +1 -0
- package/lib/diagnostics.d.ts +58 -0
- package/lib/diagnostics.d.ts.map +1 -0
- package/lib/diagnostics.js +59 -0
- package/lib/diagnostics.js.map +1 -0
- package/lib/failures.d.ts +71 -0
- package/lib/failures.d.ts.map +1 -0
- package/lib/failures.js +139 -0
- package/lib/failures.js.map +1 -0
- package/lib/harness/host.d.ts +106 -0
- package/lib/harness/host.d.ts.map +1 -0
- package/lib/harness/host.js +142 -0
- package/lib/harness/host.js.map +1 -0
- package/lib/harness/message.d.ts +45 -0
- package/lib/harness/message.d.ts.map +1 -0
- package/lib/harness/message.js +28 -0
- package/lib/harness/message.js.map +1 -0
- package/lib/harness/model-selection.d.ts +82 -0
- package/lib/harness/model-selection.d.ts.map +1 -0
- package/lib/harness/model-selection.js +103 -0
- package/lib/harness/model-selection.js.map +1 -0
- package/lib/harness/questions-seam.d.ts +34 -0
- package/lib/harness/questions-seam.d.ts.map +1 -0
- package/lib/harness/questions-seam.js +45 -0
- package/lib/harness/questions-seam.js.map +1 -0
- package/lib/harness/types.d.ts +108 -0
- package/lib/harness/types.d.ts.map +1 -0
- package/lib/harness/types.js +23 -0
- package/lib/harness/types.js.map +1 -0
- package/lib/index.d.ts +65 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +977 -0
- package/lib/index.js.map +1 -0
- package/lib/interact/approvals.d.ts +59 -0
- package/lib/interact/approvals.d.ts.map +1 -0
- package/lib/interact/approvals.js +132 -0
- package/lib/interact/approvals.js.map +1 -0
- package/lib/interact/pending.d.ts +56 -0
- package/lib/interact/pending.d.ts.map +1 -0
- package/lib/interact/pending.js +89 -0
- package/lib/interact/pending.js.map +1 -0
- package/lib/interact/questions.d.ts +100 -0
- package/lib/interact/questions.d.ts.map +1 -0
- package/lib/interact/questions.js +255 -0
- package/lib/interact/questions.js.map +1 -0
- package/lib/interact/surface.d.ts +33 -0
- package/lib/interact/surface.d.ts.map +1 -0
- package/lib/interact/surface.js +39 -0
- package/lib/interact/surface.js.map +1 -0
- package/lib/interact/text-capture.d.ts +38 -0
- package/lib/interact/text-capture.d.ts.map +1 -0
- package/lib/interact/text-capture.js +75 -0
- package/lib/interact/text-capture.js.map +1 -0
- package/lib/media/collect.d.ts +157 -0
- package/lib/media/collect.d.ts.map +1 -0
- package/lib/media/collect.js +235 -0
- package/lib/media/collect.js.map +1 -0
- package/lib/media/extractor.d.ts +117 -0
- package/lib/media/extractor.d.ts.map +1 -0
- package/lib/media/extractor.js +207 -0
- package/lib/media/extractor.js.map +1 -0
- package/lib/media/intake.d.ts +57 -0
- package/lib/media/intake.d.ts.map +1 -0
- package/lib/media/intake.js +115 -0
- package/lib/media/intake.js.map +1 -0
- package/lib/media/limits.d.ts +69 -0
- package/lib/media/limits.d.ts.map +1 -0
- package/lib/media/limits.js +97 -0
- package/lib/media/limits.js.map +1 -0
- package/lib/media/ocr.d.ts +66 -0
- package/lib/media/ocr.d.ts.map +1 -0
- package/lib/media/ocr.js +131 -0
- package/lib/media/ocr.js.map +1 -0
- package/lib/media/screenshot.d.ts +57 -0
- package/lib/media/screenshot.d.ts.map +1 -0
- package/lib/media/screenshot.js +101 -0
- package/lib/media/screenshot.js.map +1 -0
- package/lib/media/vision.d.ts +60 -0
- package/lib/media/vision.d.ts.map +1 -0
- package/lib/media/vision.js +74 -0
- package/lib/media/vision.js.map +1 -0
- package/lib/render/clamp.d.ts +34 -0
- package/lib/render/clamp.d.ts.map +1 -0
- package/lib/render/clamp.js +65 -0
- package/lib/render/clamp.js.map +1 -0
- package/lib/render/escape.d.ts +15 -0
- package/lib/render/escape.d.ts.map +1 -0
- package/lib/render/escape.js +17 -0
- package/lib/render/escape.js.map +1 -0
- package/lib/reply/activity.d.ts +31 -0
- package/lib/reply/activity.d.ts.map +1 -0
- package/lib/reply/activity.js +88 -0
- package/lib/reply/activity.js.map +1 -0
- package/lib/reply/rich-stream.d.ts +173 -0
- package/lib/reply/rich-stream.d.ts.map +1 -0
- package/lib/reply/rich-stream.js +286 -0
- package/lib/reply/rich-stream.js.map +1 -0
- package/lib/reply/split-markdown.d.ts +21 -0
- package/lib/reply/split-markdown.d.ts.map +1 -0
- package/lib/reply/split-markdown.js +87 -0
- package/lib/reply/split-markdown.js.map +1 -0
- package/lib/reply/turn-bridge.d.ts +143 -0
- package/lib/reply/turn-bridge.d.ts.map +1 -0
- package/lib/reply/turn-bridge.js +182 -0
- package/lib/reply/turn-bridge.js.map +1 -0
- package/lib/router.d.ts +415 -0
- package/lib/router.d.ts.map +1 -0
- package/lib/router.js +663 -0
- package/lib/router.js.map +1 -0
- package/lib/secrets.d.ts +30 -0
- package/lib/secrets.d.ts.map +1 -0
- package/lib/secrets.js +51 -0
- package/lib/secrets.js.map +1 -0
- package/lib/session/bindings.d.ts +81 -0
- package/lib/session/bindings.d.ts.map +1 -0
- package/lib/session/bindings.js +170 -0
- package/lib/session/bindings.js.map +1 -0
- package/lib/session/history.d.ts +50 -0
- package/lib/session/history.d.ts.map +1 -0
- package/lib/session/history.js +127 -0
- package/lib/session/history.js.map +1 -0
- package/lib/session/models.d.ts +99 -0
- package/lib/session/models.d.ts.map +1 -0
- package/lib/session/models.js +123 -0
- package/lib/session/models.js.map +1 -0
- package/lib/session/permission.d.ts +83 -0
- package/lib/session/permission.d.ts.map +1 -0
- package/lib/session/permission.js +107 -0
- package/lib/session/permission.js.map +1 -0
- package/lib/session/picker.d.ts +49 -0
- package/lib/session/picker.d.ts.map +1 -0
- package/lib/session/picker.js +107 -0
- package/lib/session/picker.js.map +1 -0
- package/lib/session/preferences.d.ts +55 -0
- package/lib/session/preferences.d.ts.map +1 -0
- package/lib/session/preferences.js +102 -0
- package/lib/session/preferences.js.map +1 -0
- package/lib/session/recovery.d.ts +66 -0
- package/lib/session/recovery.d.ts.map +1 -0
- package/lib/session/recovery.js +112 -0
- package/lib/session/recovery.js.map +1 -0
- package/lib/session/runner.d.ts +223 -0
- package/lib/session/runner.d.ts.map +1 -0
- package/lib/session/runner.js +260 -0
- package/lib/session/runner.js.map +1 -0
- package/lib/session/workspaces.d.ts +28 -0
- package/lib/session/workspaces.d.ts.map +1 -0
- package/lib/session/workspaces.js +53 -0
- package/lib/session/workspaces.js.map +1 -0
- package/lib/telegram/addressing.d.ts +39 -0
- package/lib/telegram/addressing.d.ts.map +1 -0
- package/lib/telegram/addressing.js +81 -0
- package/lib/telegram/addressing.js.map +1 -0
- package/lib/telegram/albums.d.ts +46 -0
- package/lib/telegram/albums.d.ts.map +1 -0
- package/lib/telegram/albums.js +104 -0
- package/lib/telegram/albums.js.map +1 -0
- package/lib/telegram/api.d.ts +238 -0
- package/lib/telegram/api.d.ts.map +1 -0
- package/lib/telegram/api.js +518 -0
- package/lib/telegram/api.js.map +1 -0
- package/lib/telegram/poller.d.ts +59 -0
- package/lib/telegram/poller.d.ts.map +1 -0
- package/lib/telegram/poller.js +94 -0
- package/lib/telegram/poller.js.map +1 -0
- package/lib/telegram/types.d.ts +130 -0
- package/lib/telegram/types.d.ts.map +1 -0
- package/lib/telegram/types.js +9 -0
- package/lib/telegram/types.js.map +1 -0
- package/lib/telegram/typing.d.ts +47 -0
- package/lib/telegram/typing.d.ts.map +1 -0
- package/lib/telegram/typing.js +91 -0
- package/lib/telegram/typing.js.map +1 -0
- package/lib/versions.d.ts +63 -0
- package/lib/versions.d.ts.map +1 -0
- package/lib/versions.js +135 -0
- package/lib/versions.js.map +1 -0
- package/package.json +94 -10
package/lib/router.js
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing one incoming Telegram update.
|
|
3
|
+
*
|
|
4
|
+
* Every update arrives here and leaves as exactly one of: a refusal, a
|
|
5
|
+
* command, an answer to a pending prompt, a button press, or a prompt for the
|
|
6
|
+
* agent. The order those are tried in is the whole design:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Access first.** Nothing else runs for a user who is not allowed, so no
|
|
9
|
+
* unauthorised text ever reaches the agent, not even as a command.
|
|
10
|
+
* 2. **Pending prompts before commands.** If a question is waiting for typed
|
|
11
|
+
* input, that is what the user is answering — routing it to the agent as a
|
|
12
|
+
* new prompt would strand the question forever.
|
|
13
|
+
* 3. **Commands before prompts.** Otherwise `/new` would be a message asking
|
|
14
|
+
* the agent about the word "new".
|
|
15
|
+
*
|
|
16
|
+
* Failures are reported into the chat rather than thrown: an update handler
|
|
17
|
+
* that throws would abort the long-poll loop and take the bot offline.
|
|
18
|
+
*/
|
|
19
|
+
import { COMMANDS, helpText, parseCommand } from './commands.js';
|
|
20
|
+
import { AlbumBuffer, captionOf } from './telegram/albums.js';
|
|
21
|
+
import { addressesBot, isGroupChat, stripMention } from './telegram/addressing.js';
|
|
22
|
+
import { escapeHtml } from './render/escape.js';
|
|
23
|
+
import { SILENT_LOGGER } from './harness/types.js';
|
|
24
|
+
/**
|
|
25
|
+
* A parenthetical naming the layer beneath this conversation's choice.
|
|
26
|
+
*
|
|
27
|
+
* Empty when the conversation is simply following the deployment, because
|
|
28
|
+
* there is then only one answer and pointing at it would be noise.
|
|
29
|
+
*/
|
|
30
|
+
function originNote(control, target) {
|
|
31
|
+
const note = control.origin?.(target);
|
|
32
|
+
return note === undefined ? '' : `\n<i>${escapeHtml(note)}</i>`;
|
|
33
|
+
}
|
|
34
|
+
/** Words that mean "no reader at all", as anyone would type them. */
|
|
35
|
+
const OFF_WORDS = new Set(['off', 'none', 'no', 'disable', 'disabled']);
|
|
36
|
+
/**
|
|
37
|
+
* Render the conversation's facts as a markdown table.
|
|
38
|
+
*
|
|
39
|
+
* Pipes inside a value would split a cell and shift every column after it, so
|
|
40
|
+
* they are escaped — a working directory can contain one, and a model id from
|
|
41
|
+
* a router-style provider routinely does.
|
|
42
|
+
*/
|
|
43
|
+
function statusTable(rows) {
|
|
44
|
+
const body = rows
|
|
45
|
+
.map((row) => `| ${escapeCell(row.label)} | ${escapeCell(row.value)} |`)
|
|
46
|
+
.join('\n');
|
|
47
|
+
return `| | |\n| --- | --- |\n${body}`;
|
|
48
|
+
}
|
|
49
|
+
/** Keep a value inside its own cell. */
|
|
50
|
+
function escapeCell(value) {
|
|
51
|
+
return value.split('|').join('\\|').replace(/\r?\n/g, ' ');
|
|
52
|
+
}
|
|
53
|
+
export class UpdateRouter {
|
|
54
|
+
options;
|
|
55
|
+
/**
|
|
56
|
+
* Albums still arriving, if this deployment reads attachments at all.
|
|
57
|
+
*
|
|
58
|
+
* Owned here rather than injected because its whole job is to defer part of
|
|
59
|
+
* this class's own work back to it.
|
|
60
|
+
*/
|
|
61
|
+
albums;
|
|
62
|
+
logger;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.options = options;
|
|
65
|
+
this.logger = options.logger ?? SILENT_LOGGER;
|
|
66
|
+
this.albums = options.media
|
|
67
|
+
? new AlbumBuffer({
|
|
68
|
+
deliver: (messages) => void this.onAlbum(messages),
|
|
69
|
+
...(options.albumWindowMs === undefined ? {} : { windowMs: options.albumWindowMs }),
|
|
70
|
+
})
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
/** Stop waiting on albums still arriving — the plugin is unloading. */
|
|
74
|
+
dispose() {
|
|
75
|
+
this.albums?.dispose();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Handle one update. Never throws: a thrown handler would stop the poll loop.
|
|
79
|
+
*
|
|
80
|
+
* @param update - one raw update from `getUpdates`.
|
|
81
|
+
*/
|
|
82
|
+
async handle(update) {
|
|
83
|
+
try {
|
|
84
|
+
if (update.callback_query)
|
|
85
|
+
return await this.onCallback(update.callback_query);
|
|
86
|
+
const message = update.message ?? update.edited_message;
|
|
87
|
+
if (message)
|
|
88
|
+
return await this.onMessage(message);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.logger.error('[dsh-telegram] update handling failed', error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** A button press: acknowledge first, then route it. */
|
|
95
|
+
async onCallback(query) {
|
|
96
|
+
// Telegram spins the button until this lands, so acknowledge before any
|
|
97
|
+
// slow work the press unblocks.
|
|
98
|
+
await this.options.chat.answerCallbackQuery(query.id);
|
|
99
|
+
if (this.options.access.check(query.from.id) !== 'allowed')
|
|
100
|
+
return;
|
|
101
|
+
const routed = this.options.questions.handleCallback(query.data) ||
|
|
102
|
+
this.options.approvals.handleCallback(query.data) ||
|
|
103
|
+
(this.options.recovery?.handleCallback(query.data) ?? false) ||
|
|
104
|
+
(this.options.sessions?.handleCallback(query.data) ?? false);
|
|
105
|
+
if (!routed) {
|
|
106
|
+
this.logger.debug('[dsh-telegram] ignoring a stale or unknown button press');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** An incoming message: access, pending prompt, command, then prompt. */
|
|
110
|
+
async onMessage(message) {
|
|
111
|
+
const target = targetOf(message);
|
|
112
|
+
const userId = message.from?.id;
|
|
113
|
+
if (userId === undefined)
|
|
114
|
+
return;
|
|
115
|
+
const raw = message.text ?? message.caption;
|
|
116
|
+
const decision = this.options.access.check(userId);
|
|
117
|
+
if (decision !== 'allowed')
|
|
118
|
+
return await this.onUnauthorized(target, userId, raw, decision);
|
|
119
|
+
// Asked after access and before anything else: a group is a room where
|
|
120
|
+
// people talk to each other, and a bot that answers every line is one
|
|
121
|
+
// nobody keeps around. Silence is the whole response — a message that was
|
|
122
|
+
// not for us deserves no reply, not even a refusal.
|
|
123
|
+
if (this.options.requireAddressing && !addressesBot(message, this.options.botUsername, this.options.botId)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// The @mention is addressing, not content. Left in, every prompt from a
|
|
127
|
+
// group would open with the bot's own name, which the model reads as part
|
|
128
|
+
// of the question.
|
|
129
|
+
const text = raw !== undefined && isGroupChat(message)
|
|
130
|
+
? stripMention(raw, this.options.botUsername) || undefined
|
|
131
|
+
: raw;
|
|
132
|
+
const carriesMedia = hasMedia(message);
|
|
133
|
+
// A waiting question wants typed words, and a command is never a file, so
|
|
134
|
+
// both checks look at plain text only.
|
|
135
|
+
if (text !== undefined && !carriesMedia) {
|
|
136
|
+
if (this.options.textCapture.deliver(target, text))
|
|
137
|
+
return;
|
|
138
|
+
const command = parseCommand(text, this.options.botUsername);
|
|
139
|
+
if (command)
|
|
140
|
+
return await this.onCommand(target, userId, command.name, command.args);
|
|
141
|
+
}
|
|
142
|
+
if (!carriesMedia) {
|
|
143
|
+
if (text === undefined) {
|
|
144
|
+
return await this.say(target, 'I can read text, images, and text files.');
|
|
145
|
+
}
|
|
146
|
+
return await this.runPrompt(target, [{ type: 'text', text }]);
|
|
147
|
+
}
|
|
148
|
+
if (!this.options.media) {
|
|
149
|
+
return await this.say(target, 'This bot is not set up to read attachments.');
|
|
150
|
+
}
|
|
151
|
+
// An album arrives as several updates sharing one id, with the caption on
|
|
152
|
+
// exactly one of them. Held rather than answered, and delivered as a whole
|
|
153
|
+
// once it stops growing — otherwise three screenshots become three turns,
|
|
154
|
+
// two of them with no question attached.
|
|
155
|
+
if (this.albums?.offer(message) === true)
|
|
156
|
+
return;
|
|
157
|
+
// Held across the whole of it. Downloading a large file, retrying one that
|
|
158
|
+
// failed, and reading an image on a vision model all outlast Telegram's
|
|
159
|
+
// five-second action several times over, and none of them show anything in
|
|
160
|
+
// the chat while they run.
|
|
161
|
+
const release = this.options.typing?.hold(target);
|
|
162
|
+
try {
|
|
163
|
+
const collected = await this.options.media.collect(message, text, {
|
|
164
|
+
modelSees: await this.modelSees(target),
|
|
165
|
+
});
|
|
166
|
+
// Said first: the user should not have to wait for a reply to learn that
|
|
167
|
+
// what they attached went nowhere.
|
|
168
|
+
if (collected.notice)
|
|
169
|
+
await this.say(target, escapeHtml(collected.notice));
|
|
170
|
+
await this.runPrompt(target, collected.parts);
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
release?.();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Handle a whole album as one message.
|
|
178
|
+
*
|
|
179
|
+
* Access and addressing were already decided for each part as it arrived, so
|
|
180
|
+
* what is left is the part a single photo would have taken.
|
|
181
|
+
*
|
|
182
|
+
* @param messages - the album's parts, in the order they were sent.
|
|
183
|
+
*/
|
|
184
|
+
async onAlbum(messages) {
|
|
185
|
+
const first = messages[0];
|
|
186
|
+
if (!first || !this.options.media)
|
|
187
|
+
return;
|
|
188
|
+
const target = targetOf(first);
|
|
189
|
+
const caption = captionOf(messages);
|
|
190
|
+
const release = this.options.typing?.hold(target);
|
|
191
|
+
try {
|
|
192
|
+
const collected = await this.options.media.collectAll(messages, caption, {
|
|
193
|
+
modelSees: await this.modelSees(target),
|
|
194
|
+
});
|
|
195
|
+
if (collected.notice)
|
|
196
|
+
await this.say(target, escapeHtml(collected.notice));
|
|
197
|
+
await this.runPrompt(target, collected.parts);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
this.logger.error('[dsh-telegram] could not read an album', error);
|
|
201
|
+
await this.say(target, '⚠️ Those files could not be read.');
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
release?.();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* A user who may not drive the agent. An unclaimed bot still accepts
|
|
209
|
+
* `/claim`, because that is the only way it ever becomes usable.
|
|
210
|
+
*/
|
|
211
|
+
async onUnauthorized(target, userId, text, decision) {
|
|
212
|
+
const command = text === undefined ? undefined : parseCommand(text, this.options.botUsername);
|
|
213
|
+
if (decision === 'unclaimed' && command?.name === 'claim') {
|
|
214
|
+
const claimed = await this.options.access.claim(userId, command.args);
|
|
215
|
+
return await this.say(target, claimed
|
|
216
|
+
? '✅ Claimed. This bot now answers to you alone.'
|
|
217
|
+
: '❌ That claim code is not right.');
|
|
218
|
+
}
|
|
219
|
+
if (decision === 'unclaimed') {
|
|
220
|
+
return await this.say(target, 'This bot has no owner yet.\nRun <code>/claim <code></code> with the code printed in the harness console.');
|
|
221
|
+
}
|
|
222
|
+
this.logger.warn(`[dsh-telegram] refused a message from user ${userId}`);
|
|
223
|
+
await this.say(target, '⛔ You are not allowed to use this bot.');
|
|
224
|
+
}
|
|
225
|
+
/** Run one command. */
|
|
226
|
+
async onCommand(target, userId, name, args) {
|
|
227
|
+
switch (name) {
|
|
228
|
+
case 'start':
|
|
229
|
+
return await this.say(target, `👋 Connected to DeepSeek Harness.\nSend a message to talk to the agent.\n\n${helpText()}`);
|
|
230
|
+
case 'help':
|
|
231
|
+
return await this.say(target, helpText());
|
|
232
|
+
case 'claim':
|
|
233
|
+
return await this.say(target, 'This bot is already claimed.');
|
|
234
|
+
case 'whoami':
|
|
235
|
+
return await this.say(target, `Your Telegram user id is <code>${userId}</code>.`);
|
|
236
|
+
case 'new':
|
|
237
|
+
await this.options.runner.reset(target);
|
|
238
|
+
return await this.say(target, '🆕 Started a fresh conversation.');
|
|
239
|
+
case 'cd':
|
|
240
|
+
return await this.onChangeDirectory(target, args);
|
|
241
|
+
case 'model':
|
|
242
|
+
return await this.onModel(target, args);
|
|
243
|
+
case 'effort':
|
|
244
|
+
return await this.onEffort(target, args);
|
|
245
|
+
case 'vision':
|
|
246
|
+
return await this.onVision(target, args);
|
|
247
|
+
case 'permission':
|
|
248
|
+
return await this.onPermission(target, args);
|
|
249
|
+
case 'diag':
|
|
250
|
+
return await this.onDiagnostics(target);
|
|
251
|
+
case 'screenshot':
|
|
252
|
+
return await this.onScreenshot(target);
|
|
253
|
+
case 'sessions':
|
|
254
|
+
if (!this.options.sessions) {
|
|
255
|
+
return await this.say(target, 'This deployment does not keep a conversation list.');
|
|
256
|
+
}
|
|
257
|
+
return await this.options.sessions.offer(target);
|
|
258
|
+
case 'stop': {
|
|
259
|
+
const stopped = await this.options.runner.stop(target);
|
|
260
|
+
return await this.say(target, stopped ? '🛑 Stopped.' : 'Nothing was running.');
|
|
261
|
+
}
|
|
262
|
+
case 'status':
|
|
263
|
+
return await this.describeConversation(target);
|
|
264
|
+
default:
|
|
265
|
+
// Unreachable: parseCommand only returns names present in COMMANDS.
|
|
266
|
+
return await this.say(target, `Unknown command. Try ${Object.keys(COMMANDS).join(', ')}.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Hand a prompt to the agent, reporting a failure into the chat. */
|
|
270
|
+
async runPrompt(target, content) {
|
|
271
|
+
// A prompt is not instant even without attachments: it may wait behind the
|
|
272
|
+
// conversation's previous message, and an image in it is read before the
|
|
273
|
+
// conversation ever sees it.
|
|
274
|
+
const release = this.options.typing?.hold(target);
|
|
275
|
+
try {
|
|
276
|
+
await this.options.runner.prompt(target, content);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
280
|
+
const reason = this.options.redact?.(raw) ?? raw;
|
|
281
|
+
this.logger.error('[dsh-telegram] prompt failed', error);
|
|
282
|
+
await this.say(target, `⚠️ ${escapeHtml(reason)}`);
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
release?.();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Report what this plugin can see about itself.
|
|
290
|
+
*
|
|
291
|
+
* Exists because several profiles compose no log sink at all, so a plugin
|
|
292
|
+
* that only logs its failures is silent about them. Every fault found here
|
|
293
|
+
* so far was found by someone noticing odd behaviour in a chat and asking —
|
|
294
|
+
* the seam list in particular would have shown at a glance that Telegram
|
|
295
|
+
* agents were joining no preset, and therefore had almost no tools.
|
|
296
|
+
*
|
|
297
|
+
* @param target - the conversation asking.
|
|
298
|
+
*/
|
|
299
|
+
async onDiagnostics(target) {
|
|
300
|
+
const diagnostics = this.options.diagnostics;
|
|
301
|
+
if (!diagnostics) {
|
|
302
|
+
return await this.say(target, 'This deployment keeps no diagnostics.');
|
|
303
|
+
}
|
|
304
|
+
const report = await diagnostics.report();
|
|
305
|
+
const seams = report.seams
|
|
306
|
+
.map((seam) => `| ${escapeCell(seam.name)} | ${seam.present ? '✅' : '❌'} |`)
|
|
307
|
+
.join('\n');
|
|
308
|
+
const failures = report.failures.length === 0
|
|
309
|
+
? '_Nothing has gone wrong since the last restart._'
|
|
310
|
+
: report.failures
|
|
311
|
+
.map((failure) => `- \`${failure.at.replace('T', ' ').slice(0, 19)}\` ` +
|
|
312
|
+
`${failure.level === 'error' ? '🔴' : '🟠'} ${failure.message}`)
|
|
313
|
+
.join('\n');
|
|
314
|
+
const markdown = [
|
|
315
|
+
'**Connection**',
|
|
316
|
+
'',
|
|
317
|
+
`| | |\n| --- | --- |\n${report.status
|
|
318
|
+
.map((row) => `| ${escapeCell(row.label)} | ${escapeCell(row.value)} |`)
|
|
319
|
+
.join('\n')}`,
|
|
320
|
+
'',
|
|
321
|
+
'**Harness seams**',
|
|
322
|
+
'',
|
|
323
|
+
`| | |\n| --- | --- |\n${seams}`,
|
|
324
|
+
'',
|
|
325
|
+
`**Recent failures** (${report.failures.length})`,
|
|
326
|
+
'',
|
|
327
|
+
failures,
|
|
328
|
+
].join('\n');
|
|
329
|
+
if (this.options.chat.sendRichMessage) {
|
|
330
|
+
try {
|
|
331
|
+
await this.options.chat.sendRichMessage({
|
|
332
|
+
chatId: target.chatId,
|
|
333
|
+
markdown,
|
|
334
|
+
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
this.logger.warn('[dsh-telegram] could not send the diagnostics', error);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
await this.say(target, report.status
|
|
343
|
+
.map((row) => `<b>${escapeHtml(row.label)}</b> <code>${escapeHtml(row.value)}</code>`)
|
|
344
|
+
.join('\n'));
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Send a picture of the screen the harness is running on.
|
|
348
|
+
*
|
|
349
|
+
* @param target - the conversation to send it to.
|
|
350
|
+
*/
|
|
351
|
+
async onScreenshot(target) {
|
|
352
|
+
const screen = this.options.screen;
|
|
353
|
+
if (!screen) {
|
|
354
|
+
return await this.say(target, 'Screenshots are off. Turn them on in Settings → Telegram → Screen.');
|
|
355
|
+
}
|
|
356
|
+
const release = this.options.typing?.hold(target);
|
|
357
|
+
try {
|
|
358
|
+
const failure = await screen.send(target);
|
|
359
|
+
if (failure !== undefined)
|
|
360
|
+
await this.say(target, `⚠️ ${escapeHtml(failure)}`);
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
this.logger.error('[dsh-telegram] could not take a screenshot', error);
|
|
364
|
+
await this.say(target, '⚠️ The screenshot could not be taken.');
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
release?.();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Everything about this conversation worth knowing in one message.
|
|
372
|
+
*
|
|
373
|
+
* The settings live behind four commands, and having to run all four to
|
|
374
|
+
* answer "what am I actually talking to right now" is four commands too
|
|
375
|
+
* many — especially the permission line, which is the one worth being sure
|
|
376
|
+
* about before asking for something destructive.
|
|
377
|
+
*
|
|
378
|
+
* @param target - the conversation.
|
|
379
|
+
*/
|
|
380
|
+
async describeConversation(target) {
|
|
381
|
+
const rows = [...(await this.options.runner.status(target))];
|
|
382
|
+
const models = this.options.models;
|
|
383
|
+
if (models)
|
|
384
|
+
rows.push({ label: 'Model', value: models.describe(target) });
|
|
385
|
+
const effort = this.options.effort;
|
|
386
|
+
if (effort)
|
|
387
|
+
rows.push({ label: 'Effort', value: effort.describe(target) });
|
|
388
|
+
const vision = this.options.vision;
|
|
389
|
+
if (vision)
|
|
390
|
+
rows.push({ label: 'Reads images', value: vision.describe(target) });
|
|
391
|
+
const permission = this.options.permission;
|
|
392
|
+
if (permission)
|
|
393
|
+
rows.push({ label: 'Permission', value: permission.describe(target) });
|
|
394
|
+
// Sent as markdown so Telegram draws it as a real table — since Bot API
|
|
395
|
+
// 10.1 it parses these itself, which is the same path an agent's own
|
|
396
|
+
// tables take. A deployment without the rich send falls back to lines.
|
|
397
|
+
if (this.options.chat.sendRichMessage) {
|
|
398
|
+
try {
|
|
399
|
+
// Called as a method, not through a saved reference. Detaching it
|
|
400
|
+
// loses `this`, and the client's own `this.call(...)` then throws —
|
|
401
|
+
// which the catch below turns into a silent fall back to lines.
|
|
402
|
+
await this.options.chat.sendRichMessage({
|
|
403
|
+
chatId: target.chatId,
|
|
404
|
+
markdown: statusTable(rows),
|
|
405
|
+
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
this.logger.warn('[dsh-telegram] could not send the status table', error);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
await this.say(target, rows
|
|
414
|
+
.map((row) => `<b>${escapeHtml(row.label)}</b> <code>${escapeHtml(row.value)}</code>`)
|
|
415
|
+
.join('\n'));
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Show or change how hard the model thinks before answering.
|
|
419
|
+
*
|
|
420
|
+
* The options come from the model itself: `low`/`medium`/`high` is one
|
|
421
|
+
* provider's vocabulary rather than everyone's, and offering an effort a
|
|
422
|
+
* model does not have would fail the turn instead of the command.
|
|
423
|
+
*
|
|
424
|
+
* @param target - the conversation.
|
|
425
|
+
* @param args - what followed `/effort`.
|
|
426
|
+
*/
|
|
427
|
+
async onEffort(target, args) {
|
|
428
|
+
const effort = this.options.effort;
|
|
429
|
+
if (!effort) {
|
|
430
|
+
return await this.say(target, 'This deployment does not allow changing the effort.');
|
|
431
|
+
}
|
|
432
|
+
const wanted = args.trim();
|
|
433
|
+
const offered = await effort.options(target);
|
|
434
|
+
if (offered.length === 0) {
|
|
435
|
+
return await this.say(target, `<code>${escapeHtml(effort.model(target))}</code> offers no reasoning effort to choose.`);
|
|
436
|
+
}
|
|
437
|
+
if (wanted === '') {
|
|
438
|
+
const list = offered.map((option) => `• <code>${escapeHtml(option)}</code>`).join('\n');
|
|
439
|
+
return await this.say(target, `🎚 <code>${escapeHtml(effort.describe(target))}</code>` +
|
|
440
|
+
originNote(effort, target) +
|
|
441
|
+
`\n\n${list}\n\nChange it with <code>/effort high</code>.`);
|
|
442
|
+
}
|
|
443
|
+
if (wanted.toLowerCase() === 'default') {
|
|
444
|
+
await effort.clear(target);
|
|
445
|
+
return await this.say(target, `🎚 Back to <code>${escapeHtml(effort.describe(target))}</code>.`);
|
|
446
|
+
}
|
|
447
|
+
const chosen = await effort.choose(target, wanted);
|
|
448
|
+
if (chosen === undefined) {
|
|
449
|
+
return await this.say(target, `⚠️ <code>${escapeHtml(effort.model(target))}</code> has no effort called ` +
|
|
450
|
+
`<code>${escapeHtml(wanted)}</code>. It offers: ${offered.join(', ')}.`);
|
|
451
|
+
}
|
|
452
|
+
return await this.say(target, `🎚 Thinking <code>${escapeHtml(chosen)}</code> from your next message.`);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Show or change which model reads images for this conversation.
|
|
456
|
+
*
|
|
457
|
+
* `off` is a real answer rather than the absence of one: a conversation
|
|
458
|
+
* whose own model can see wants no reader at all, and saying so has to
|
|
459
|
+
* outrank whatever the deployment configured.
|
|
460
|
+
*
|
|
461
|
+
* @param target - the conversation.
|
|
462
|
+
* @param args - what followed `/vision`.
|
|
463
|
+
*/
|
|
464
|
+
async onVision(target, args) {
|
|
465
|
+
const vision = this.options.vision;
|
|
466
|
+
if (!vision) {
|
|
467
|
+
return await this.say(target, 'This deployment does not allow changing the image reader.');
|
|
468
|
+
}
|
|
469
|
+
const wanted = args.trim();
|
|
470
|
+
if (wanted === '') {
|
|
471
|
+
return await this.say(target, `👁 <code>${escapeHtml(vision.describe(target))}</code>` +
|
|
472
|
+
originNote(vision, target) +
|
|
473
|
+
'\n\nSee what is configured with <code>/model list</code>, change it with ' +
|
|
474
|
+
'<code>/vision provider/model</code>, or turn it off with ' +
|
|
475
|
+
'<code>/vision off</code>.');
|
|
476
|
+
}
|
|
477
|
+
if (OFF_WORDS.has(wanted.toLowerCase())) {
|
|
478
|
+
await vision.disable(target);
|
|
479
|
+
return await this.say(target, `👁 <code>${escapeHtml(vision.describe(target))}</code>` +
|
|
480
|
+
originNote(vision, target) +
|
|
481
|
+
'\n\nImages now go to the conversation itself, which needs a model that reads them.');
|
|
482
|
+
}
|
|
483
|
+
if (wanted.toLowerCase() === 'default') {
|
|
484
|
+
await vision.clear(target);
|
|
485
|
+
return await this.say(target, `👁 Back to <code>${escapeHtml(vision.describe(target))}</code>.`);
|
|
486
|
+
}
|
|
487
|
+
const chosen = await vision.choose(target, wanted);
|
|
488
|
+
switch (chosen.kind) {
|
|
489
|
+
case 'route':
|
|
490
|
+
return await this.say(target, `👁 Images now read by <code>${escapeHtml(chosen.route)}</code>.`);
|
|
491
|
+
case 'ambiguous':
|
|
492
|
+
return await this.say(target, `Several providers offer that. Name one:\n${chosen.candidates
|
|
493
|
+
.map((candidate) => `• <code>${escapeHtml(candidate)}</code>`)
|
|
494
|
+
.join('\n')}`);
|
|
495
|
+
default:
|
|
496
|
+
return await this.say(target, `⚠️ No configured model called <code>${escapeHtml(wanted)}</code>. ` +
|
|
497
|
+
'Try <code>/model list</code>.');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Show or change what the agent is allowed to do here.
|
|
502
|
+
*
|
|
503
|
+
* Applied to the conversation in flight as well as recorded, because the
|
|
504
|
+
* point of tightening it is usually the turn about to run.
|
|
505
|
+
*
|
|
506
|
+
* @param target - the conversation.
|
|
507
|
+
* @param args - what followed `/permission`.
|
|
508
|
+
*/
|
|
509
|
+
async onPermission(target, args) {
|
|
510
|
+
const permission = this.options.permission;
|
|
511
|
+
if (!permission) {
|
|
512
|
+
return await this.say(target, 'This deployment does not allow changing permissions.');
|
|
513
|
+
}
|
|
514
|
+
const wanted = args.trim();
|
|
515
|
+
const offered = permission.options();
|
|
516
|
+
if (wanted === '') {
|
|
517
|
+
const list = offered.map((option) => `• <code>${escapeHtml(option)}</code>`).join('\n');
|
|
518
|
+
return await this.say(target, `🔐 <code>${escapeHtml(permission.describe(target))}</code>` +
|
|
519
|
+
originNote(permission, target) +
|
|
520
|
+
`\n\n${list}\n\nChange it with <code>/permission read-only</code>.`);
|
|
521
|
+
}
|
|
522
|
+
if (wanted.toLowerCase() === 'default') {
|
|
523
|
+
await permission.clear(target);
|
|
524
|
+
return await this.say(target, `🔐 Back to <code>${escapeHtml(permission.describe(target))}</code>.`);
|
|
525
|
+
}
|
|
526
|
+
const chosen = await permission.choose(target, wanted);
|
|
527
|
+
if (chosen === undefined) {
|
|
528
|
+
return await this.say(target, `⚠️ No permission preset called <code>${escapeHtml(wanted)}</code>. ` +
|
|
529
|
+
`This deployment offers: ${offered.join(', ')}.`);
|
|
530
|
+
}
|
|
531
|
+
return await this.say(target, `🔐 Now <code>${escapeHtml(chosen)}</code>.`);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Show, list, or change the model this conversation talks to.
|
|
535
|
+
*
|
|
536
|
+
* No reset here, unlike `/cd`: the harness reads a mutable selection while
|
|
537
|
+
* assembling each step, so a model change lands on the very next message and
|
|
538
|
+
* the conversation carries on.
|
|
539
|
+
*
|
|
540
|
+
* @param target - the conversation.
|
|
541
|
+
* @param args - what followed `/model`.
|
|
542
|
+
*/
|
|
543
|
+
async onModel(target, args) {
|
|
544
|
+
const models = this.options.models;
|
|
545
|
+
if (!models) {
|
|
546
|
+
return await this.say(target, 'This deployment does not allow changing the model.');
|
|
547
|
+
}
|
|
548
|
+
const wanted = args.trim();
|
|
549
|
+
if (wanted === '') {
|
|
550
|
+
return await this.say(target, `🧠 <code>${escapeHtml(models.describe(target))}</code>` +
|
|
551
|
+
originNote(models, target) +
|
|
552
|
+
'\n\nSee them with <code>/model list</code>, or change it with ' +
|
|
553
|
+
'<code>/model provider/model</code>.');
|
|
554
|
+
}
|
|
555
|
+
if (wanted.toLowerCase() === 'default') {
|
|
556
|
+
await models.clear(target);
|
|
557
|
+
return await this.say(target, `🧠 Back to <code>${escapeHtml(models.describe(target))}</code>.`);
|
|
558
|
+
}
|
|
559
|
+
if (wanted.toLowerCase() === 'list')
|
|
560
|
+
return await this.say(target, await models.list());
|
|
561
|
+
const chosen = await models.choose(target, wanted);
|
|
562
|
+
switch (chosen.kind) {
|
|
563
|
+
case 'route':
|
|
564
|
+
return await this.say(target, `🧠 Now talking to <code>${escapeHtml(chosen.route)}</code>.\n\n` +
|
|
565
|
+
'It applies from your next message; this conversation carries on.');
|
|
566
|
+
case 'ambiguous':
|
|
567
|
+
return await this.say(target, `Several providers offer that. Name one:\n${chosen.candidates
|
|
568
|
+
.map((candidate) => `• <code>${escapeHtml(candidate)}</code>`)
|
|
569
|
+
.join('\n')}`);
|
|
570
|
+
default:
|
|
571
|
+
return await this.say(target, `⚠️ No configured model called <code>${escapeHtml(wanted)}</code>. ` +
|
|
572
|
+
'Try <code>/model list</code>.');
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Show or change the conversation's working directory.
|
|
577
|
+
*
|
|
578
|
+
* Changing it necessarily starts a fresh conversation: the sandbox derives
|
|
579
|
+
* its writable root from the session's cwd, and that root is fixed when the
|
|
580
|
+
* session opens. Rather than fail a move the user reasonably expects to
|
|
581
|
+
* work, the reset is done for them and said out loud.
|
|
582
|
+
*
|
|
583
|
+
* @param target - the conversation.
|
|
584
|
+
* @param args - what followed `/cd`; empty means "tell me where I am".
|
|
585
|
+
*/
|
|
586
|
+
async onChangeDirectory(target, args) {
|
|
587
|
+
const workspace = this.options.workspace;
|
|
588
|
+
const current = workspace?.current(target);
|
|
589
|
+
if (!workspace || current === undefined) {
|
|
590
|
+
return await this.say(target, 'This deployment does not allow changing directory.');
|
|
591
|
+
}
|
|
592
|
+
if (args.trim() === '') {
|
|
593
|
+
return await this.say(target, `📁 <code>${escapeHtml(current)}</code>\n\nChange it with <code>/cd ~/projects/app</code>.`);
|
|
594
|
+
}
|
|
595
|
+
const wanted = workspace.resolve(args, current);
|
|
596
|
+
if (wanted === undefined) {
|
|
597
|
+
return await this.say(target, 'Give me a directory: <code>/cd ~/projects/app</code>');
|
|
598
|
+
}
|
|
599
|
+
if (wanted === current) {
|
|
600
|
+
return await this.say(target, `Already in <code>${escapeHtml(wanted)}</code>.`);
|
|
601
|
+
}
|
|
602
|
+
const verdict = await workspace.inspect(wanted);
|
|
603
|
+
if (verdict !== 'directory') {
|
|
604
|
+
const why = verdict === 'file'
|
|
605
|
+
? 'that is a file, not a directory'
|
|
606
|
+
: verdict === 'denied'
|
|
607
|
+
? 'it cannot be read'
|
|
608
|
+
: 'no such directory';
|
|
609
|
+
return await this.say(target, `⚠️ Cannot use <code>${escapeHtml(wanted)}</code> — ${why}.`);
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
await workspace.set(target, wanted);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
this.logger.error('[dsh-telegram] could not record the working directory', error);
|
|
616
|
+
return await this.say(target, '⚠️ The directory could not be saved.');
|
|
617
|
+
}
|
|
618
|
+
// After the directory is recorded, so a failed reset still leaves the
|
|
619
|
+
// choice in place for the next message rather than losing it silently.
|
|
620
|
+
await this.options.runner.reset(target);
|
|
621
|
+
return await this.say(target, `📁 Now working in <code>${escapeHtml(wanted)}</code>.\n\n` +
|
|
622
|
+
'🆕 Started a fresh conversation — a session keeps the directory it opened in.');
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Whether this conversation's own model reads images.
|
|
626
|
+
*
|
|
627
|
+
* Never fatal: an unanswerable question here means the refusal is decided
|
|
628
|
+
* the way it was before there was a model that could see.
|
|
629
|
+
*/
|
|
630
|
+
async modelSees(target) {
|
|
631
|
+
try {
|
|
632
|
+
return (await this.options.modelSees?.(target)) === true;
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/** Send one plain notice into a conversation, swallowing delivery failures. */
|
|
639
|
+
async say(target, html) {
|
|
640
|
+
try {
|
|
641
|
+
await this.options.chat.sendMessage({
|
|
642
|
+
chatId: target.chatId,
|
|
643
|
+
html,
|
|
644
|
+
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
this.logger.warn('[dsh-telegram] could not deliver a notice', error);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** Whether a message carries anything beyond its text. */
|
|
653
|
+
function hasMedia(message) {
|
|
654
|
+
return Boolean(message.photo || message.document || message.voice || message.audio || message.video);
|
|
655
|
+
}
|
|
656
|
+
/** The conversation a message belongs to. */
|
|
657
|
+
function targetOf(message) {
|
|
658
|
+
return {
|
|
659
|
+
chatId: String(message.chat.id),
|
|
660
|
+
...(message.message_thread_id !== undefined ? { threadId: message.message_thread_id } : {}),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
//# sourceMappingURL=router.js.map
|