@soffits/dsh-telegram-bot 0.4.2 → 0.4.4

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,7 +7,7 @@ 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
13
  - Sender labels: each Telegram prompt names who wrote 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-Livia-Fix empty Telegram sessions`, 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.
@@ -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
 
@@ -146,7 +149,7 @@ Scripts, scheduled jobs, other plugins and agents can post into a topic by its n
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.2",
3
+ "version": "0.4.4",
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'
@@ -41,7 +42,7 @@ import { createUpdateAdmission } from './update-ledger.js'
41
42
  import { publishStatus } from './status.js'
42
43
  import { messageAnswer, turnAnswer } from './final-text.js'
43
44
  import { createOutbox } from './outbox.js'
44
- import { deliveredToTopic, isAutoCommandTopic, isPlaceholderTitle, isTopicGone, resolveSendTarget, topicNameOf, topicTargetFor, topicTask, topicTitle } from './topics.js'
45
+ import { deliveredToTopic, isAutoCommandTopic, isPlaceholderTitle, isTopicGone, plannedTitle, resolveSendTarget, sessionTitleOf, topicNameOf, topicTargetFor } from './topics.js'
45
46
  import { runProgram, spokenMediaOf, transcribe, transcriptNote } from './voice.js'
46
47
  import { createStickerLibrary, describeSticker, STICKER_PREVIEW_DIR } from './stickers.js'
47
48
  import { composePrompt, DOWNLOAD_LIMIT_BYTES, downloadableFile, isForwarded, messageBody, repliedMessage, replyReference, senderLabel } from './message-context.js'
@@ -240,6 +241,7 @@ export function apply(ctx, config) {
240
241
  threadId: chat.threadId,
241
242
  title: title ?? state.sessions[sessionId]?.title ?? '',
242
243
  ...(state.sessions[sessionId]?.titleLocked === true ? { titleLocked: true } : {}),
244
+ ...(typeof state.sessions[sessionId]?.titlePerson === 'string' ? { titlePerson: state.sessions[sessionId].titlePerson } : {}),
243
245
  ...(chat.cwd === undefined ? {} : { cwd: chat.cwd }),
244
246
  ...(chat.permission === undefined ? {} : { permission: chat.permission }),
245
247
  updatedAt: Date.now(),
@@ -1739,10 +1741,11 @@ export function apply(ctx, config) {
1739
1741
  function attachAgent(agent, chat) {
1740
1742
  if (chat.boundAgent === agent) return
1741
1743
  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))
1744
+ // Bring a title from an older form, or with a changed person label, to
1745
+ // the current one when the session is used.
1746
+ const bound = agent.session === undefined ? undefined : topicOfSession(agent.session.id)
1747
+ if (bound !== undefined) {
1748
+ fireAndForget('topic-title', () => syncTopicTitle(agent.session, bound.chatId, bound.threadId))
1746
1749
  }
1747
1750
  agent.ctx.on('session/event', (session, event) => {
1748
1751
  try {
@@ -1928,7 +1931,11 @@ export function apply(ctx, config) {
1928
1931
  } catch {
1929
1932
  current = undefined
1930
1933
  }
1931
- if (service !== undefined && current === undefined) setSessionTitle(session, topicPrefixOf(chat.chatId, chat.threadId))
1934
+ if (service !== undefined && current === undefined) {
1935
+ const person = personOf(chat.chatId)
1936
+ setSessionTitle(session, sessionTitleOf(person, topicNameFor(chat.chatId, chat.threadId)))
1937
+ rememberTitlePerson(session.id, person)
1938
+ }
1932
1939
  }
1933
1940
  if (typeof agent?.followup !== 'function' || typeof agent?.ctx?.on !== 'function') {
1934
1941
  warn(`session ${session?.id ?? agent?.id} cannot run an empty turn; DSH may treat it as blank`)
@@ -2800,6 +2807,25 @@ export function apply(ctx, config) {
2800
2807
  await sendPlain(chat.chatId, tr('↪️ Sent to the running turn.', '↪️ Отправлено в текущий ход.'), signal, chat.threadId)
2801
2808
  }
2802
2809
 
2810
+ /**
2811
+ * Bring a topic's archived session back before a message from the topic
2812
+ * starts a turn in it. DSH admits a prompt into an archived session but
2813
+ * rejects its every model step (the archived-session gate), so without this
2814
+ * the turn would end as `blocked` and the message would get no answer.
2815
+ * @param chat - chat bridge state.
2816
+ */
2817
+ async function unarchiveForTopic(chat) {
2818
+ const registry = ctx.get('workspaceRegistry')
2819
+ const sessionId = chat.sessionId
2820
+ if (typeof sessionId !== 'string' || registry?.archivedSessionIds?.includes(sessionId) !== true) return
2821
+ if (typeof registry.unarchiveSession !== 'function') {
2822
+ warn(`session ${sessionId} is archived and this DSH cannot unarchive it; the message will not be answered`)
2823
+ return
2824
+ }
2825
+ await registry.unarchiveSession(sessionId)
2826
+ info(`unarchived session ${sessionId} for a message in its topic`)
2827
+ }
2828
+
2803
2829
  /**
2804
2830
  * Show the turn's placeholders and hand its prompt to DSH.
2805
2831
  * @param chat - chat bridge state.
@@ -2818,6 +2844,7 @@ export function apply(ctx, config) {
2818
2844
  chat.lastEdit = Date.now()
2819
2845
  try {
2820
2846
  const agent = await ensureAgent(chat)
2847
+ await unarchiveForTopic(chat)
2821
2848
  let admitted = false
2822
2849
  if (nativeText) {
2823
2850
  admitted = admitText(ctx, { sessionId: chat.sessionId, chatId: chat.chatId, messageId, text }, signal)
@@ -3418,48 +3445,149 @@ export function apply(ctx, config) {
3418
3445
  }
3419
3446
 
3420
3447
  /**
3421
- * The topic name a bound session's title starts with.
3448
+ * The topic name that stands in a bound session's title until DSH has
3449
+ * titled the conversation.
3422
3450
  * @param chatId - chat holding the topic.
3423
3451
  * @param threadId - topic thread.
3424
3452
  * @returns the topic's name, or "topic <id>" while it is unknown.
3425
3453
  */
3426
- function topicPrefixOf(chatId, threadId) {
3454
+ function topicNameFor(chatId, threadId) {
3427
3455
  const name = state.topicNames?.[`${chatId}:${threadId}`]
3428
3456
  return typeof name === 'string' && name.trim().length > 0 ? name.trim() : `topic ${threadId}`
3429
3457
  }
3430
3458
 
3459
+ /** Cached copy of the `userNamesFile` map, re-read when the file changes. */
3460
+ let userNamesCache = { path: '', key: '', mtimeMs: -1, names: {} }
3461
+
3462
+ /**
3463
+ * Names the operator gave Telegram users (and chats): `userNames` from the
3464
+ * config over the map in `userNamesFile`.
3465
+ * @returns `{ "<id>": name }`.
3466
+ */
3467
+ function userNamesOf() {
3468
+ const inline = readLive(config.userNames, {})
3469
+ const path = String(readLive(config.userNamesFile, '') ?? '').trim()
3470
+ const key = String(readLive(config.userNamesKey, '') ?? '').trim()
3471
+ let fromFile = {}
3472
+ if (path.length > 0) {
3473
+ try {
3474
+ const mtimeMs = statSync(path).mtimeMs
3475
+ if (userNamesCache.path !== path || userNamesCache.key !== key || userNamesCache.mtimeMs !== mtimeMs) {
3476
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
3477
+ const map = key.length > 0 ? parsed?.[key] : parsed
3478
+ userNamesCache = { path, key, mtimeMs, names: map !== null && typeof map === 'object' && !Array.isArray(map) ? map : {} }
3479
+ }
3480
+ fromFile = userNamesCache.names
3481
+ } catch (error) {
3482
+ if (userNamesCache.path !== path || userNamesCache.mtimeMs !== -2) warn(`user names not read from ${path}: ${messageOf(error)}`)
3483
+ userNamesCache = { path, key, mtimeMs: -2, names: {} }
3484
+ }
3485
+ }
3486
+ const names = {}
3487
+ for (const source of [fromFile, inline]) {
3488
+ if (source === null || typeof source !== 'object') continue
3489
+ for (const [id, name] of Object.entries(source)) {
3490
+ if (typeof name === 'string' && name.trim().length > 0) names[String(id).trim()] = name.trim()
3491
+ }
3492
+ }
3493
+ return names
3494
+ }
3495
+
3431
3496
  /**
3432
- * The topic a session is bound to.
3497
+ * Remember how Telegram names a chat, for session titles: a private chat's
3498
+ * first name, a group's title.
3499
+ * @param chat - Telegram chat object.
3500
+ */
3501
+ function rememberChatName(chat) {
3502
+ if (typeof chat?.id !== 'number') return
3503
+ const raw = chat.type === 'private' ? chat.first_name : chat.title
3504
+ const name = typeof raw === 'string' ? raw.trim() : ''
3505
+ if (name.length === 0) return
3506
+ state.chatNames ??= {}
3507
+ if (state.chatNames[String(chat.id)] === name) return
3508
+ state.chatNames[String(chat.id)] = name
3509
+ void persistState()
3510
+ }
3511
+
3512
+ /**
3513
+ * Whose chat a session belongs to, as its title shows it: the name the
3514
+ * operator configured for the chat (a private chat's id is its user's id),
3515
+ * else the name Telegram gives the chat (first name, group title).
3516
+ * @param chatId - Telegram chat id.
3517
+ * @returns the label.
3518
+ */
3519
+ function personOf(chatId) {
3520
+ const configured = userNamesOf()[String(chatId)]
3521
+ if (configured !== undefined) return configured
3522
+ const known = state.chatNames?.[String(chatId)]
3523
+ return typeof known === 'string' && known.length > 0 ? known : `chat ${chatId}`
3524
+ }
3525
+
3526
+ /**
3527
+ * Learn the names of chats the bot has bound sessions in but never heard
3528
+ * from since this version, so their titles get a name instead of an id.
3529
+ * @param signal - cancellation.
3530
+ */
3531
+ async function learnChatNames(signal) {
3532
+ const configured = userNamesOf()
3533
+ const chatIds = new Set(Object.values(state.sessions).map((record) => record?.chatId).filter((id) => typeof id === 'number'))
3534
+ for (const chatId of chatIds) {
3535
+ if (signal.aborted) return
3536
+ if (configured[String(chatId)] !== undefined || state.chatNames?.[String(chatId)] !== undefined) continue
3537
+ try {
3538
+ rememberChatName(await call('getChat', { chat_id: chatId }, signal, 10_000))
3539
+ } catch (error) {
3540
+ if (!signal.aborted) warn(`chat name not read for ${chatId}: ${messageOf(error)}`)
3541
+ }
3542
+ }
3543
+ }
3544
+
3545
+ /**
3546
+ * The chat a session is bound to: a topic, or a chat without topics.
3433
3547
  * @param sessionId - DSH session id.
3434
- * @returns `{ chatId, threadId }`, or undefined.
3548
+ * @returns `{ chatId, threadId }` (threadId undefined without topics), or undefined.
3435
3549
  */
3436
3550
  function topicOfSession(sessionId) {
3437
3551
  if (!stateLoaded || typeof sessionId !== 'string') return undefined
3438
3552
  const record = state.sessions[sessionId]
3439
- if (typeof record?.chatId !== 'number' || typeof record.threadId !== 'number') return undefined
3440
- if (state.threads[`${record.chatId}:${record.threadId}`] !== sessionId) return undefined
3441
- return { chatId: record.chatId, threadId: record.threadId }
3553
+ if (typeof record?.chatId !== 'number') return undefined
3554
+ const threadId = typeof record.threadId === 'number' ? record.threadId : undefined
3555
+ if (state.threads[`${record.chatId}:${threadId ?? 0}`] !== sessionId) return undefined
3556
+ return { chatId: record.chatId, threadId }
3442
3557
  }
3443
3558
 
3444
3559
  /**
3445
- * Bring a topic session's title to "<topic name> · <conversation title>".
3446
- * DSH writes the conversation part (its title provider, or the first-prompt
3447
- * fallback); DSH has no title hook, so the bot adds the prefix by appending
3448
- * the same title event with the same source, which keeps automatic titling
3449
- * going. Before a conversation title exists the title is the bare topic
3450
- * name, set as a user title so DSH's fallback does not replace it; the bot
3451
- * asks DSH for the real title at the first model request (see
3452
- * `startTopicTitle`). A title someone typed is left alone.
3560
+ * What a bound session's title may carry: the current person label and
3561
+ * placeholder names, plus earlier ones it may still show.
3562
+ * @param sessionId - DSH session id.
3563
+ * @param chatId - chat holding the session.
3564
+ * @param threadId - topic thread, undefined for a chat without topics.
3565
+ * @param previousNames - earlier topic names.
3566
+ * @returns `{ person, names, people }` for `plannedTitle`.
3567
+ */
3568
+ function titleContext(sessionId, chatId, threadId, previousNames = []) {
3569
+ const person = personOf(chatId)
3570
+ const names = typeof threadId === 'number' ? [topicNameFor(chatId, threadId), ...previousNames, `topic ${threadId}`] : []
3571
+ const earlier = state.sessions[sessionId]?.titlePerson
3572
+ return { person, names, people: typeof earlier === 'string' && earlier !== person ? [earlier] : [] }
3573
+ }
3574
+
3575
+ /**
3576
+ * Bring a bound session's title to "Telegram-<person>-<conversation title>"
3577
+ * (see `plannedTitle`). DSH writes the conversation part (its title
3578
+ * provider, or the first-prompt fallback); before it exists a topic
3579
+ * session's title is "Telegram-<person>-<topic name>", set as a user title
3580
+ * so DSH's fallback does not replace it, and the bot asks DSH for the real
3581
+ * title at the first model request (see `startTopicTitle`). A title someone
3582
+ * typed is left alone.
3453
3583
  * @param session - live session.
3454
- * @param chatId - chat holding the topic.
3455
- * @param threadId - topic thread.
3584
+ * @param chatId - chat holding the session.
3585
+ * @param threadId - topic thread, undefined for a chat without topics.
3456
3586
  * @param previousNames - earlier topic names the title may still carry.
3457
3587
  */
3458
3588
  function syncTopicTitle(session, chatId, threadId, previousNames = []) {
3459
3589
  const service = ctx.get('sessionTitle')
3460
3590
  if (service === undefined) return
3461
- const prefix = topicPrefixOf(chatId, threadId)
3462
- const names = [prefix, ...previousNames, `topic ${threadId}`]
3463
3591
  let current
3464
3592
  try {
3465
3593
  current = service.get(session)
@@ -3467,28 +3595,84 @@ export function apply(ctx, config) {
3467
3595
  warn(`session title not read for ${session?.id}: ${messageOf(error)}`)
3468
3596
  return
3469
3597
  }
3470
- if (current === undefined) {
3471
- setSessionTitle(session, prefix)
3472
- return
3473
- }
3474
- const title = current.title
3475
- if (current.source?.kind === 'user') {
3476
- if (isPlaceholderTitle(title, names) && title !== prefix) setSessionTitle(session, prefix)
3477
- return
3598
+ const context = titleContext(session.id, chatId, threadId, previousNames)
3599
+ const plan = plannedTitle(current, context)
3600
+ if (plan.kind === 'rename') {
3601
+ setSessionTitle(session, plan.title)
3602
+ } else if (plan.kind === 'append') {
3603
+ try {
3604
+ session.append('session/title', {
3605
+ title: plan.title,
3606
+ messageSeqs: Array.isArray(current.messageSeqs) ? [...current.messageSeqs] : [],
3607
+ source: current.source,
3608
+ })
3609
+ } catch (error) {
3610
+ warn(`session title not set for ${session?.id}: ${messageOf(error)}`)
3611
+ return
3612
+ }
3478
3613
  }
3479
- const wanted = topicTitle(prefix, topicTask(title, names))
3480
- if (wanted === title) return
3481
- try {
3482
- session.append('session/title', {
3483
- title: wanted,
3484
- messageSeqs: Array.isArray(current.messageSeqs) ? [...current.messageSeqs] : [],
3485
- source: current.source,
3486
- })
3487
- } catch (error) {
3488
- warn(`session title not set for ${session?.id}: ${messageOf(error)}`)
3614
+ rememberTitlePerson(session.id, context.person)
3615
+ }
3616
+
3617
+ /**
3618
+ * Record which person label a session's title carries, so a later change
3619
+ * of that label (a new configured name) still finds the old prefix.
3620
+ * @param sessionId - DSH session id.
3621
+ * @param person - the label now in the title.
3622
+ */
3623
+ function rememberTitlePerson(sessionId, person) {
3624
+ const record = state.sessions[sessionId]
3625
+ if (record === undefined || record.titlePerson === person) return
3626
+ record.titlePerson = person
3627
+ void persistState()
3628
+ }
3629
+
3630
+ /**
3631
+ * Move every bound session's title to the current form once the bot is
3632
+ * connected, so the sidebar shows where each session comes from without
3633
+ * waiting for it to be used. Titles are read from the session log first;
3634
+ * only a session whose title changes is opened. Topics are never renamed.
3635
+ * @param signal - cancellation.
3636
+ */
3637
+ async function migrateSessionTitles(signal) {
3638
+ const query = ctx.get('sessionQuery')
3639
+ if (ctx.get('sessionTitle') === undefined) return
3640
+ await learnChatNames(signal)
3641
+ for (const [key, sessionId] of Object.entries(state.threads)) {
3642
+ if (signal.aborted) return
3643
+ const topic = topicOfSession(sessionId)
3644
+ if (topic === undefined || `${topic.chatId}:${topic.threadId ?? 0}` !== key) continue
3645
+ try {
3646
+ const live = ctx.get('agents')?.get(sessionId)
3647
+ if (live?.session === undefined && typeof query?.readTitleSnapshot === 'function') {
3648
+ const observed = await query.readTitleSnapshot(sessionId, signal)
3649
+ const context = titleContext(sessionId, topic.chatId, topic.threadId)
3650
+ if (plannedTitle(observed?.title, context).kind === 'none') {
3651
+ rememberTitlePerson(sessionId, context.person)
3652
+ continue
3653
+ }
3654
+ }
3655
+ const agent = live ?? await openForTitle(sessionId)
3656
+ if (agent?.session !== undefined) syncTopicTitle(agent.session, topic.chatId, topic.threadId)
3657
+ } catch (error) {
3658
+ if (!signal.aborted) warn(`session title not migrated for ${sessionId}: ${messageOf(error)}`)
3659
+ }
3489
3660
  }
3490
3661
  }
3491
3662
 
3663
+ /**
3664
+ * Open a bound session only to change its title, through the Web session
3665
+ * controller as the Web UI's rename does. Unlike `ensureAgent`, a failure
3666
+ * leaves the binding alone. Without a controller the session keeps its
3667
+ * title until it is next used.
3668
+ * @param sessionId - DSH session id.
3669
+ * @returns the live agent, or undefined.
3670
+ */
3671
+ async function openForTitle(sessionId) {
3672
+ const controller = sessionControllerOf()
3673
+ return controller === undefined ? undefined : resolveThroughController(controller, sessionId)
3674
+ }
3675
+
3492
3676
  /** Sessions whose first conversation title is being generated. */
3493
3677
  const titleRequests = new Set()
3494
3678
 
@@ -3504,10 +3688,11 @@ export function apply(ctx, config) {
3504
3688
  function startTopicTitle(session) {
3505
3689
  const topic = topicOfSession(session?.id)
3506
3690
  const service = ctx.get('sessionTitle')
3507
- if (topic === undefined || typeof service?.refresh !== 'function' || titleRequests.has(session.id)) return
3691
+ // Without a topic there is no placeholder, so DSH's own titling runs.
3692
+ if (typeof topic?.threadId !== 'number' || typeof service?.refresh !== 'function' || titleRequests.has(session.id)) return
3508
3693
  const current = service.get(session)
3509
- const names = [topicPrefixOf(topic.chatId, topic.threadId), `topic ${topic.threadId}`]
3510
- if (current !== undefined && (current.source?.kind !== 'user' || !isPlaceholderTitle(current.title, names))) return
3694
+ const { person, names, people } = titleContext(session.id, topic.chatId, topic.threadId)
3695
+ if (current !== undefined && (current.source?.kind !== 'user' || !isPlaceholderTitle(current.title, names, [person, ...people]))) return
3511
3696
  const events = typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : []
3512
3697
  const hasPrompt = events.some((event) => event?.type === 'user/message'
3513
3698
  && event.data?.source?.kind === 'user'
@@ -4931,12 +5116,13 @@ export function apply(ctx, config) {
4931
5116
  const directTopicId = message.direct_messages_topic?.topic_id
4932
5117
  const threadId = message.message_thread_id ?? inferredTopicId(message)
4933
5118
  const announced = topicNameOf(message)
4934
- if (announced !== undefined && chat !== undefined && typeof threadId === 'number') {
4935
- try {
4936
- if (chatAllowed(chat, allowedChatsOf(config))) rememberTopicName(chat.id, threadId, announced.name, announced.authoritative)
4937
- } catch {
4938
- // A malformed allowlist is reported by the main check below.
5119
+ try {
5120
+ if (chat !== undefined && chatAllowed(chat, allowedChatsOf(config))) {
5121
+ rememberChatName(chat)
5122
+ if (announced !== undefined && typeof threadId === 'number') rememberTopicName(chat.id, threadId, announced.name, announced.authoritative)
4939
5123
  }
5124
+ } catch {
5125
+ // A malformed allowlist is reported by the main check below.
4940
5126
  }
4941
5127
  const hasAttachment = downloadableFile(message) !== undefined
4942
5128
  // A forward is a prompt even when its media cannot be attached (video,
@@ -6005,6 +6191,7 @@ export function apply(ctx, config) {
6005
6191
  void publishCommands(tokenLifetime.signal)
6006
6192
  void unpinLegacyGoalPanels(tokenLifetime.signal)
6007
6193
  void checkForumConfiguration(me, tokenLifetime.signal)
6194
+ void migrateSessionTitles(tokenLifetime.signal).catch((error) => warn(`session titles not migrated: ${messageOf(error)}`))
6008
6195
  void notifyAll(
6009
6196
  `🟢 <b>${tr('Telegram bot enabled', 'Telegram-бот включён')}</b>\n` +
6010
6197
  `@${escapeHtml(me.username ?? String(me.id))}\n` +
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/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/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