@soffits/dsh-telegram-bot 0.4.4 → 0.5.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/MAINTAINING.md +2 -2
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/app.js +49 -5
- package/src/sender.js +134 -0
- package/src/steer.js +7 -3
package/MAINTAINING.md
CHANGED
|
@@ -10,7 +10,7 @@ This repository is a maintained fork of [Boobaw/dsh-telegram-bot](https://github
|
|
|
10
10
|
- Session titles are `Telegram-<person>-<DSH title>` (`plannedTitle` in `src/topics.js`): 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 `Telegram-<person>-<topic name>` placeholder is a user title that stops automatic titling. `<person>` comes from `userNames` / `userNamesFile`, else the chat's Telegram name (`chatNames` in the state file, learned from messages or `getChat`). Each session record keeps the label its title carries (`titlePerson`), so a changed name still finds the old prefix. `migrateSessionTitles` brings every bound session to the current form once the bot connects, reading titles from the log first and opening only sessions whose title changes. DSH's session header keeps only its own fields (dsh-session copies `cwd`, `parentSession`, `agentPreset` and the rest by name), so the source and person are not in session meta; DSH 0.2.2 has experimental plugin records (`appendPluginRecord` in dsh-session) that could carry them in the log later. 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
11
|
- Deleted topics are forgotten as soon as any Bot API call reports them gone (`forgetGoneTopic`).
|
|
12
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.
|
|
13
|
+
- Sender labels: each Telegram prompt names who wrote it, in a text line for the model and as `source.principal` for readers (`src/sender.js`). DSH writes `principal` itself only for non-owner callers of its session controller, and the bot hands text prompts to that controller, which builds the message, so the bot remembers the sender by request id and records it on the message in an `agent/pre-step` listener (DSH lets that hook replace a step's messages); messages the bot builds itself get it directly. Sends recorded in a bound session carry the sender (job, session or label) the same way.
|
|
14
14
|
- Voice messages, audio and round videos: downloaded, transcribed (DSH speech-to-text or a configured command) and passed with their file path.
|
|
15
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
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`.
|
|
@@ -36,4 +36,4 @@ git push origin main
|
|
|
36
36
|
|
|
37
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
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.
|
|
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/sender.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/sender.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
|
@@ -25,7 +25,7 @@ BotFather: **Bot Settings → Threaded mode → Enable**. Leave **Disallow users
|
|
|
25
25
|
- One Telegram topic per DSH session; Web and Telegram use the same session.
|
|
26
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
27
|
- Topic names are yours: the bot never renames a topic after the session title. `/rename` renames it on request.
|
|
28
|
-
- A Telegram session is titled `Telegram-<person>-<what the conversation is about>` in DSH, for example `Telegram-
|
|
28
|
+
- A Telegram session is titled `Telegram-<person>-<what the conversation is about>` in DSH, for example `Telegram-Ana-Plan a weekend trip`, so the sidebar shows where each session comes from and whose chat it is. `<person>` is the name set for the chat's user in `userNames` or `userNamesFile`, else the user's Telegram first name; in a group it is the group's title. The last part is DSH's own title (its title model, or the first words of the first message); until it exists the topic name stands in for it. Sessions with titles from earlier versions (`<topic name> · <task>`, a bare topic name) are moved to this form when the bot starts. A title someone types for the session is left alone.
|
|
29
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
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
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.
|
|
@@ -42,7 +42,7 @@ BotFather: **Bot Settings → Threaded mode → Enable**. Leave **Disallow users
|
|
|
42
42
|
- `ask_user_question` support: options, multi-select, custom answers and plan review.
|
|
43
43
|
- Documents and photos sent in a session topic are saved as DSH attachments and passed to the agent.
|
|
44
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.
|
|
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. The sender is also recorded as data on the prompt's source as `principal`, the field DSH uses for the caller behind a user message: `{ kind: 'telegram-user', channel: 'telegram', id: '123', name: 'Ana Silva', username: 'ana', chat: { id, thread, name } }`, where `chat` is the chat, its topic thread and the topic's name. Scripts that read session logs can use it instead of parsing the line.
|
|
46
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
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
48
|
- `/new`, `/workspace`, `/last`, `/goal`, `/rename`, `/topic`, `/fork`, `/clear`, `/stop`, `/restart`, `/status` and `/menu`.
|
|
@@ -145,7 +145,7 @@ Private-chat topics send with `message_thread_id`. If Telegram answers `message
|
|
|
145
145
|
|
|
146
146
|
## Send to a topic
|
|
147
147
|
|
|
148
|
-
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`.
|
|
148
|
+
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. The record's source (kind `telegram-post`) names the topic it went to (`channel: 'telegram'`, `chatId`, `threadId`, `topic`) and the sender as `principal`: `{ kind: 'cron-job', channel: 'cron', id, name, sessionId? }` for a scheduled job, `{ kind: 'dsh-session', channel: 'dsh', id }` for a DSH session, or `{ kind: 'external', channel: 'external', id: <label> }` for a caller that gave only a label. 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`.
|
|
149
149
|
|
|
150
150
|
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.
|
|
151
151
|
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -35,8 +35,9 @@ import {
|
|
|
35
35
|
toTelegramMarkdown,
|
|
36
36
|
toolArg,
|
|
37
37
|
} from './formatting.js'
|
|
38
|
-
import { admitText } from './admission.js'
|
|
38
|
+
import { admitText, promptRequestId } from './admission.js'
|
|
39
39
|
import { STEER_REACTION, busyMode, clearTelegramInbox, hasPendingInput, steerPrompt } from './steer.js'
|
|
40
|
+
import { createPrincipalTagger, sendPrincipal, telegramUserPrincipal } from './sender.js'
|
|
40
41
|
import { loadState, STATE_PATH } from './state.js'
|
|
41
42
|
import { createUpdateAdmission } from './update-ledger.js'
|
|
42
43
|
import { publishStatus } from './status.js'
|
|
@@ -129,6 +130,10 @@ export function apply(ctx, config) {
|
|
|
129
130
|
})
|
|
130
131
|
/** Ids of legacy follow-up messages the bridge submitted from Telegram. */
|
|
131
132
|
const telegramMessageIds = new Set()
|
|
133
|
+
// Telegram senders by `<chatId>:<messageId>`, kept from routing until the
|
|
134
|
+
// prompt is handed to DSH (a topic binding or the queue may come between).
|
|
135
|
+
const promptSenders = new Map()
|
|
136
|
+
const principals = createPrincipalTagger()
|
|
132
137
|
|
|
133
138
|
/**
|
|
134
139
|
* Persist the chat → session mapping atomically.
|
|
@@ -2754,7 +2759,7 @@ export function apply(ctx, config) {
|
|
|
2754
2759
|
}
|
|
2755
2760
|
let requestId
|
|
2756
2761
|
try {
|
|
2757
|
-
requestId = await steerPrompt(ctx, agent, { sessionId: chat.sessionId, chatId: chat.chatId, messageId, text, attachments }, signal)
|
|
2762
|
+
requestId = await steerPrompt(ctx, agent, { sessionId: chat.sessionId, chatId: chat.chatId, messageId, text, attachments, principal: promptPrincipal(chat, messageId) }, signal, principals)
|
|
2758
2763
|
} catch (error) {
|
|
2759
2764
|
// Not admitted: the bot's queue still delivers it after the turn.
|
|
2760
2765
|
reportError('steer', error)
|
|
@@ -2846,14 +2851,16 @@ export function apply(ctx, config) {
|
|
|
2846
2851
|
const agent = await ensureAgent(chat)
|
|
2847
2852
|
await unarchiveForTopic(chat)
|
|
2848
2853
|
let admitted = false
|
|
2854
|
+
const principal = promptPrincipal(chat, messageId)
|
|
2849
2855
|
if (nativeText) {
|
|
2856
|
+
principals.remember(promptRequestId(chat.chatId, messageId), principal)
|
|
2850
2857
|
admitted = admitText(ctx, { sessionId: chat.sessionId, chatId: chat.chatId, messageId, text }, signal)
|
|
2851
2858
|
if (admitted !== false) await admitted
|
|
2852
2859
|
}
|
|
2853
2860
|
if (!admitted) {
|
|
2854
2861
|
const id = randomUUID()
|
|
2855
2862
|
telegramMessageIds.add(id)
|
|
2856
|
-
agent.followup({ id, role: 'user', content: [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments], source: { kind: 'user' } })
|
|
2863
|
+
agent.followup({ id, role: 'user', content: [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments], source: { kind: 'user', ...(principal === undefined ? {} : { principal }) } })
|
|
2857
2864
|
}
|
|
2858
2865
|
// The turn/end listener finalizes the Telegram turn. whenIdle is only a
|
|
2859
2866
|
// fallback for the turn that started this epoch, never a queued turn.
|
|
@@ -3952,13 +3959,17 @@ export function apply(ctx, config) {
|
|
|
3952
3959
|
* line the model also reads, `from` the request's sender.
|
|
3953
3960
|
* @returns the message source.
|
|
3954
3961
|
*/
|
|
3955
|
-
function postSource({ header, threadId, topic, from, self }) {
|
|
3962
|
+
function postSource({ header, chatId, threadId, topic, from, self }) {
|
|
3956
3963
|
const job = typeof from?.job === 'string' && from.job.trim().length > 0 ? from.job.trim() : null
|
|
3957
3964
|
const label = typeof from?.label === 'string' && from.label.trim().length > 0 ? from.label.trim() : null
|
|
3965
|
+
const principal = sendPrincipal(from)
|
|
3958
3966
|
return {
|
|
3959
3967
|
kind: POST_SOURCE_KIND,
|
|
3960
3968
|
form: 'notice',
|
|
3961
3969
|
summary: header.slice(0, 120),
|
|
3970
|
+
...(principal === undefined ? {} : { principal }),
|
|
3971
|
+
channel: 'telegram',
|
|
3972
|
+
chatId: String(chatId),
|
|
3962
3973
|
threadId,
|
|
3963
3974
|
topic: topic ?? null,
|
|
3964
3975
|
job,
|
|
@@ -4105,7 +4116,7 @@ export function apply(ctx, config) {
|
|
|
4105
4116
|
id: randomUUID(),
|
|
4106
4117
|
role: 'user',
|
|
4107
4118
|
content,
|
|
4108
|
-
source: postSource({ header, threadId, topic: target.name, from: request?.from, self }),
|
|
4119
|
+
source: postSource({ header, chatId, threadId, topic: target.name, from: request?.from, self }),
|
|
4109
4120
|
})
|
|
4110
4121
|
recorded = true
|
|
4111
4122
|
} catch (error) {
|
|
@@ -5873,6 +5884,36 @@ export function apply(ctx, config) {
|
|
|
5873
5884
|
return { text, attachments, messageId: message.message_id, from: message.from }
|
|
5874
5885
|
}
|
|
5875
5886
|
|
|
5887
|
+
/**
|
|
5888
|
+
* Keep who wrote a Telegram message until its prompt reaches DSH.
|
|
5889
|
+
* @param chatId - Telegram chat.
|
|
5890
|
+
* @param messageId - Telegram message id.
|
|
5891
|
+
* @param from - Telegram `from` of the message.
|
|
5892
|
+
*/
|
|
5893
|
+
function rememberSender(chatId, messageId, from) {
|
|
5894
|
+
if (!Number.isSafeInteger(messageId) || from === undefined || from === null) return
|
|
5895
|
+
const key = `${chatId}:${messageId}`
|
|
5896
|
+
promptSenders.delete(key)
|
|
5897
|
+
promptSenders.set(key, from)
|
|
5898
|
+
if (promptSenders.size > 1024) promptSenders.delete(promptSenders.keys().next().value)
|
|
5899
|
+
}
|
|
5900
|
+
|
|
5901
|
+
/**
|
|
5902
|
+
* The sender of a Telegram message, recorded on its prompt's source.
|
|
5903
|
+
* @param chat - the topic the prompt runs in.
|
|
5904
|
+
* @param messageId - Telegram message id.
|
|
5905
|
+
* @returns the principal, or undefined when the sender is not known.
|
|
5906
|
+
*/
|
|
5907
|
+
function promptPrincipal(chat, messageId) {
|
|
5908
|
+
const from = promptSenders.get(`${chat.chatId}:${messageId}`)
|
|
5909
|
+
if (from === undefined) return undefined
|
|
5910
|
+
return telegramUserPrincipal(from, {
|
|
5911
|
+
chatId: chat.chatId,
|
|
5912
|
+
threadId: chat.threadId,
|
|
5913
|
+
topic: typeof chat.threadId === 'number' ? topicNameFor(chat.chatId, chat.threadId) : undefined,
|
|
5914
|
+
})
|
|
5915
|
+
}
|
|
5916
|
+
|
|
5876
5917
|
/**
|
|
5877
5918
|
* Route one prompt: a new topic from All, the workspace picker in a topic
|
|
5878
5919
|
* without a session, or a turn in the bound session.
|
|
@@ -5886,6 +5927,7 @@ export function apply(ctx, config) {
|
|
|
5886
5927
|
// Say who is writing: several allowed users may share one bot.
|
|
5887
5928
|
const label = senderLabel(prompt.from)
|
|
5888
5929
|
if (label !== undefined) prompt = { ...prompt, text: prompt.text.length > 0 ? `${label}\n${prompt.text}` : label }
|
|
5930
|
+
rememberSender(chatId, prompt.messageId, prompt.from)
|
|
5889
5931
|
const route = routeMessage({ threadId, inSession: typeof chatState.sessionId === 'string' })
|
|
5890
5932
|
if (route === 'new-topic') {
|
|
5891
5933
|
await createNewSessionTopic(chatId, threadId, signal, defaultWorkspaceCwd(), prompt.text, prompt.attachments, prompt.messageId)
|
|
@@ -6527,6 +6569,8 @@ export function apply(ctx, config) {
|
|
|
6527
6569
|
return () => outbox.stop()
|
|
6528
6570
|
}, 'dsh-telegram-bot: outbox')
|
|
6529
6571
|
|
|
6572
|
+
// Record each Telegram sender on the prompt the session controller built for it.
|
|
6573
|
+
ctx.on('agent/pre-step', (payload, next) => principals.preStep(payload, next), { global: true })
|
|
6530
6574
|
// Host-wide feeds: every session's events, whichever client started the
|
|
6531
6575
|
// turn. Only sessions bound to a topic are mirrored.
|
|
6532
6576
|
ctx.on('session/event', (session, event) => {
|
package/src/sender.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who sent a message, as data on the message itself.
|
|
3
|
+
*
|
|
4
|
+
* DSH's field for the caller behind a user message is `source.principal`:
|
|
5
|
+
* the session controller writes it for callers other than the owner, so an
|
|
6
|
+
* absent principal means the owner. The bot admits every Telegram prompt as
|
|
7
|
+
* the owner, so it fills the field itself, and also on the copies of sends it
|
|
8
|
+
* records in bound sessions. Readers then get the sender without parsing the
|
|
9
|
+
* sender line, which stays in the text for the model.
|
|
10
|
+
*
|
|
11
|
+
* Every principal has `kind`, `channel` and `id`, and `name` when one is
|
|
12
|
+
* known; the other fields depend on the kind. None carries a credential, so
|
|
13
|
+
* all are safe in a session log.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Remembered prompts waiting for their step; the oldest go first past this. */
|
|
17
|
+
export const MAX_PENDING_PRINCIPALS = 1024
|
|
18
|
+
|
|
19
|
+
function nonEmpty(value) {
|
|
20
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The Telegram user who wrote a message, and where.
|
|
25
|
+
* @param user - Telegram `from` of the message.
|
|
26
|
+
* @param where - `{ chatId, threadId, topic }`: the chat, its topic thread and the topic's name.
|
|
27
|
+
* @returns the principal, or undefined without a sender.
|
|
28
|
+
*/
|
|
29
|
+
export function telegramUserPrincipal(user, { chatId, threadId, topic } = {}) {
|
|
30
|
+
if (user === undefined || user === null || user.id === undefined || user.id === null) return undefined
|
|
31
|
+
const name = [user.first_name, user.last_name].map(nonEmpty).filter((part) => part !== undefined).join(' ')
|
|
32
|
+
const username = nonEmpty(user.username)
|
|
33
|
+
const chat = chatId === undefined || chatId === null
|
|
34
|
+
? undefined
|
|
35
|
+
: {
|
|
36
|
+
id: String(chatId),
|
|
37
|
+
...(Number.isSafeInteger(threadId) && threadId > 0 ? { thread: threadId } : {}),
|
|
38
|
+
...(nonEmpty(topic) === undefined ? {} : { name: nonEmpty(topic) }),
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
kind: 'telegram-user',
|
|
42
|
+
channel: 'telegram',
|
|
43
|
+
id: String(user.id),
|
|
44
|
+
...(name.length > 0 ? { name } : {}),
|
|
45
|
+
...(username === undefined ? {} : { username }),
|
|
46
|
+
...(chat === undefined ? {} : { chat }),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The sender of a send into a topic, from the request's `from`: a scheduled
|
|
52
|
+
* job, else a DSH session, else the free-form label of an outside caller.
|
|
53
|
+
* @param from - `{ job?, jobId?, sessionId?, label? }`.
|
|
54
|
+
* @returns the principal, or undefined when the request names no sender.
|
|
55
|
+
*/
|
|
56
|
+
export function sendPrincipal(from) {
|
|
57
|
+
const job = nonEmpty(from?.job)
|
|
58
|
+
const jobId = nonEmpty(from?.jobId)
|
|
59
|
+
const sessionId = nonEmpty(from?.sessionId)
|
|
60
|
+
const label = nonEmpty(from?.label)
|
|
61
|
+
if (job !== undefined || jobId !== undefined) {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'cron-job',
|
|
64
|
+
channel: 'cron',
|
|
65
|
+
id: jobId ?? job,
|
|
66
|
+
...(job === undefined ? {} : { name: job }),
|
|
67
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (sessionId !== undefined) {
|
|
71
|
+
return { kind: 'dsh-session', channel: 'dsh', id: sessionId, ...(label === undefined ? {} : { name: label }) }
|
|
72
|
+
}
|
|
73
|
+
if (label !== undefined) return { kind: 'external', channel: 'external', id: label, name: label }
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A frozen copy of a message whose source records its sender.
|
|
79
|
+
* @param message - the user message.
|
|
80
|
+
* @param principal - its sender.
|
|
81
|
+
* @returns the copy; the message itself when there is no principal.
|
|
82
|
+
*/
|
|
83
|
+
export function withPrincipal(message, principal) {
|
|
84
|
+
if (principal === undefined) return message
|
|
85
|
+
return Object.freeze({ ...message, source: Object.freeze({ ...message.source, principal: deepFreeze(principal) }) })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function deepFreeze(value) {
|
|
89
|
+
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
|
|
90
|
+
for (const child of Object.values(value)) deepFreeze(child)
|
|
91
|
+
Object.freeze(value)
|
|
92
|
+
}
|
|
93
|
+
return value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Principals for prompts the bot hands to DSH's session controller, which
|
|
98
|
+
* builds the message itself. The bot remembers the sender by request id
|
|
99
|
+
* before the hand-over; `preStep`, an `agent/pre-step` listener, records it
|
|
100
|
+
* on the message as it enters a step, so it is in the session log with the
|
|
101
|
+
* message (DSH lets that hook replace the messages of a step).
|
|
102
|
+
* @param limit - most principals remembered at once.
|
|
103
|
+
*/
|
|
104
|
+
export function createPrincipalTagger(limit = MAX_PENDING_PRINCIPALS) {
|
|
105
|
+
const pending = new Map()
|
|
106
|
+
return {
|
|
107
|
+
/** Remember the sender of the prompt with this request id. */
|
|
108
|
+
remember(requestId, principal) {
|
|
109
|
+
if (typeof requestId !== 'string' || principal === undefined) return
|
|
110
|
+
pending.delete(requestId)
|
|
111
|
+
pending.set(requestId, principal)
|
|
112
|
+
if (pending.size > limit) pending.delete(pending.keys().next().value)
|
|
113
|
+
},
|
|
114
|
+
/** Pending principals, for tests. */
|
|
115
|
+
get size() {
|
|
116
|
+
return pending.size
|
|
117
|
+
},
|
|
118
|
+
/** `agent/pre-step` listener: record remembered senders on the step's messages. */
|
|
119
|
+
async preStep(_payload, next) {
|
|
120
|
+
const decision = await next()
|
|
121
|
+
if (decision?.kind !== 'enter' || pending.size === 0 || !Array.isArray(decision.messages)) return decision
|
|
122
|
+
let changed = false
|
|
123
|
+
const messages = decision.messages.map((message) => {
|
|
124
|
+
const rpcId = message?.source?.kind === 'user' ? message.source.rpcId : undefined
|
|
125
|
+
const principal = typeof rpcId === 'string' ? pending.get(rpcId) : undefined
|
|
126
|
+
if (principal === undefined || message.source.principal !== undefined) return message
|
|
127
|
+
pending.delete(rpcId)
|
|
128
|
+
changed = true
|
|
129
|
+
return withPrincipal(message, principal)
|
|
130
|
+
})
|
|
131
|
+
return changed ? { ...decision, messages } : decision
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/steer.js
CHANGED
|
@@ -52,20 +52,24 @@ function hasRequest(agent, requestId) {
|
|
|
52
52
|
* dropped. A request id already admitted is skipped, so it is never sent twice.
|
|
53
53
|
* @param ctx - plugin context.
|
|
54
54
|
* @param agent - the session's live agent.
|
|
55
|
-
* @param prompt - `{ sessionId, chatId, messageId, text, attachments }
|
|
55
|
+
* @param prompt - `{ sessionId, chatId, messageId, text, attachments, principal }`;
|
|
56
|
+
* `principal` is the sender recorded on the message's source.
|
|
56
57
|
* @param signal - request lifetime.
|
|
58
|
+
* @param tagger - remembers the principal of a prompt the session controller
|
|
59
|
+
* builds (see `createPrincipalTagger`).
|
|
57
60
|
* @returns the request id, or `undefined` when this DSH cannot steer.
|
|
58
61
|
*/
|
|
59
|
-
export async function steerPrompt(ctx, agent, { sessionId, chatId, messageId, text, attachments = [] }, signal) {
|
|
62
|
+
export async function steerPrompt(ctx, agent, { sessionId, chatId, messageId, text, attachments = [], principal }, signal, tagger) {
|
|
60
63
|
const requestId = steerRequestId(chatId, messageId)
|
|
61
64
|
const content = [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments]
|
|
62
65
|
const controller = ctx.get('sessionController')
|
|
63
66
|
if (attachments.length === 0 && content.length > 0 && typeof controller?.prompt === 'function') {
|
|
67
|
+
tagger?.remember(requestId, principal)
|
|
64
68
|
await controller.prompt({ sessionId, requestId, mode: 'steer', content }, signal)
|
|
65
69
|
return requestId
|
|
66
70
|
}
|
|
67
71
|
if (hasRequest(agent, requestId)) return requestId
|
|
68
|
-
const message = { id: randomUUID(), role: 'user', content, source: { kind: 'user', rpcId: requestId } }
|
|
72
|
+
const message = { id: randomUUID(), role: 'user', content, source: { kind: 'user', rpcId: requestId, ...(principal === undefined ? {} : { principal }) } }
|
|
69
73
|
if (typeof agent?.steer === 'function') agent.steer(message)
|
|
70
74
|
else if (typeof agent?.send === 'function') agent.send(message, 'next-step', true)
|
|
71
75
|
else return undefined
|