@soffits/dsh-telegram-bot 0.4.3 → 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 CHANGED
@@ -7,10 +7,10 @@ This repository is a maintained fork of [Boobaw/dsh-telegram-bot](https://github
7
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
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
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.
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 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.
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`.
@@ -98,10 +98,13 @@ Restart `dsh web` after installing or updating the plugin. Then open **Settings
98
98
  | `transcribeLanguage` | Language hint for DSH speech-to-text, such as `zh` or `en`; empty uses its own setting |
99
99
  | `transcribeCommand` | Program and arguments for `transcriber: command` |
100
100
  | `ffmpegPath` | ffmpeg used to decode recordings for DSH speech-to-text; default `ffmpeg` |
101
+ | `userNames` | Names for Telegram user ids (or group chat ids) in session titles, such as `{ "123456789": "Alex" }` |
102
+ | `userNamesFile` | JSON file with more such names, re-read when it changes; `userNames` wins |
103
+ | `userNamesKey` | Key of the id → name object inside `userNamesFile`; empty means the whole file is the map |
101
104
  | Bot language | `en` (default) or `ru` |
102
105
  | Enable bot | Start or pause Telegram polling |
103
106
 
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`.
107
+ The voice fields, the name 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
108
 
106
109
  The token field only reports whether a token is configured. A token supplied through the environment cannot be overwritten from the page.
107
110
 
@@ -142,11 +145,11 @@ Private-chat topics send with `message_thread_id`. If Telegram answers `message
142
145
 
143
146
  ## Send to a topic
144
147
 
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`.
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`.
146
149
 
147
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.
148
151
 
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.
152
+ 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 as the placeholder in session titles.
150
153
 
151
154
  From a shell (no bot token needed):
152
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soffits/dsh-telegram-bot",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Telegram sessions, topics, model controls and approvals for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/app.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Host runtime: Telegram polling, topic routing and the DSH session bridge. */
2
2
  import { randomUUID } from 'node:crypto'
3
+ import { readFileSync, statSync } from 'node:fs'
3
4
  import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
4
5
  import https from 'node:https'
5
6
  import { tmpdir } from 'node:os'
@@ -34,14 +35,15 @@ import {
34
35
  toTelegramMarkdown,
35
36
  toolArg,
36
37
  } from './formatting.js'
37
- import { admitText } from './admission.js'
38
+ import { admitText, promptRequestId } from './admission.js'
38
39
  import { STEER_REACTION, busyMode, clearTelegramInbox, hasPendingInput, steerPrompt } from './steer.js'
40
+ import { createPrincipalTagger, sendPrincipal, telegramUserPrincipal } from './sender.js'
39
41
  import { loadState, STATE_PATH } from './state.js'
40
42
  import { createUpdateAdmission } from './update-ledger.js'
41
43
  import { publishStatus } from './status.js'
42
44
  import { messageAnswer, turnAnswer } from './final-text.js'
43
45
  import { createOutbox } from './outbox.js'
44
- import { deliveredToTopic, isAutoCommandTopic, isPlaceholderTitle, isTopicGone, resolveSendTarget, topicNameOf, topicTargetFor, topicTask, topicTitle } from './topics.js'
46
+ import { deliveredToTopic, isAutoCommandTopic, isPlaceholderTitle, isTopicGone, plannedTitle, resolveSendTarget, sessionTitleOf, topicNameOf, topicTargetFor } from './topics.js'
45
47
  import { runProgram, spokenMediaOf, transcribe, transcriptNote } from './voice.js'
46
48
  import { createStickerLibrary, describeSticker, STICKER_PREVIEW_DIR } from './stickers.js'
47
49
  import { composePrompt, DOWNLOAD_LIMIT_BYTES, downloadableFile, isForwarded, messageBody, repliedMessage, replyReference, senderLabel } from './message-context.js'
@@ -128,6 +130,10 @@ export function apply(ctx, config) {
128
130
  })
129
131
  /** Ids of legacy follow-up messages the bridge submitted from Telegram. */
130
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()
131
137
 
132
138
  /**
133
139
  * Persist the chat → session mapping atomically.
@@ -240,6 +246,7 @@ export function apply(ctx, config) {
240
246
  threadId: chat.threadId,
241
247
  title: title ?? state.sessions[sessionId]?.title ?? '',
242
248
  ...(state.sessions[sessionId]?.titleLocked === true ? { titleLocked: true } : {}),
249
+ ...(typeof state.sessions[sessionId]?.titlePerson === 'string' ? { titlePerson: state.sessions[sessionId].titlePerson } : {}),
243
250
  ...(chat.cwd === undefined ? {} : { cwd: chat.cwd }),
244
251
  ...(chat.permission === undefined ? {} : { permission: chat.permission }),
245
252
  updatedAt: Date.now(),
@@ -1739,10 +1746,11 @@ export function apply(ctx, config) {
1739
1746
  function attachAgent(agent, chat) {
1740
1747
  if (chat.boundAgent === agent) return
1741
1748
  chat.boundAgent = agent
1742
- // Older versions titled topic sessions "TG · <name>"; move them to the
1743
- // current form when the session is next used.
1744
- if (agent.session !== undefined && typeof chat.threadId === 'number' && topicOfSession(agent.session.id) !== undefined) {
1745
- fireAndForget('topic-title', () => syncTopicTitle(agent.session, chat.chatId, chat.threadId))
1749
+ // Bring a title from an older form, or with a changed person label, to
1750
+ // the current one when the session is used.
1751
+ const bound = agent.session === undefined ? undefined : topicOfSession(agent.session.id)
1752
+ if (bound !== undefined) {
1753
+ fireAndForget('topic-title', () => syncTopicTitle(agent.session, bound.chatId, bound.threadId))
1746
1754
  }
1747
1755
  agent.ctx.on('session/event', (session, event) => {
1748
1756
  try {
@@ -1928,7 +1936,11 @@ export function apply(ctx, config) {
1928
1936
  } catch {
1929
1937
  current = undefined
1930
1938
  }
1931
- if (service !== undefined && current === undefined) setSessionTitle(session, topicPrefixOf(chat.chatId, chat.threadId))
1939
+ if (service !== undefined && current === undefined) {
1940
+ const person = personOf(chat.chatId)
1941
+ setSessionTitle(session, sessionTitleOf(person, topicNameFor(chat.chatId, chat.threadId)))
1942
+ rememberTitlePerson(session.id, person)
1943
+ }
1932
1944
  }
1933
1945
  if (typeof agent?.followup !== 'function' || typeof agent?.ctx?.on !== 'function') {
1934
1946
  warn(`session ${session?.id ?? agent?.id} cannot run an empty turn; DSH may treat it as blank`)
@@ -2747,7 +2759,7 @@ export function apply(ctx, config) {
2747
2759
  }
2748
2760
  let requestId
2749
2761
  try {
2750
- 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)
2751
2763
  } catch (error) {
2752
2764
  // Not admitted: the bot's queue still delivers it after the turn.
2753
2765
  reportError('steer', error)
@@ -2839,14 +2851,16 @@ export function apply(ctx, config) {
2839
2851
  const agent = await ensureAgent(chat)
2840
2852
  await unarchiveForTopic(chat)
2841
2853
  let admitted = false
2854
+ const principal = promptPrincipal(chat, messageId)
2842
2855
  if (nativeText) {
2856
+ principals.remember(promptRequestId(chat.chatId, messageId), principal)
2843
2857
  admitted = admitText(ctx, { sessionId: chat.sessionId, chatId: chat.chatId, messageId, text }, signal)
2844
2858
  if (admitted !== false) await admitted
2845
2859
  }
2846
2860
  if (!admitted) {
2847
2861
  const id = randomUUID()
2848
2862
  telegramMessageIds.add(id)
2849
- 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 }) } })
2850
2864
  }
2851
2865
  // The turn/end listener finalizes the Telegram turn. whenIdle is only a
2852
2866
  // fallback for the turn that started this epoch, never a queued turn.
@@ -3438,48 +3452,149 @@ export function apply(ctx, config) {
3438
3452
  }
3439
3453
 
3440
3454
  /**
3441
- * The topic name a bound session's title starts with.
3455
+ * The topic name that stands in a bound session's title until DSH has
3456
+ * titled the conversation.
3442
3457
  * @param chatId - chat holding the topic.
3443
3458
  * @param threadId - topic thread.
3444
3459
  * @returns the topic's name, or "topic <id>" while it is unknown.
3445
3460
  */
3446
- function topicPrefixOf(chatId, threadId) {
3461
+ function topicNameFor(chatId, threadId) {
3447
3462
  const name = state.topicNames?.[`${chatId}:${threadId}`]
3448
3463
  return typeof name === 'string' && name.trim().length > 0 ? name.trim() : `topic ${threadId}`
3449
3464
  }
3450
3465
 
3466
+ /** Cached copy of the `userNamesFile` map, re-read when the file changes. */
3467
+ let userNamesCache = { path: '', key: '', mtimeMs: -1, names: {} }
3468
+
3469
+ /**
3470
+ * Names the operator gave Telegram users (and chats): `userNames` from the
3471
+ * config over the map in `userNamesFile`.
3472
+ * @returns `{ "<id>": name }`.
3473
+ */
3474
+ function userNamesOf() {
3475
+ const inline = readLive(config.userNames, {})
3476
+ const path = String(readLive(config.userNamesFile, '') ?? '').trim()
3477
+ const key = String(readLive(config.userNamesKey, '') ?? '').trim()
3478
+ let fromFile = {}
3479
+ if (path.length > 0) {
3480
+ try {
3481
+ const mtimeMs = statSync(path).mtimeMs
3482
+ if (userNamesCache.path !== path || userNamesCache.key !== key || userNamesCache.mtimeMs !== mtimeMs) {
3483
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
3484
+ const map = key.length > 0 ? parsed?.[key] : parsed
3485
+ userNamesCache = { path, key, mtimeMs, names: map !== null && typeof map === 'object' && !Array.isArray(map) ? map : {} }
3486
+ }
3487
+ fromFile = userNamesCache.names
3488
+ } catch (error) {
3489
+ if (userNamesCache.path !== path || userNamesCache.mtimeMs !== -2) warn(`user names not read from ${path}: ${messageOf(error)}`)
3490
+ userNamesCache = { path, key, mtimeMs: -2, names: {} }
3491
+ }
3492
+ }
3493
+ const names = {}
3494
+ for (const source of [fromFile, inline]) {
3495
+ if (source === null || typeof source !== 'object') continue
3496
+ for (const [id, name] of Object.entries(source)) {
3497
+ if (typeof name === 'string' && name.trim().length > 0) names[String(id).trim()] = name.trim()
3498
+ }
3499
+ }
3500
+ return names
3501
+ }
3502
+
3503
+ /**
3504
+ * Remember how Telegram names a chat, for session titles: a private chat's
3505
+ * first name, a group's title.
3506
+ * @param chat - Telegram chat object.
3507
+ */
3508
+ function rememberChatName(chat) {
3509
+ if (typeof chat?.id !== 'number') return
3510
+ const raw = chat.type === 'private' ? chat.first_name : chat.title
3511
+ const name = typeof raw === 'string' ? raw.trim() : ''
3512
+ if (name.length === 0) return
3513
+ state.chatNames ??= {}
3514
+ if (state.chatNames[String(chat.id)] === name) return
3515
+ state.chatNames[String(chat.id)] = name
3516
+ void persistState()
3517
+ }
3518
+
3519
+ /**
3520
+ * Whose chat a session belongs to, as its title shows it: the name the
3521
+ * operator configured for the chat (a private chat's id is its user's id),
3522
+ * else the name Telegram gives the chat (first name, group title).
3523
+ * @param chatId - Telegram chat id.
3524
+ * @returns the label.
3525
+ */
3526
+ function personOf(chatId) {
3527
+ const configured = userNamesOf()[String(chatId)]
3528
+ if (configured !== undefined) return configured
3529
+ const known = state.chatNames?.[String(chatId)]
3530
+ return typeof known === 'string' && known.length > 0 ? known : `chat ${chatId}`
3531
+ }
3532
+
3451
3533
  /**
3452
- * The topic a session is bound to.
3534
+ * Learn the names of chats the bot has bound sessions in but never heard
3535
+ * from since this version, so their titles get a name instead of an id.
3536
+ * @param signal - cancellation.
3537
+ */
3538
+ async function learnChatNames(signal) {
3539
+ const configured = userNamesOf()
3540
+ const chatIds = new Set(Object.values(state.sessions).map((record) => record?.chatId).filter((id) => typeof id === 'number'))
3541
+ for (const chatId of chatIds) {
3542
+ if (signal.aborted) return
3543
+ if (configured[String(chatId)] !== undefined || state.chatNames?.[String(chatId)] !== undefined) continue
3544
+ try {
3545
+ rememberChatName(await call('getChat', { chat_id: chatId }, signal, 10_000))
3546
+ } catch (error) {
3547
+ if (!signal.aborted) warn(`chat name not read for ${chatId}: ${messageOf(error)}`)
3548
+ }
3549
+ }
3550
+ }
3551
+
3552
+ /**
3553
+ * The chat a session is bound to: a topic, or a chat without topics.
3453
3554
  * @param sessionId - DSH session id.
3454
- * @returns `{ chatId, threadId }`, or undefined.
3555
+ * @returns `{ chatId, threadId }` (threadId undefined without topics), or undefined.
3455
3556
  */
3456
3557
  function topicOfSession(sessionId) {
3457
3558
  if (!stateLoaded || typeof sessionId !== 'string') return undefined
3458
3559
  const record = state.sessions[sessionId]
3459
- if (typeof record?.chatId !== 'number' || typeof record.threadId !== 'number') return undefined
3460
- if (state.threads[`${record.chatId}:${record.threadId}`] !== sessionId) return undefined
3461
- return { chatId: record.chatId, threadId: record.threadId }
3560
+ if (typeof record?.chatId !== 'number') return undefined
3561
+ const threadId = typeof record.threadId === 'number' ? record.threadId : undefined
3562
+ if (state.threads[`${record.chatId}:${threadId ?? 0}`] !== sessionId) return undefined
3563
+ return { chatId: record.chatId, threadId }
3462
3564
  }
3463
3565
 
3464
3566
  /**
3465
- * Bring a topic session's title to "<topic name> · <conversation title>".
3466
- * DSH writes the conversation part (its title provider, or the first-prompt
3467
- * fallback); DSH has no title hook, so the bot adds the prefix by appending
3468
- * the same title event with the same source, which keeps automatic titling
3469
- * going. Before a conversation title exists the title is the bare topic
3470
- * name, set as a user title so DSH's fallback does not replace it; the bot
3471
- * asks DSH for the real title at the first model request (see
3472
- * `startTopicTitle`). A title someone typed is left alone.
3567
+ * What a bound session's title may carry: the current person label and
3568
+ * placeholder names, plus earlier ones it may still show.
3569
+ * @param sessionId - DSH session id.
3570
+ * @param chatId - chat holding the session.
3571
+ * @param threadId - topic thread, undefined for a chat without topics.
3572
+ * @param previousNames - earlier topic names.
3573
+ * @returns `{ person, names, people }` for `plannedTitle`.
3574
+ */
3575
+ function titleContext(sessionId, chatId, threadId, previousNames = []) {
3576
+ const person = personOf(chatId)
3577
+ const names = typeof threadId === 'number' ? [topicNameFor(chatId, threadId), ...previousNames, `topic ${threadId}`] : []
3578
+ const earlier = state.sessions[sessionId]?.titlePerson
3579
+ return { person, names, people: typeof earlier === 'string' && earlier !== person ? [earlier] : [] }
3580
+ }
3581
+
3582
+ /**
3583
+ * Bring a bound session's title to "Telegram-<person>-<conversation title>"
3584
+ * (see `plannedTitle`). DSH writes the conversation part (its title
3585
+ * provider, or the first-prompt fallback); before it exists a topic
3586
+ * session's title is "Telegram-<person>-<topic name>", set as a user title
3587
+ * so DSH's fallback does not replace it, and the bot asks DSH for the real
3588
+ * title at the first model request (see `startTopicTitle`). A title someone
3589
+ * typed is left alone.
3473
3590
  * @param session - live session.
3474
- * @param chatId - chat holding the topic.
3475
- * @param threadId - topic thread.
3591
+ * @param chatId - chat holding the session.
3592
+ * @param threadId - topic thread, undefined for a chat without topics.
3476
3593
  * @param previousNames - earlier topic names the title may still carry.
3477
3594
  */
3478
3595
  function syncTopicTitle(session, chatId, threadId, previousNames = []) {
3479
3596
  const service = ctx.get('sessionTitle')
3480
3597
  if (service === undefined) return
3481
- const prefix = topicPrefixOf(chatId, threadId)
3482
- const names = [prefix, ...previousNames, `topic ${threadId}`]
3483
3598
  let current
3484
3599
  try {
3485
3600
  current = service.get(session)
@@ -3487,28 +3602,84 @@ export function apply(ctx, config) {
3487
3602
  warn(`session title not read for ${session?.id}: ${messageOf(error)}`)
3488
3603
  return
3489
3604
  }
3490
- if (current === undefined) {
3491
- setSessionTitle(session, prefix)
3492
- return
3493
- }
3494
- const title = current.title
3495
- if (current.source?.kind === 'user') {
3496
- if (isPlaceholderTitle(title, names) && title !== prefix) setSessionTitle(session, prefix)
3497
- return
3605
+ const context = titleContext(session.id, chatId, threadId, previousNames)
3606
+ const plan = plannedTitle(current, context)
3607
+ if (plan.kind === 'rename') {
3608
+ setSessionTitle(session, plan.title)
3609
+ } else if (plan.kind === 'append') {
3610
+ try {
3611
+ session.append('session/title', {
3612
+ title: plan.title,
3613
+ messageSeqs: Array.isArray(current.messageSeqs) ? [...current.messageSeqs] : [],
3614
+ source: current.source,
3615
+ })
3616
+ } catch (error) {
3617
+ warn(`session title not set for ${session?.id}: ${messageOf(error)}`)
3618
+ return
3619
+ }
3498
3620
  }
3499
- const wanted = topicTitle(prefix, topicTask(title, names))
3500
- if (wanted === title) return
3501
- try {
3502
- session.append('session/title', {
3503
- title: wanted,
3504
- messageSeqs: Array.isArray(current.messageSeqs) ? [...current.messageSeqs] : [],
3505
- source: current.source,
3506
- })
3507
- } catch (error) {
3508
- warn(`session title not set for ${session?.id}: ${messageOf(error)}`)
3621
+ rememberTitlePerson(session.id, context.person)
3622
+ }
3623
+
3624
+ /**
3625
+ * Record which person label a session's title carries, so a later change
3626
+ * of that label (a new configured name) still finds the old prefix.
3627
+ * @param sessionId - DSH session id.
3628
+ * @param person - the label now in the title.
3629
+ */
3630
+ function rememberTitlePerson(sessionId, person) {
3631
+ const record = state.sessions[sessionId]
3632
+ if (record === undefined || record.titlePerson === person) return
3633
+ record.titlePerson = person
3634
+ void persistState()
3635
+ }
3636
+
3637
+ /**
3638
+ * Move every bound session's title to the current form once the bot is
3639
+ * connected, so the sidebar shows where each session comes from without
3640
+ * waiting for it to be used. Titles are read from the session log first;
3641
+ * only a session whose title changes is opened. Topics are never renamed.
3642
+ * @param signal - cancellation.
3643
+ */
3644
+ async function migrateSessionTitles(signal) {
3645
+ const query = ctx.get('sessionQuery')
3646
+ if (ctx.get('sessionTitle') === undefined) return
3647
+ await learnChatNames(signal)
3648
+ for (const [key, sessionId] of Object.entries(state.threads)) {
3649
+ if (signal.aborted) return
3650
+ const topic = topicOfSession(sessionId)
3651
+ if (topic === undefined || `${topic.chatId}:${topic.threadId ?? 0}` !== key) continue
3652
+ try {
3653
+ const live = ctx.get('agents')?.get(sessionId)
3654
+ if (live?.session === undefined && typeof query?.readTitleSnapshot === 'function') {
3655
+ const observed = await query.readTitleSnapshot(sessionId, signal)
3656
+ const context = titleContext(sessionId, topic.chatId, topic.threadId)
3657
+ if (plannedTitle(observed?.title, context).kind === 'none') {
3658
+ rememberTitlePerson(sessionId, context.person)
3659
+ continue
3660
+ }
3661
+ }
3662
+ const agent = live ?? await openForTitle(sessionId)
3663
+ if (agent?.session !== undefined) syncTopicTitle(agent.session, topic.chatId, topic.threadId)
3664
+ } catch (error) {
3665
+ if (!signal.aborted) warn(`session title not migrated for ${sessionId}: ${messageOf(error)}`)
3666
+ }
3509
3667
  }
3510
3668
  }
3511
3669
 
3670
+ /**
3671
+ * Open a bound session only to change its title, through the Web session
3672
+ * controller as the Web UI's rename does. Unlike `ensureAgent`, a failure
3673
+ * leaves the binding alone. Without a controller the session keeps its
3674
+ * title until it is next used.
3675
+ * @param sessionId - DSH session id.
3676
+ * @returns the live agent, or undefined.
3677
+ */
3678
+ async function openForTitle(sessionId) {
3679
+ const controller = sessionControllerOf()
3680
+ return controller === undefined ? undefined : resolveThroughController(controller, sessionId)
3681
+ }
3682
+
3512
3683
  /** Sessions whose first conversation title is being generated. */
3513
3684
  const titleRequests = new Set()
3514
3685
 
@@ -3524,10 +3695,11 @@ export function apply(ctx, config) {
3524
3695
  function startTopicTitle(session) {
3525
3696
  const topic = topicOfSession(session?.id)
3526
3697
  const service = ctx.get('sessionTitle')
3527
- if (topic === undefined || typeof service?.refresh !== 'function' || titleRequests.has(session.id)) return
3698
+ // Without a topic there is no placeholder, so DSH's own titling runs.
3699
+ if (typeof topic?.threadId !== 'number' || typeof service?.refresh !== 'function' || titleRequests.has(session.id)) return
3528
3700
  const current = service.get(session)
3529
- const names = [topicPrefixOf(topic.chatId, topic.threadId), `topic ${topic.threadId}`]
3530
- if (current !== undefined && (current.source?.kind !== 'user' || !isPlaceholderTitle(current.title, names))) return
3701
+ const { person, names, people } = titleContext(session.id, topic.chatId, topic.threadId)
3702
+ if (current !== undefined && (current.source?.kind !== 'user' || !isPlaceholderTitle(current.title, names, [person, ...people]))) return
3531
3703
  const events = typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : []
3532
3704
  const hasPrompt = events.some((event) => event?.type === 'user/message'
3533
3705
  && event.data?.source?.kind === 'user'
@@ -3787,13 +3959,17 @@ export function apply(ctx, config) {
3787
3959
  * line the model also reads, `from` the request's sender.
3788
3960
  * @returns the message source.
3789
3961
  */
3790
- function postSource({ header, threadId, topic, from, self }) {
3962
+ function postSource({ header, chatId, threadId, topic, from, self }) {
3791
3963
  const job = typeof from?.job === 'string' && from.job.trim().length > 0 ? from.job.trim() : null
3792
3964
  const label = typeof from?.label === 'string' && from.label.trim().length > 0 ? from.label.trim() : null
3965
+ const principal = sendPrincipal(from)
3793
3966
  return {
3794
3967
  kind: POST_SOURCE_KIND,
3795
3968
  form: 'notice',
3796
3969
  summary: header.slice(0, 120),
3970
+ ...(principal === undefined ? {} : { principal }),
3971
+ channel: 'telegram',
3972
+ chatId: String(chatId),
3797
3973
  threadId,
3798
3974
  topic: topic ?? null,
3799
3975
  job,
@@ -3940,7 +4116,7 @@ export function apply(ctx, config) {
3940
4116
  id: randomUUID(),
3941
4117
  role: 'user',
3942
4118
  content,
3943
- source: postSource({ header, threadId, topic: target.name, from: request?.from, self }),
4119
+ source: postSource({ header, chatId, threadId, topic: target.name, from: request?.from, self }),
3944
4120
  })
3945
4121
  recorded = true
3946
4122
  } catch (error) {
@@ -4951,12 +5127,13 @@ export function apply(ctx, config) {
4951
5127
  const directTopicId = message.direct_messages_topic?.topic_id
4952
5128
  const threadId = message.message_thread_id ?? inferredTopicId(message)
4953
5129
  const announced = topicNameOf(message)
4954
- if (announced !== undefined && chat !== undefined && typeof threadId === 'number') {
4955
- try {
4956
- if (chatAllowed(chat, allowedChatsOf(config))) rememberTopicName(chat.id, threadId, announced.name, announced.authoritative)
4957
- } catch {
4958
- // A malformed allowlist is reported by the main check below.
5130
+ try {
5131
+ if (chat !== undefined && chatAllowed(chat, allowedChatsOf(config))) {
5132
+ rememberChatName(chat)
5133
+ if (announced !== undefined && typeof threadId === 'number') rememberTopicName(chat.id, threadId, announced.name, announced.authoritative)
4959
5134
  }
5135
+ } catch {
5136
+ // A malformed allowlist is reported by the main check below.
4960
5137
  }
4961
5138
  const hasAttachment = downloadableFile(message) !== undefined
4962
5139
  // A forward is a prompt even when its media cannot be attached (video,
@@ -5707,6 +5884,36 @@ export function apply(ctx, config) {
5707
5884
  return { text, attachments, messageId: message.message_id, from: message.from }
5708
5885
  }
5709
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
+
5710
5917
  /**
5711
5918
  * Route one prompt: a new topic from All, the workspace picker in a topic
5712
5919
  * without a session, or a turn in the bound session.
@@ -5720,6 +5927,7 @@ export function apply(ctx, config) {
5720
5927
  // Say who is writing: several allowed users may share one bot.
5721
5928
  const label = senderLabel(prompt.from)
5722
5929
  if (label !== undefined) prompt = { ...prompt, text: prompt.text.length > 0 ? `${label}\n${prompt.text}` : label }
5930
+ rememberSender(chatId, prompt.messageId, prompt.from)
5723
5931
  const route = routeMessage({ threadId, inSession: typeof chatState.sessionId === 'string' })
5724
5932
  if (route === 'new-topic') {
5725
5933
  await createNewSessionTopic(chatId, threadId, signal, defaultWorkspaceCwd(), prompt.text, prompt.attachments, prompt.messageId)
@@ -6025,6 +6233,7 @@ export function apply(ctx, config) {
6025
6233
  void publishCommands(tokenLifetime.signal)
6026
6234
  void unpinLegacyGoalPanels(tokenLifetime.signal)
6027
6235
  void checkForumConfiguration(me, tokenLifetime.signal)
6236
+ void migrateSessionTitles(tokenLifetime.signal).catch((error) => warn(`session titles not migrated: ${messageOf(error)}`))
6028
6237
  void notifyAll(
6029
6238
  `🟢 <b>${tr('Telegram bot enabled', 'Telegram-бот включён')}</b>\n` +
6030
6239
  `@${escapeHtml(me.username ?? String(me.id))}\n` +
@@ -6360,6 +6569,8 @@ export function apply(ctx, config) {
6360
6569
  return () => outbox.stop()
6361
6570
  }, 'dsh-telegram-bot: outbox')
6362
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 })
6363
6574
  // Host-wide feeds: every session's events, whichever client started the
6364
6575
  // turn. Only sessions bound to a topic are mirrored.
6365
6576
  ctx.on('session/event', (session, event) => {
package/src/config.js CHANGED
@@ -34,6 +34,20 @@ export const Config = z.object({
34
34
  * other field is unchanged.
35
35
  */
36
36
  restartRequest: z.number().default(0).volatile(),
37
+ /**
38
+ * Names for Telegram user ids, shown in session titles
39
+ * ("Telegram-<name>-<task>"). A private chat's id is its user's id; group
40
+ * chat ids may be listed too. Unlisted chats use the name Telegram gives
41
+ * them (a user's first name, a group's title). Wins over `userNamesFile`.
42
+ */
43
+ userNames: z.dict(z.string()).default({}).volatile(),
44
+ /**
45
+ * JSON file with more such names, re-read when it changes, so a list kept
46
+ * elsewhere stays the one source. Empty means none.
47
+ */
48
+ userNamesFile: z.string().default('').volatile(),
49
+ /** Key of the id → name object inside `userNamesFile`; empty means the whole file. */
50
+ userNamesKey: z.string().default('').volatile(),
37
51
  /** Working directory for Telegram-created DSH sessions. */
38
52
  cwd: z.string().default(process.cwd()).volatile(),
39
53
  /** Agent preset mounted for Telegram-created sessions when presets exist. */
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/state.js CHANGED
@@ -35,6 +35,7 @@ async function loadState() {
35
35
  updateLedger: parsed.updateLedger !== null && typeof parsed.updateLedger === 'object' && !Array.isArray(parsed.updateLedger) ? parsed.updateLedger : {},
36
36
  updateOffset: Number.isSafeInteger(parsed.updateOffset) && parsed.updateOffset >= 0 ? parsed.updateOffset : 0,
37
37
  topicNames: parsed.topicNames !== null && typeof parsed.topicNames === 'object' && !Array.isArray(parsed.topicNames) ? parsed.topicNames : {},
38
+ chatNames: parsed.chatNames !== null && typeof parsed.chatNames === 'object' && !Array.isArray(parsed.chatNames) ? parsed.chatNames : {},
38
39
  directChats: parsed.directChats !== null && typeof parsed.directChats === 'object' && !Array.isArray(parsed.directChats) ? parsed.directChats : {},
39
40
  directTopics: parsed.directTopics !== null && typeof parsed.directTopics === 'object' && !Array.isArray(parsed.directTopics) ? parsed.directTopics : {},
40
41
  goalPanels: parsed.goalPanels !== null && typeof parsed.goalPanels === 'object' && !Array.isArray(parsed.goalPanels) ? parsed.goalPanels : (parsed.goalPins !== null && typeof parsed.goalPins === 'object' && !Array.isArray(parsed.goalPins) ? parsed.goalPins : {}),
@@ -43,7 +44,7 @@ async function loadState() {
43
44
  } catch {
44
45
  // Missing or corrupt state is nonfatal; the next session creates a mapping.
45
46
  }
46
- return { version: 2, sessions: {}, threads: {}, updateLedger: {}, updateOffset: 0, topicNames: {}, directChats: {}, directTopics: {}, goalPanels: {} }
47
+ return { version: 2, sessions: {}, threads: {}, updateLedger: {}, updateOffset: 0, topicNames: {}, chatNames: {}, directChats: {}, directTopics: {}, goalPanels: {} }
47
48
  }
48
49
 
49
50
  export { STATE_PATH, loadState }
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
package/src/topics.js CHANGED
@@ -17,50 +17,97 @@ export function topicNameOf(message) {
17
17
  return undefined
18
18
  }
19
19
 
20
- /** Joins a topic name and the task part of a topic session's title. */
21
- export const TITLE_SEPARATOR = ' · '
20
+ /** Where a bot session comes from, the first part of its DSH title. */
21
+ export const TITLE_SOURCE = 'Telegram'
22
22
 
23
- /** The title form earlier versions gave topic sessions: "TG · <topic name>". */
23
+ /** Joins the source, the person and the task in a session title. */
24
+ const TITLE_JOIN = '-'
25
+
26
+ /** Joined a topic name and the task in titles of earlier versions. */
27
+ const LEGACY_SEPARATOR = ' · '
28
+
29
+ /** The title form the oldest versions gave topic sessions: "TG · <topic name>". */
24
30
  const LEGACY_PREFIX = 'TG · '
25
31
 
26
32
  /**
27
- * The DSH title of a topic session: the topic name, then what the
28
- * conversation is about once DSH has titled it.
29
- * @param prefix - the topic's name.
30
- * @param task - the conversation's own title, or empty before one exists.
33
+ * The DSH title of a bot session: "Telegram-<person>-<task>". Before DSH has
34
+ * titled the conversation, a topic's name stands in for the task.
35
+ * @param person - whose chat it is: a person's name, or a group's title.
36
+ * @param task - what the conversation is about; empty gives "Telegram-<person>".
31
37
  * @returns the session title.
32
38
  */
33
- export function topicTitle(prefix, task) {
39
+ export function sessionTitleOf(person, task) {
34
40
  const rest = typeof task === 'string' ? task.trim() : ''
35
- return rest.length === 0 ? prefix : `${prefix}${TITLE_SEPARATOR}${rest}`
41
+ const head = `${TITLE_SOURCE}${TITLE_JOIN}${person}`
42
+ return rest.length === 0 ? head : `${head}${TITLE_JOIN}${rest}`
36
43
  }
37
44
 
38
45
  /**
39
- * Whether a title is one the bot set before any conversation title existed:
40
- * a bare topic name (current or earlier) or the old "TG · <name>" form.
46
+ * Whether a title is a placeholder the bot set before any conversation title
47
+ * existed, in this version's form or an earlier one: "Telegram-<person>-<topic
48
+ * name>", a bare topic name, or "TG · <topic name>".
41
49
  * @param title - current session title.
42
- * @param names - topic names the title may carry.
50
+ * @param names - topic names the title may carry (current and earlier).
51
+ * @param people - person labels the title may carry (current and earlier).
43
52
  * @returns true for a bot placeholder.
44
53
  */
45
- export function isPlaceholderTitle(title, names) {
46
- return typeof title === 'string' && (title.startsWith(LEGACY_PREFIX) || names.includes(title))
54
+ export function isPlaceholderTitle(title, names, people = []) {
55
+ if (typeof title !== 'string') return false
56
+ if (title.startsWith(LEGACY_PREFIX) || names.includes(title)) return true
57
+ return people.some((person) => names.some((name) => sessionTitleOf(person, name) === title))
47
58
  }
48
59
 
49
60
  /**
50
- * The conversation part of a title, without a topic-name prefix the bot added.
61
+ * The conversation part of a title, without the source, person or topic-name
62
+ * prefix the bot added in this or an earlier version.
51
63
  * @param title - current session title.
52
- * @param names - topic names a prefix may carry, current name first.
53
- * @returns the task part; empty when the title is only a prefix.
64
+ * @param names - topic names an earlier-form prefix may carry.
65
+ * @param people - person labels a prefix may carry.
66
+ * @returns the task part; empty when the title is only a placeholder.
54
67
  */
55
- export function topicTask(title, names) {
56
- if (isPlaceholderTitle(title, names)) return ''
57
- // Longest name first, so "Chat · 2" wins over "Chat" for "Chat · 2 · Fix".
68
+ export function titleTask(title, names, people = []) {
69
+ if (isPlaceholderTitle(title, names, people)) return ''
70
+ // Longest label first, so "Ann-Marie" wins over "Ann".
71
+ for (const person of [...people].sort((a, b) => b.length - a.length)) {
72
+ const head = sessionTitleOf(person, '')
73
+ if (title === head) return ''
74
+ if (title.startsWith(`${head}${TITLE_JOIN}`)) return title.slice(head.length + TITLE_JOIN.length).trim()
75
+ }
58
76
  for (const name of [...names].sort((a, b) => b.length - a.length)) {
59
- if (title.startsWith(`${name}${TITLE_SEPARATOR}`)) return title.slice(name.length + TITLE_SEPARATOR.length).trim()
77
+ if (title.startsWith(`${name}${LEGACY_SEPARATOR}`)) return title.slice(name.length + LEGACY_SEPARATOR.length).trim()
60
78
  }
61
79
  return title
62
80
  }
63
81
 
82
+ /**
83
+ * Decide how a bot session's title should change. DSH has no title hook, so
84
+ * the bot rewrites titles after the fact: a missing title or a bot
85
+ * placeholder becomes the current placeholder through a user rename (which
86
+ * stops DSH's own fallback from replacing it); a generated title gets the
87
+ * prefix by re-appending it with the same source, so automatic titling keeps
88
+ * running. A title someone typed is left alone.
89
+ * @param current - the latest title snapshot (`{ title, source }`), if any.
90
+ * @param options - `person`, the label for the chat now; `names`, topic names
91
+ * the title may carry, current first (empty for a chat without topics);
92
+ * `people`, earlier person labels the title may carry.
93
+ * @returns `{ kind: 'rename' | 'append', title }`, or `{ kind: 'none' }`.
94
+ */
95
+ export function plannedTitle(current, { person, names = [], people = [] }) {
96
+ const labels = [person, ...people.filter((label) => label !== person)]
97
+ const placeholder = names.length > 0 ? sessionTitleOf(person, names[0]) : undefined
98
+ if (current === undefined || typeof current?.title !== 'string') {
99
+ return placeholder === undefined ? { kind: 'none' } : { kind: 'rename', title: placeholder }
100
+ }
101
+ const title = current.title
102
+ if (current.source?.kind === 'user') {
103
+ const stale = placeholder !== undefined && title !== placeholder && isPlaceholderTitle(title, names, labels)
104
+ return stale ? { kind: 'rename', title: placeholder } : { kind: 'none' }
105
+ }
106
+ const task = titleTask(title, names, labels)
107
+ const wanted = task.length > 0 ? sessionTitleOf(person, task) : (placeholder ?? sessionTitleOf(person, ''))
108
+ return wanted === title ? { kind: 'none' } : { kind: 'append', title: wanted }
109
+ }
110
+
64
111
  /**
65
112
  * Commands about the chat as a whole rather than one topic. When one of them
66
113
  * is sent in All, Telegram turns it into a topic of its own; the bot answers