@soffits/dsh-telegram-bot 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MAINTAINING.md CHANGED
@@ -5,7 +5,7 @@ This repository is a maintained fork of [Boobaw/dsh-telegram-bot](https://github
5
5
  - `/new`: a fresh session in the same topic and workspace.
6
6
  - User-created topics: each new topic asks for its workspace and a new or existing session; `/workspace` switches; topic names are never changed after session titles.
7
7
  - Two-way sync: turns started in the Web UI, by scheduled tasks or by other plugins also appear in the bound topic (`mirrorTurns`).
8
- - Sending into topics by thread id: the `telegramTopics` Host service, the `telegram_send` tool and the `dsh-telegram-send` command, with the sender (job, session) recorded and remembered, so replies to those messages carry the full original.
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
10
  - Topic session titles are `<topic name> · <DSH title>`: DSH has no title hook, so `syncTopicTitle` re-appends each generated `session/title` with the prefix and the same source (automatic titling keeps running), and `startTopicTitle` calls `sessionTitle.refresh` at the first model request, because the bare-name placeholder is a user title that stops automatic titling. Topic renames record a `telegram-topic` notice as a `user/message` (`recordNote`): session search indexes only message, tool and todo events, not titles or queued inbox items.
11
11
  - Deleted topics are forgotten as soon as any Bot API call reports them gone (`forgetGoneTopic`).
package/README.md CHANGED
@@ -142,7 +142,7 @@ Private-chat topics send with `message_thread_id`. If Telegram answers `message
142
142
 
143
143
  ## Send to a topic
144
144
 
145
- Scripts, scheduled jobs, other plugins and agents can post into a topic by its numeric thread id (`/id` in the topic shows it). The message (text and files) appears in the topic and is also recorded in the session bound to that topic, without starting a turn: it is queued as context for that session's next turn, labelled with who sent it. 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`.
145
+ Scripts, scheduled jobs, other plugins and agents can post into a topic by its numeric thread id (`/id` in the topic shows it). The message (text, files, a sticker) appears in the topic and is also recorded in the session bound to that topic, without starting a turn, labelled with who sent it. This holds for the bound session's own sends too, so a Web UI reader of that session sees the same pictures, files and stickers: pictures and stickers (as their still image) are recorded as images, other files as file attachments. An idle session gets the record at once; a busy one gets it at its next step. Thread ids belong to one chat, so the message goes to the chat the bot saw that thread in (its binding or known name), not the chat that last wrote to the bot. A thread id the bot does not know in any chat is an error; nothing is created. In the rare case two chats have a topic with the same thread id, the send fails until the caller names the chat (`chat`, `--chat`). Topic names are only labels, never addresses. A known topic with no session gets a new one first, made the way `/new` makes it (the configured working directory and preset, titled after the topic), so the message is recorded; only a topic whose user is still choosing its session just shows the message, and the result says so (`note`). If no session can take the record, the message is still sent and the result carries `recordError`.
146
146
 
147
147
  The bot also remembers each such send (`$DSH_HOME/telegram/sent-messages.json`: the last 1,000 sends from the past 180 days, text up to 20,000 characters). When someone replies to or quotes one of those messages, the agent gets the whole original text, however many Telegram messages it was split into, with its send time, job name and sending session, and a hint to open that session for more context. This works after `/new` too, when the current session never saw the message.
148
148
 
package/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  window.__ModuleLoader__.load({
2
- id: 'dsh-telegram-bot',
2
+ id: '@soffits/dsh-telegram-bot',
3
3
  factory: (require) => {
4
4
  var module = { exports: {} }
5
5
  var exports = module.exports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soffits/dsh-telegram-bot",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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
@@ -45,7 +45,7 @@ import { runProgram, spokenMediaOf, transcribe, transcriptNote } from './voice.j
45
45
  import { createStickerLibrary, describeSticker, STICKER_PREVIEW_DIR } from './stickers.js'
46
46
  import { composePrompt, DOWNLOAD_LIMIT_BYTES, downloadableFile, isForwarded, messageBody, repliedMessage, replyReference, senderLabel } from './message-context.js'
47
47
  import { createSentLog } from './sent-log.js'
48
- import { catalogKey, createModelCatalog, defaultModelOf, effortChoices, healGoneRoute, effortName, findModel, modelByKey, modelName, pageOf, pickedSelection, providerByKey, sameSelection, sessionModelOf } from './models.js'
48
+ import { catalogKey, createModelCatalog, defaultModelOf, effortChoices, effortName, findModel, isGoneModel, modelByKey, modelName, pageOf, pickedSelection, providerByKey, sameSelection, sessionModelOf } from './models.js'
49
49
  import {
50
50
  allowedChatsOf,
51
51
  allowedUsersOf,
@@ -1060,6 +1060,7 @@ export function apply(ctx, config) {
1060
1060
  if (data.length >= 12 && data.toString('latin1', 0, 4) === 'RIFF' && data.toString('latin1', 8, 12) === 'WEBP') return { mediaType: 'image/webp', ext: '.webp' }
1061
1061
  if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return { mediaType: 'image/jpeg', ext: '.jpg' }
1062
1062
  if (data.length >= 8 && data.toString('latin1', 1, 4) === 'PNG') return { mediaType: 'image/png', ext: '.png' }
1063
+ if (data.length >= 6 && data.toString('latin1', 0, 4) === 'GIF8') return { mediaType: 'image/gif', ext: '.gif' }
1063
1064
  return undefined
1064
1065
  }
1065
1066
 
@@ -1966,18 +1967,43 @@ export function apply(ctx, config) {
1966
1967
  * @returns the live agent.
1967
1968
  */
1968
1969
  /**
1969
- * Move a bound session whose model route is gone from this host onto the
1970
- * default model (see `healGoneRoute`), so the topic keeps answering.
1970
+ * Move a topic's session off a model the catalog no longer offers (a
1971
+ * removed provider or a dropped model: every turn would fail before any
1972
+ * request, so no fallback can help). The session goes to the current
1973
+ * default through the Web picker's `selectModel`, a stale per-topic pick is
1974
+ * dropped, and the topic gets one line about it. A model that is merely
1975
+ * failing now, or no catalog, changes nothing.
1976
+ * @param chat - chat bridge state.
1971
1977
  * @param agent - the session's live agent.
1972
1978
  */
1973
- async function healRoute(agent) {
1979
+ async function healRoute(chat, agent) {
1974
1980
  try {
1975
- const moved = await healGoneRoute(ctx, agent)
1976
- if (moved !== undefined) {
1977
- warn(`${agent.session?.id ?? agent.id} was on ${moved.from.provider}/${moved.from.model}, which this host no longer has; it continues on the default ${moved.to.provider}/${moved.to.model}`)
1981
+ const catalog = await modelCatalog.load()
1982
+ const pickKey = `${chat.chatId}:${chat.threadId}`
1983
+ if (isGoneModel(catalog, state.pickedModels?.[pickKey])) {
1984
+ delete state.pickedModels[pickKey]
1985
+ void persistState()
1986
+ }
1987
+ const current = agent.model?.current?.()
1988
+ if (!isGoneModel(catalog, current)) return
1989
+ const fallback = defaultModelOf(ctx)
1990
+ if (fallback === undefined || isGoneModel(catalog, fallback)) return
1991
+ const controller = ctx.get('sessionController')
1992
+ if (typeof controller?.selectModel === 'function') {
1993
+ await controller.selectModel({ sessionId: agent.session?.id ?? chat.sessionId, ...fallback })
1994
+ } else {
1995
+ agent.model.select(fallback)
1978
1996
  }
1997
+ warn(`${agent.session?.id ?? agent.id} was on ${current.provider}/${current.model}, which is not offered any more; it continues on ${fallback.provider}/${fallback.model}`)
1998
+ const name = (selection) => findModel(catalog, selection)?.model.name ?? `${selection.provider}/${selection.model}`
1999
+ await send(
2000
+ chat.chatId,
2001
+ `🤖 ${tr(`${escapeHtml(`${current.provider}/${current.model}`)} is gone; this topic now uses ${escapeHtml(name(fallback))}.`, `${escapeHtml(`${current.provider}/${current.model}`)} больше недоступна; топик теперь использует ${escapeHtml(name(fallback))}.`)}`,
2002
+ bridgeAbort.signal,
2003
+ chat.threadId,
2004
+ )
1979
2005
  } catch (error) {
1980
- warn(`could not check the model route of ${agent.session?.id ?? agent.id}: ${messageOf(error)}`)
2006
+ warn(`could not check the model of ${agent.session?.id ?? agent.id}: ${messageOf(error)}`)
1981
2007
  }
1982
2008
  }
1983
2009
 
@@ -1996,7 +2022,7 @@ export function apply(ctx, config) {
1996
2022
  if (typeof live.session?.header?.cwd === 'string') chat.cwd = live.session.header.cwd
1997
2023
  attachAgent(live, chat)
1998
2024
  await syncPermission(chat, live)
1999
- await healRoute(live)
2025
+ await healRoute(chat, live)
2000
2026
  return live
2001
2027
  }
2002
2028
  try {
@@ -2006,7 +2032,7 @@ export function apply(ctx, config) {
2006
2032
  if (typeof agent?.session?.header?.cwd === 'string') chat.cwd = agent.session.header.cwd
2007
2033
  attachAgent(agent, chat)
2008
2034
  await syncPermission(chat, agent)
2009
- await healRoute(agent)
2035
+ await healRoute(chat, agent)
2010
2036
  info(`resumed Telegram session ${chat.sessionId}`)
2011
2037
  return agent
2012
2038
  } catch (error) {
@@ -3336,18 +3362,31 @@ export function apply(ctx, config) {
3336
3362
  * @param summary - one-line label the Web UI shows.
3337
3363
  */
3338
3364
  function recordNote(agent, text, summary) {
3339
- const message = {
3365
+ recordMessage(agent, {
3340
3366
  id: randomUUID(),
3341
3367
  role: 'user',
3342
3368
  content: [{ type: 'text', text }],
3343
3369
  source: { kind: 'telegram-topic', form: 'notice', summary: summary.slice(0, 120) },
3344
- }
3370
+ })
3371
+ }
3372
+
3373
+ /**
3374
+ * Put a message into a session without starting a turn: appended now to an
3375
+ * idle session whose surface starts with its system prompt, otherwise queued
3376
+ * for the next step (see recordNote for why). Either way the Web UI shows
3377
+ * it, images included, and the model reads it at its next step.
3378
+ * @param agent - the session's live agent.
3379
+ * @param message - a user message (`{ id, role, content, source }`).
3380
+ */
3381
+ function recordMessage(agent, message) {
3345
3382
  if (agent.status === 'idle' && typeof agent.session?.append === 'function' && hasSystemHead(agent.session)) {
3346
3383
  agent.session.append('user/message', message, { surfaceOp: 'append' })
3347
3384
  } else if (typeof agent.inject === 'function') {
3348
3385
  agent.inject(message)
3386
+ } else if (typeof agent.send === 'function') {
3387
+ agent.send(message, 'next-step', false)
3349
3388
  } else {
3350
- throw new Error('The session cannot record notes.')
3389
+ throw new Error('The session cannot record messages.')
3351
3390
  }
3352
3391
  }
3353
3392
 
@@ -3566,6 +3605,59 @@ export function apply(ctx, config) {
3566
3605
  return { path, name: basename(path), data }
3567
3606
  }
3568
3607
 
3608
+ /**
3609
+ * The content blocks that show sent files in a session: pictures as images
3610
+ * (the Web UI draws them inline), everything else as file attachments. A
3611
+ * picture DSH refuses as an image is kept as a file; a file that cannot be
3612
+ * stored at all becomes a line naming it, so the rest is still recorded.
3613
+ * @param files - `{ name, data }` as read for upload.
3614
+ * @returns content blocks, in file order.
3615
+ */
3616
+ async function recordedFiles(files) {
3617
+ const store = ctx.get('attachments')
3618
+ const blocks = []
3619
+ for (const file of files) {
3620
+ const image = sniffImage(file.data)
3621
+ try {
3622
+ if (store === undefined) throw new Error('no attachment store')
3623
+ if (image !== undefined) {
3624
+ try {
3625
+ blocks.push({ type: 'image', attachment: await store.saveImage({ data: file.data, mediaType: image.mediaType, name: file.name }) })
3626
+ continue
3627
+ } catch (error) {
3628
+ warn(`${file.name} kept as a file, not an image: ${messageOf(error)}`)
3629
+ }
3630
+ }
3631
+ blocks.push({ type: 'file', attachment: await store.saveFile({ data: file.data, name: file.name }) })
3632
+ } catch (error) {
3633
+ warn(`${file.name} not stored in the session: ${messageOf(error)}`)
3634
+ blocks.push({ type: 'text', text: `[File ${file.name} was sent but could not be stored here: ${messageOf(error)}]` })
3635
+ }
3636
+ }
3637
+ return blocks
3638
+ }
3639
+
3640
+ /**
3641
+ * The sticker's still picture as an image block, for the session record.
3642
+ * @param sticker - sticker library entry.
3643
+ * @param signal - request lifetime.
3644
+ * @returns one image block, or none when the sticker has no still picture
3645
+ * or it cannot be stored (the text line still names the sticker).
3646
+ */
3647
+ async function recordedSticker(sticker, signal) {
3648
+ const store = ctx.get('attachments')
3649
+ if (store === undefined || typeof sticker?.id !== 'string') return []
3650
+ try {
3651
+ const preview = await stickerPreview(sticker, signal)
3652
+ if (preview === undefined) return []
3653
+ const attachment = await store.saveImage({ data: preview.data, mediaType: preview.mediaType, name: `sticker-${sticker.id}${extname(preview.path)}` })
3654
+ return [{ type: 'image', attachment }]
3655
+ } catch (error) {
3656
+ warn(`sticker ${sticker.id} preview not stored in the session: ${messageOf(error)}`)
3657
+ return []
3658
+ }
3659
+ }
3660
+
3569
3661
  /**
3570
3662
  * Remember that a file went to a topic in one turn, so the same file is
3571
3663
  * not uploaded twice in that turn (present, telegram_send and reply images
@@ -3697,8 +3789,8 @@ export function apply(ctx, config) {
3697
3789
  * thread id; the message goes to the chat the bot saw that thread in,
3698
3790
  * or to `chat` when given (needed only when two chats share the id);
3699
3791
  * `from` may hold `label` (who sent it), `job` (a job name)
3700
- * and `sessionId` (the sender session; when it is the bound session
3701
- * itself, no copy is recorded).
3792
+ * and `sessionId` (the sender session; the bound session sending into
3793
+ * its own topic gets its copy too, labelled "by this session").
3702
3794
  * @param signal - request lifetime.
3703
3795
  * @returns where it went and whether the session recorded it.
3704
3796
  */
@@ -3779,31 +3871,24 @@ export function apply(ctx, config) {
3779
3871
  }
3780
3872
  }
3781
3873
  }
3782
- if (typeof sessionId === 'string' && sessionId !== request?.from?.sessionId) {
3874
+ // Every send is recorded in the bound session, the sender's own included:
3875
+ // the Web UI shows that session, and a tool call alone shows neither the
3876
+ // picture nor the sticker that went to Telegram.
3877
+ if (typeof sessionId === 'string') {
3783
3878
  try {
3784
3879
  const chat = await chatFor(chatId, threadId)
3785
3880
  const agent = ctx.get('agents')?.get(sessionId) ?? await ensureAgent(chat)
3786
- const label = sender.length > 0 ? ` by ${sender}` : ''
3881
+ const self = sessionId === request?.from?.sessionId
3882
+ const label = self ? ' by this session' : sender.length > 0 ? ` by ${sender}` : ''
3787
3883
  const where = target.name === undefined ? `topic ${threadId}` : `topic "${target.name}"`
3788
3884
  const stickerLine = sticker === undefined ? '' : `\n[Sticker ${describeSticker(sticker)}]`
3789
3885
  const content = [{ type: 'text', text: `[Sent to the Telegram ${where}${label}]${text.trim().length > 0 ? `\n${text.trim()}` : ''}${stickerLine}` }]
3790
- const store = ctx.get('attachments')
3791
- for (const file of files) {
3792
- if (store === undefined) break
3793
- const mediaType = PHOTO_TYPES[extname(file.name).toLowerCase()]
3794
- const attachment = mediaType === undefined
3795
- ? await store.saveFile({ data: file.data, name: file.name })
3796
- : await store.saveImage({ data: file.data, mediaType, name: file.name })
3797
- content.push({ type: mediaType === undefined ? 'file' : 'image', attachment })
3798
- }
3886
+ content.push(...await recordedFiles(files))
3887
+ if (sticker !== undefined) content.push(...await recordedSticker(sticker, signal))
3799
3888
  const id = randomUUID()
3800
- // A next-step inbox item is durable and waits for whatever turn comes
3801
- // next; it does not wake the agent. The request id keeps the mirror from
3802
- // echoing it back into the topic.
3803
- const message = { id, role: 'user', content, source: { kind: 'user', rpcId: `telegram-post:${id}` } }
3804
- if (typeof agent?.inject === 'function') agent.inject(message)
3805
- else if (typeof agent?.send === 'function') agent.send(message, 'next-step', false)
3806
- else throw new Error('The bound session cannot record messages.')
3889
+ // Recording never starts a turn. The request id keeps the mirror from
3890
+ // echoing the copy back into the topic.
3891
+ recordMessage(agent, { id, role: 'user', content, source: { kind: 'user', rpcId: `telegram-post:${id}` } })
3807
3892
  recorded = true
3808
3893
  } catch (error) {
3809
3894
  // The message is already in Telegram; report the missing record
@@ -3825,7 +3910,7 @@ export function apply(ctx, config) {
3825
3910
  job: typeof request?.from?.job === 'string' && request.from.job.trim().length > 0 ? request.from.job.trim() : null,
3826
3911
  label: typeof request?.from?.label === 'string' ? request.from.label : null,
3827
3912
  boundSession: typeof sessionId === 'string' ? sessionId : null,
3828
- recorded: recorded || (typeof sessionId === 'string' && sessionId === request?.from?.sessionId),
3913
+ recorded,
3829
3914
  })
3830
3915
  } catch (error) {
3831
3916
  reportError('sent-log', error)
@@ -6095,7 +6180,7 @@ export function apply(ctx, config) {
6095
6180
  /** Model-facing tool for sending into a topic; see sendToTopic. */
6096
6181
  const sendTool = {
6097
6182
  name: 'telegram_send',
6098
- description: 'Send a message, and optionally files, into a Telegram topic of the DSH Telegram bot, addressed by its numeric thread id. The message appears in the topic and is recorded in the session bound to that topic, which sees it on its next turn; no turn is started. Use it to notify the user on Telegram. It goes to the chat that owns the thread; fails on a thread id the bot does not know.',
6183
+ description: 'Send a message, and optionally files, into a Telegram topic of the DSH Telegram bot, addressed by its numeric thread id. The message appears in the topic and is recorded in the session bound to that topic, files and pictures included, so it also shows there in the Web UI; that session reads it at its next step, and no turn is started. Use it to notify the user on Telegram. It goes to the chat that owns the thread; fails on a thread id the bot does not know.',
6099
6184
  parameters: {
6100
6185
  type: 'object',
6101
6186
  properties: {
@@ -6128,7 +6213,7 @@ export function apply(ctx, config) {
6128
6213
  /** Model-facing tool for the sticker library; see stickers.js. */
6129
6214
  const stickerTool = {
6130
6215
  name: 'telegram_sticker',
6131
- description: 'Use the Telegram sticker library: stickers the user sent (added automatically) and whole sets you add. find: search by meaning words, emoji or set. view: see one sticker as an image. note: save what a sticker means and when to use it, so find can pick it later. add_set: load every sticker of a set by its name (the part after t.me/addstickers/). send: send a sticker into a topic by thread id; it is recorded in the bound session like telegram_send.',
6216
+ description: 'Use the Telegram sticker library: stickers the user sent (added automatically) and whole sets you add. find: search by meaning words, emoji or set. view: see one sticker as an image. note: save what a sticker means and when to use it, so find can pick it later. add_set: load every sticker of a set by its name (the part after t.me/addstickers/). send: send a sticker into a topic by thread id; it is recorded in the bound session, with its picture, like telegram_send.',
6132
6217
  parameters: {
6133
6218
  type: 'object',
6134
6219
  properties: {
package/src/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  window.__ModuleLoader__.load({
2
- id: 'dsh-telegram-bot',
2
+ id: '@soffits/dsh-telegram-bot',
3
3
  factory: (require) => {
4
4
  var module = { exports: {} }
5
5
  var exports = module.exports
package/src/models.js CHANGED
@@ -79,34 +79,19 @@ export function defaultModelOf(ctx) {
79
79
  return undefined
80
80
  }
81
81
 
82
- /** Failure codes that mean a route is gone from this host, not briefly down. */
83
- const GONE_ROUTE_CODES = new Set(['NO_ADAPTER', 'UNKNOWN_MODEL'])
84
-
85
82
  /**
86
- * Move a session off a model route this host no longer has (a provider that
87
- * was removed, a model dropped from its catalog) onto the default model, so
88
- * a topic left on that route answers again instead of failing every turn
89
- * with NO_ADAPTER. The switch is recorded on the session only, the way a job
90
- * picks its model; the saved default stays as it is. Routes that resolve, or
91
- * fail for any other reason, are left alone.
92
- * @param ctx - Host context (`llm`, `agentDefaultModel`).
93
- * @param agent - the session's live agent.
94
- * @returns the dead route and the default it moved to, or undefined.
83
+ * Whether the catalog no longer offers a selection's provider or model, for
84
+ * example after a provider was removed from the profile. A provider the
85
+ * catalog reports as failing is not counted: its models are unknown now, not
86
+ * gone. Without a catalog nothing is called gone.
87
+ * @param catalog - loaded catalog, or undefined.
88
+ * @param selection - provider and model, or undefined.
89
+ * @returns true when the selection names something the catalog dropped.
95
90
  */
96
- export async function healGoneRoute(ctx, agent) {
97
- const current = agent?.model?.current?.()
98
- const llm = ctx.get('llm')
99
- if (current === undefined || typeof llm?.resolveCallConfig !== 'function') return undefined
100
- try {
101
- await llm.resolveCallConfig(toSelection(current))
102
- return undefined
103
- } catch (error) {
104
- if (!GONE_ROUTE_CODES.has(error?.code)) return undefined
105
- }
106
- const fallback = defaultModelOf(ctx)
107
- if (fallback === undefined || (fallback.provider === current.provider && fallback.model === current.model)) return undefined
108
- agent.model.select(fallback)
109
- return { from: toSelection(current), to: fallback }
91
+ export function isGoneModel(catalog, selection) {
92
+ if (catalog === undefined || typeof selection?.provider !== 'string' || typeof selection?.model !== 'string') return false
93
+ if (catalog.failures.some((failure) => failure?.id === selection.provider)) return false
94
+ return findModel(catalog, selection) === undefined
110
95
  }
111
96
 
112
97
  /**