@soffits/dsh-telegram-bot 0.0.0-stage → 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/CREDITS.md +24 -0
- package/LICENSE +22 -0
- package/MAINTAINING.md +39 -0
- package/README.md +293 -2
- package/bin/dsh-telegram-send.js +110 -0
- package/client.js +809 -0
- package/cordis.patch.yml +8 -0
- package/docs/bot-setting.gif +0 -0
- package/icon.svg +12 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/package.json +72 -4
- package/src/admission.js +21 -0
- package/src/app.js +6371 -0
- package/src/client.js +809 -0
- package/src/config.js +73 -0
- package/src/constants.js +80 -0
- package/src/errors.js +16 -0
- package/src/final-text.js +45 -0
- package/src/formatting.js +390 -0
- package/src/index.js +24 -0
- package/src/message-context.js +339 -0
- package/src/models.js +242 -0
- package/src/outbox.js +160 -0
- package/src/sent-log.js +112 -0
- package/src/state.js +49 -0
- package/src/status.js +43 -0
- package/src/steer.js +104 -0
- package/src/stickers.js +185 -0
- package/src/topics.js +211 -0
- package/src/update-ledger.js +58 -0
- package/src/utils.js +139 -0
- package/src/voice.js +151 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describe what an incoming Telegram message replies to, quotes or forwards,
|
|
3
|
+
* as short labelled plain text the agent reads before the message itself.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Longest quoted or forwarded text passed to the agent, in characters. */
|
|
7
|
+
export const QUOTE_LIMIT = 2000
|
|
8
|
+
|
|
9
|
+
/** Longest text of a message sent on someone's behalf passed to the agent, in characters. */
|
|
10
|
+
export const SENT_TEXT_LIMIT = 8000
|
|
11
|
+
|
|
12
|
+
/** Largest file Telegram lets a bot download (getFile). */
|
|
13
|
+
export const DOWNLOAD_LIMIT_BYTES = 20 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
/** Labels the bridge puts on prompts it mirrors from other clients. */
|
|
16
|
+
const MIRROR_LABELS = [
|
|
17
|
+
['🖥 From Web:', 'the Web UI (a user prompt this bot mirrored into the chat, not an assistant answer)'],
|
|
18
|
+
['🖥 Из Web:', 'the Web UI (a user prompt this bot mirrored into the chat, not an assistant answer)'],
|
|
19
|
+
['⏰ Scheduled task:', 'a scheduled task (a prompt this bot mirrored into the chat, not an assistant answer)'],
|
|
20
|
+
['⏰ Задача по расписанию:', 'a scheduled task (a prompt this bot mirrored into the chat, not an assistant answer)'],
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Text or caption of a message.
|
|
25
|
+
* @param message - Telegram message or external reply.
|
|
26
|
+
* @returns the text, or an empty string.
|
|
27
|
+
*/
|
|
28
|
+
export function messageBody(message) {
|
|
29
|
+
if (typeof message?.text === 'string') return message.text
|
|
30
|
+
if (typeof message?.caption === 'string') return message.caption
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when the message was forwarded from somewhere else.
|
|
36
|
+
* @param message - Telegram message.
|
|
37
|
+
*/
|
|
38
|
+
export function isForwarded(message) {
|
|
39
|
+
return originOf(message) !== undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The photo, document, spoken recording (voice, audio, round video) or
|
|
44
|
+
* sticker of a message, when the bot can download it.
|
|
45
|
+
* @param message - Telegram message or external reply.
|
|
46
|
+
* @returns the file kind and size, or undefined.
|
|
47
|
+
*/
|
|
48
|
+
export function downloadableFile(message) {
|
|
49
|
+
const photo = Array.isArray(message?.photo) ? message.photo.at(-1) : undefined
|
|
50
|
+
const candidates = [['photo', photo], ['document', message?.document], ['voice', message?.voice], ['video_note', message?.video_note], ['audio', message?.audio], ['sticker', message?.sticker]]
|
|
51
|
+
const found = candidates.find(([, file]) => typeof file?.file_id === 'string')
|
|
52
|
+
if (found === undefined) return undefined
|
|
53
|
+
const [kind, file] = found
|
|
54
|
+
return { kind, size: typeof file.file_size === 'number' ? file.file_size : undefined }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The message a reply points at, ignoring the topic's own first message,
|
|
59
|
+
* which Telegram sets as the reply target of every message in a topic.
|
|
60
|
+
* @param message - Telegram message.
|
|
61
|
+
* @returns the replied-to message, or undefined.
|
|
62
|
+
*/
|
|
63
|
+
export function repliedMessage(message) {
|
|
64
|
+
const target = message?.reply_to_message
|
|
65
|
+
if (target === undefined || target === null) return undefined
|
|
66
|
+
if (target.forum_topic_created !== undefined) return undefined
|
|
67
|
+
if (message.is_topic_message === true && target.message_id === message.message_thread_id) return undefined
|
|
68
|
+
return target
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Chat and message id of the message a reply points at, for looking it up
|
|
73
|
+
* in the sent-message log.
|
|
74
|
+
* @param message - Telegram message.
|
|
75
|
+
* @returns { chatId, messageId }, or undefined when it is not a reply.
|
|
76
|
+
*/
|
|
77
|
+
export function replyReference(message) {
|
|
78
|
+
if (originOf(message) !== undefined) return undefined
|
|
79
|
+
const target = repliedMessage(message)
|
|
80
|
+
if (target !== undefined) return { chatId: target.chat?.id ?? message.chat?.id, messageId: target.message_id }
|
|
81
|
+
const external = message?.external_reply
|
|
82
|
+
if (external?.chat?.id !== undefined && Number.isSafeInteger(external.message_id)) return { chatId: external.chat.id, messageId: external.message_id }
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the prompt text for one message: context lines, then the message.
|
|
88
|
+
* A forwarded message is shown in full inside its context block.
|
|
89
|
+
* @param message - Telegram message.
|
|
90
|
+
* @param options - botId: this bot's user id; replyFile: outcome of attaching
|
|
91
|
+
* the replied-to file ('attached', 'too-large', 'failed'); ownFile: true when
|
|
92
|
+
* the message's own photo or document is attached; maxQuote: text limit;
|
|
93
|
+
* sent: the sent-message log entry the replied-to message belongs to;
|
|
94
|
+
* currentSession: the session now bound to the topic.
|
|
95
|
+
* @returns the prompt text, possibly empty.
|
|
96
|
+
*/
|
|
97
|
+
export function composePrompt(message, options = {}) {
|
|
98
|
+
const body = messageBody(message).trim()
|
|
99
|
+
const forward = describeForward(message, body, options)
|
|
100
|
+
const reply = forward === undefined ? describeReply(message, options) : undefined
|
|
101
|
+
const sent = reply === undefined ? undefined : describeSent(options.sent, { currentSession: options.currentSession })
|
|
102
|
+
const blocks = [reply, sent, forward].filter((block) => block !== undefined)
|
|
103
|
+
if (forward === undefined && body.length > 0) blocks.push(body)
|
|
104
|
+
return blocks.join('\n\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The line that tells the agent who wrote a Telegram message. Several allowed
|
|
109
|
+
* users can talk to the same bot, each in their own chat.
|
|
110
|
+
* @param user - Telegram `from` of the message.
|
|
111
|
+
* @returns a one-line label, or undefined without a sender.
|
|
112
|
+
*/
|
|
113
|
+
export function senderLabel(user) {
|
|
114
|
+
if (user === undefined || user === null || user.id === undefined) return undefined
|
|
115
|
+
const name = [user.first_name, user.last_name].filter((part) => typeof part === 'string' && part.trim().length > 0).join(' ').trim()
|
|
116
|
+
const details = []
|
|
117
|
+
if (typeof user.username === 'string' && user.username.length > 0) details.push(`@${user.username}`)
|
|
118
|
+
details.push(`user id ${user.id}`)
|
|
119
|
+
return name.length > 0 ? `[Telegram message from ${name} (${details.join(', ')})]` : `[Telegram message from ${details.join(', ')}]`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Describe the replied-to or externally replied message, if any.
|
|
124
|
+
* @returns the context block, or undefined.
|
|
125
|
+
*/
|
|
126
|
+
export function describeReply(message, options = {}) {
|
|
127
|
+
const limit = options.maxQuote ?? QUOTE_LIMIT
|
|
128
|
+
const quote = typeof message?.quote?.text === 'string' && message.quote.text.trim().length > 0 ? message.quote.text : undefined
|
|
129
|
+
const target = repliedMessage(message)
|
|
130
|
+
if (target !== undefined) {
|
|
131
|
+
const details = [`sent ${formatDate(target.date)}`]
|
|
132
|
+
const origin = originOf(target)
|
|
133
|
+
if (origin !== undefined) details.push(`forwarded from ${describeOrigin(origin, message, options)}${origin.date === undefined ? '' : `, originally sent ${formatDate(origin.date)}`}`)
|
|
134
|
+
const media = mediaNote(target, options.replyFile)
|
|
135
|
+
if (media !== undefined) details.push(media)
|
|
136
|
+
const text = quote ?? messageBody(target)
|
|
137
|
+
const label = quote === undefined ? '' : '; the user quoted only this part'
|
|
138
|
+
const head = `[Replying to a message from ${describeSender(target, message, options)} (${details.join('; ')})${label}]`
|
|
139
|
+
return text.trim().length === 0 ? head : `${head}\n${quoteLines(text, limit)}`
|
|
140
|
+
}
|
|
141
|
+
const external = message?.external_reply
|
|
142
|
+
if (external === undefined || external === null) {
|
|
143
|
+
if (message?.reply_to_story !== undefined) return '[Replying to a Telegram story]'
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
const details = []
|
|
147
|
+
if (external.origin !== undefined) {
|
|
148
|
+
details.push(`from ${describeOrigin(external.origin, message, options)}`)
|
|
149
|
+
if (external.origin.date !== undefined) details.push(`sent ${formatDate(external.origin.date)}`)
|
|
150
|
+
}
|
|
151
|
+
if (external.chat !== undefined && external.origin?.type !== 'channel') details.push(`in ${chatName(external.chat)}`)
|
|
152
|
+
const link = messageLink(external.chat, external.message_id)
|
|
153
|
+
if (link !== undefined) details.push(link)
|
|
154
|
+
const media = mediaNote(external, options.replyFile)
|
|
155
|
+
if (media !== undefined) details.push(media)
|
|
156
|
+
const head = `[Replying to a message from another chat${details.length === 0 ? '' : ` (${details.join('; ')})`}${quote === undefined ? '' : '; the user quoted this part'}]`
|
|
157
|
+
if (quote !== undefined) return `${head}\n${quoteLines(quote, limit)}`
|
|
158
|
+
return `${head}\n(The bot cannot see the text of messages in other chats unless the user quotes it.)`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Describe a forwarded message together with its content.
|
|
163
|
+
* @returns the context block, or undefined when the message is not forwarded.
|
|
164
|
+
*/
|
|
165
|
+
export function describeForward(message, body = messageBody(message).trim(), options = {}) {
|
|
166
|
+
const origin = originOf(message)
|
|
167
|
+
if (origin === undefined) return undefined
|
|
168
|
+
const details = []
|
|
169
|
+
if (origin.date !== undefined) details.push(`originally sent ${formatDate(origin.date)}`)
|
|
170
|
+
const link = origin.type === 'channel' ? messageLink(origin.chat, origin.message_id) : undefined
|
|
171
|
+
if (link !== undefined) details.push(link)
|
|
172
|
+
const media = mediaNote(message, options.ownFile === true ? 'attached' : undefined)
|
|
173
|
+
if (media !== undefined) details.push(media)
|
|
174
|
+
const head = `[Forwarded message from ${describeOrigin(origin, message, options)}${details.length === 0 ? '' : ` (${details.join('; ')})`}]`
|
|
175
|
+
return body.length === 0 ? head : `${head}\n${quoteLines(body, options.maxQuote ?? QUOTE_LIMIT)}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Describe a message the bot sent into a topic on someone's behalf (a script,
|
|
180
|
+
* a job, a plugin or another session): who sent it, when, where it was
|
|
181
|
+
* recorded, and its full text, which may span several Telegram messages.
|
|
182
|
+
* @param sent - entry from the sent-message log.
|
|
183
|
+
* @param options - currentSession: session now bound to the topic; limit: text limit.
|
|
184
|
+
* @returns the context block, or undefined.
|
|
185
|
+
*/
|
|
186
|
+
export function describeSent(sent, options = {}) {
|
|
187
|
+
if (sent === undefined || sent === null) return undefined
|
|
188
|
+
const details = []
|
|
189
|
+
const time = Date.parse(sent.sentAt ?? '')
|
|
190
|
+
details.push(Number.isFinite(time) ? `sent ${formatDate(time / 1000)}` : 'sent at an unknown time')
|
|
191
|
+
if (typeof sent.job === 'string' && sent.job.length > 0) details.push(`by job "${sent.job}"`)
|
|
192
|
+
if (typeof sent.fromSession === 'string' && sent.fromSession.length > 0) details.push(`from DSH session ${sent.fromSession}`)
|
|
193
|
+
else if (typeof sent.label === 'string' && sent.label.length > 0 && sent.job == null) details.push(`by ${sent.label}`)
|
|
194
|
+
if (Array.isArray(sent.messageIds) && sent.messageIds.length > 1) details.push(`${sent.messageIds.length} Telegram messages`)
|
|
195
|
+
if (sent.sticker !== undefined && sent.sticker !== null) details.push(`with sticker ${[sent.sticker.emoji, sent.sticker.setName === null || sent.sticker.setName === undefined ? undefined : `from set "${sent.sticker.setName}"`, `(${sent.sticker.id})`].filter(Boolean).join(' ')}`)
|
|
196
|
+
if (Array.isArray(sent.files) && sent.files.length > 0) details.push(`with ${sent.files.length === 1 ? 'file' : 'files'} ${sent.files.map((name) => `"${name}"`).join(', ')}`)
|
|
197
|
+
const bound = typeof sent.boundSession === 'string' ? sent.boundSession : undefined
|
|
198
|
+
if (bound !== undefined && sent.recorded === true && bound !== options.currentSession) details.push(`recorded in session ${bound}, not this one`)
|
|
199
|
+
else if (bound === undefined) details.push('the topic had no session then')
|
|
200
|
+
const head = `[The replied-to message was posted by the bot on behalf of another sender (${details.join('; ')}). Full text:]`
|
|
201
|
+
const body = typeof sent.text === 'string' && sent.text.trim().length > 0
|
|
202
|
+
? quoteLines(sent.text, options.limit ?? SENT_TEXT_LIMIT) + (sent.textCut > 0 ? `\n> […${sent.textCut} more characters not kept]` : '')
|
|
203
|
+
: '> (no text)'
|
|
204
|
+
const sessions = [sent.fromSession, bound !== options.currentSession && sent.recorded === true ? bound : undefined]
|
|
205
|
+
.filter((id, index, all) => typeof id === 'string' && id.length > 0 && all.indexOf(id) === index)
|
|
206
|
+
const hint = sessions.length === 0 ? '' : `\n(For more context, open session ${sessions.join(' or ')}.)`
|
|
207
|
+
return `${head}\n${body}${hint}`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Forward origin of a message, including the fields older Bot API versions used. */
|
|
211
|
+
function originOf(message) {
|
|
212
|
+
if (message?.forward_origin !== undefined && message.forward_origin !== null) return message.forward_origin
|
|
213
|
+
const date = message?.forward_date
|
|
214
|
+
if (message?.forward_from_chat !== undefined) {
|
|
215
|
+
return { type: message.forward_from_chat.type === 'channel' ? 'channel' : 'chat', chat: message.forward_from_chat, sender_chat: message.forward_from_chat, message_id: message.forward_from_message_id, author_signature: message.forward_signature, date }
|
|
216
|
+
}
|
|
217
|
+
if (message?.forward_from !== undefined) return { type: 'user', sender_user: message.forward_from, date }
|
|
218
|
+
if (typeof message?.forward_sender_name === 'string') return { type: 'hidden_user', sender_user_name: message.forward_sender_name, date }
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Who wrote a replied-to message, from the reader's point of view. */
|
|
223
|
+
function describeSender(target, message, options) {
|
|
224
|
+
const from = target.from
|
|
225
|
+
if (from !== undefined && options.botId !== undefined && from.id === options.botId) {
|
|
226
|
+
const text = messageBody(target)
|
|
227
|
+
const mirrored = MIRROR_LABELS.find(([label]) => text.startsWith(label))
|
|
228
|
+
return mirrored === undefined ? 'the assistant (this bot)' : mirrored[1]
|
|
229
|
+
}
|
|
230
|
+
if (target.sender_chat !== undefined) return chatName(target.sender_chat)
|
|
231
|
+
if (from !== undefined && message?.from !== undefined && from.id === message.from.id) return 'the user (their own earlier message)'
|
|
232
|
+
if (from !== undefined) return personName(from)
|
|
233
|
+
return 'an unknown sender'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Name the origin of a forwarded message. */
|
|
237
|
+
function describeOrigin(origin, message, options) {
|
|
238
|
+
const signature = typeof origin.author_signature === 'string' && origin.author_signature.length > 0 ? `, signed ${origin.author_signature}` : ''
|
|
239
|
+
if (origin.type === 'user') {
|
|
240
|
+
const user = origin.sender_user
|
|
241
|
+
if (user !== undefined && options.botId !== undefined && user.id === options.botId) return 'the assistant (this bot)'
|
|
242
|
+
if (user !== undefined && message?.from !== undefined && user.id === message.from.id) return 'the user themselves'
|
|
243
|
+
return user === undefined ? 'a user' : personName(user)
|
|
244
|
+
}
|
|
245
|
+
if (origin.type === 'hidden_user') return `${origin.sender_user_name ?? 'a user'} (account hidden)`
|
|
246
|
+
if (origin.type === 'channel') return `channel ${chatName(origin.chat)}${signature}`
|
|
247
|
+
if (origin.type === 'chat') return `${chatName(origin.sender_chat)}${signature}`
|
|
248
|
+
return 'an unknown source'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function personName(user) {
|
|
252
|
+
const name = [user.first_name, user.last_name].filter((part) => typeof part === 'string' && part.length > 0).join(' ')
|
|
253
|
+
const handle = typeof user.username === 'string' && user.username.length > 0 ? `@${user.username}` : ''
|
|
254
|
+
const label = name.length > 0 ? (handle.length > 0 ? `${name} (${handle})` : name) : (handle.length > 0 ? handle : 'a user')
|
|
255
|
+
return user.is_bot === true ? `${label}, a bot` : label
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function chatName(chat) {
|
|
259
|
+
if (chat === undefined || chat === null) return 'a chat'
|
|
260
|
+
const title = typeof chat.title === 'string' && chat.title.length > 0
|
|
261
|
+
? `"${chat.title}"`
|
|
262
|
+
: [chat.first_name, chat.last_name].filter(Boolean).join(' ')
|
|
263
|
+
const handle = typeof chat.username === 'string' && chat.username.length > 0 ? `@${chat.username}` : ''
|
|
264
|
+
if (title.length > 0) return handle.length > 0 ? `${title} (${handle})` : title
|
|
265
|
+
return handle.length > 0 ? handle : 'a chat'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Public link to a channel or supergroup message, when one exists. */
|
|
269
|
+
function messageLink(chat, messageId) {
|
|
270
|
+
if (chat === undefined || chat === null || !Number.isSafeInteger(messageId)) return undefined
|
|
271
|
+
if (typeof chat.username === 'string' && chat.username.length > 0) return `https://t.me/${chat.username}/${messageId}`
|
|
272
|
+
const id = String(chat.id ?? '')
|
|
273
|
+
if (id.startsWith('-100')) return `https://t.me/c/${id.slice(4)}/${messageId}`
|
|
274
|
+
return undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Note the media a message carries and whether it was attached. */
|
|
278
|
+
function mediaNote(message, fileOutcome) {
|
|
279
|
+
const kind = mediaKind(message)
|
|
280
|
+
if (kind === undefined) return undefined
|
|
281
|
+
if (downloadableFile(message) === undefined) return `contains ${kind}, not attached`
|
|
282
|
+
if (fileOutcome === 'attached') return `contains ${kind}, attached`
|
|
283
|
+
if (fileOutcome === 'too-large') return `contains ${kind}, too large to attach`
|
|
284
|
+
if (fileOutcome === 'failed') return `contains ${kind}, could not be downloaded`
|
|
285
|
+
return `contains ${kind}`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mediaKind(message) {
|
|
289
|
+
if (message === undefined || message === null) return undefined
|
|
290
|
+
if (Array.isArray(message.photo) && message.photo.length > 0) return 'a photo'
|
|
291
|
+
if (message.document !== undefined && message.animation === undefined) {
|
|
292
|
+
return typeof message.document.file_name === 'string' ? `a file "${message.document.file_name}"` : 'a file'
|
|
293
|
+
}
|
|
294
|
+
if (message.video !== undefined) return 'a video'
|
|
295
|
+
if (message.animation !== undefined) return 'a GIF'
|
|
296
|
+
if (message.audio !== undefined) {
|
|
297
|
+
const title = [message.audio.performer, message.audio.title].filter(Boolean).join(' - ')
|
|
298
|
+
return title.length > 0 ? `audio "${title}"` : 'audio'
|
|
299
|
+
}
|
|
300
|
+
if (message.voice !== undefined) return 'a voice message'
|
|
301
|
+
if (message.video_note !== undefined) return 'a video message'
|
|
302
|
+
if (message.sticker !== undefined) {
|
|
303
|
+
const emoji = typeof message.sticker.emoji === 'string' ? ` ${message.sticker.emoji}` : ''
|
|
304
|
+
const set = typeof message.sticker.set_name === 'string' ? ` from set "${message.sticker.set_name}"` : ''
|
|
305
|
+
return `a sticker${emoji}${set}`
|
|
306
|
+
}
|
|
307
|
+
if (message.poll !== undefined) return `a poll "${message.poll.question ?? ''}"`
|
|
308
|
+
if (message.venue !== undefined) return `a venue "${message.venue.title ?? ''}"`
|
|
309
|
+
if (message.location !== undefined) return `a location (${message.location.latitude}, ${message.location.longitude})`
|
|
310
|
+
if (message.contact !== undefined) return `a contact (${[message.contact.first_name, message.contact.last_name].filter(Boolean).join(' ')})`
|
|
311
|
+
if (message.story !== undefined) return 'a story'
|
|
312
|
+
if (message.paid_media !== undefined) return 'paid media'
|
|
313
|
+
if (message.dice !== undefined) return `a dice roll ${message.dice.emoji ?? ''}`.trim()
|
|
314
|
+
if (message.checklist !== undefined) return 'a checklist'
|
|
315
|
+
if (message.game !== undefined) return 'a game'
|
|
316
|
+
if (message.invoice !== undefined) return 'an invoice'
|
|
317
|
+
return undefined
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** UTC date for a Telegram unix timestamp. */
|
|
321
|
+
function formatDate(seconds) {
|
|
322
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return 'at an unknown time'
|
|
323
|
+
return `${new Date(seconds * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Quote text line by line, cut at a word boundary near the limit. */
|
|
327
|
+
function quoteLines(text, limit) {
|
|
328
|
+
let shown = text.trim()
|
|
329
|
+
let note = ''
|
|
330
|
+
if (shown.length > limit) {
|
|
331
|
+
let cut = shown.lastIndexOf(' ', limit)
|
|
332
|
+
const newline = shown.lastIndexOf('\n', limit)
|
|
333
|
+
if (newline > cut) cut = newline
|
|
334
|
+
if (cut < limit * 0.8) cut = limit
|
|
335
|
+
note = `\n> […truncated, ${shown.length - cut} more characters]`
|
|
336
|
+
shown = shown.slice(0, cut).trimEnd()
|
|
337
|
+
}
|
|
338
|
+
return shown.split('\n').map((line) => `> ${line}`).join('\n') + note
|
|
339
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model selection for Telegram topics. The session's live agent owns its
|
|
3
|
+
* model (`agent.model.current()`); the bot keeps no copy of it. The picker
|
|
4
|
+
* reads the same catalog as the Web model picker
|
|
5
|
+
* (`sessionController.modelCatalog()`), so every configured provider and
|
|
6
|
+
* model shows up with its Web display name.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
/** Rows of providers or models shown per picker page, sized for a phone. */
|
|
11
|
+
export const MODEL_PAGE_SIZE = 8
|
|
12
|
+
|
|
13
|
+
/** How long one catalog read is reused for labels and menus. */
|
|
14
|
+
const CATALOG_TTL_MS = 60_000
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Short stable key for a provider or a provider/model pair, for callback data
|
|
18
|
+
* (Telegram allows 64 bytes, and provider/model ids can be longer).
|
|
19
|
+
* @param provider - provider id.
|
|
20
|
+
* @param model - model id, or undefined for the provider itself.
|
|
21
|
+
* @returns 12 hex characters.
|
|
22
|
+
*/
|
|
23
|
+
export function catalogKey(provider, model) {
|
|
24
|
+
return createHash('sha256').update(`${provider}\n${model ?? ''}`).digest('hex').slice(0, 12)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Copy a selection, dropping an absent effort.
|
|
29
|
+
* @param selection - provider, model and optional effort.
|
|
30
|
+
* @returns a detached selection.
|
|
31
|
+
*/
|
|
32
|
+
function toSelection({ provider, model, reasoningEffort }) {
|
|
33
|
+
return { provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read a session's current model: the live agent's own choice, or, for a
|
|
38
|
+
* session that is not running, its recorded selection.
|
|
39
|
+
* @param ctx - Host context.
|
|
40
|
+
* @param sessionId - DSH session id.
|
|
41
|
+
* @returns the selection, or undefined when the session has none recorded.
|
|
42
|
+
*/
|
|
43
|
+
export async function sessionModelOf(ctx, sessionId) {
|
|
44
|
+
if (typeof sessionId !== 'string') return undefined
|
|
45
|
+
const agent = ctx.get('agents')?.get(sessionId)
|
|
46
|
+
if (typeof agent?.model?.current === 'function') {
|
|
47
|
+
const current = agent.model.current()
|
|
48
|
+
return current === undefined ? undefined : toSelection(current)
|
|
49
|
+
}
|
|
50
|
+
const query = ctx.get('sessionQuery')
|
|
51
|
+
if (typeof query?.observeSession !== 'function') return undefined
|
|
52
|
+
let observation
|
|
53
|
+
try {
|
|
54
|
+
observation = await query.observeSession(sessionId)
|
|
55
|
+
// The modelSelection projection's view: `next` is the pending choice or
|
|
56
|
+
// the route of the last request.
|
|
57
|
+
const view = observation?.projections?.values?.modelSelection
|
|
58
|
+
const recorded = view?.next ?? view?.pending ?? view?.lastUsed
|
|
59
|
+
return typeof recorded?.provider === 'string' && typeof recorded?.model === 'string' ? toSelection(recorded) : undefined
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined
|
|
62
|
+
} finally {
|
|
63
|
+
observation?.[Symbol.dispose]?.()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the deployment default model (the one the Web UI saves).
|
|
69
|
+
* @param ctx - Host context.
|
|
70
|
+
* @returns the selection, or undefined without a default-model service.
|
|
71
|
+
*/
|
|
72
|
+
export function defaultModelOf(ctx) {
|
|
73
|
+
try {
|
|
74
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection()
|
|
75
|
+
if (typeof selection?.provider === 'string' && typeof selection?.model === 'string') return toSelection(selection)
|
|
76
|
+
} catch {
|
|
77
|
+
// No default configured.
|
|
78
|
+
}
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
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.
|
|
90
|
+
*/
|
|
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
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether two selections name the same provider, model and effort.
|
|
99
|
+
* @param left - first selection.
|
|
100
|
+
* @param right - second selection.
|
|
101
|
+
* @returns true when they agree.
|
|
102
|
+
*/
|
|
103
|
+
export function sameSelection(left, right) {
|
|
104
|
+
return left?.provider === right?.provider && left?.model === right?.model && left?.reasoningEffort === right?.reasoningEffort
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Cached reader for the Web model catalog.
|
|
109
|
+
* @param ctx - Host context.
|
|
110
|
+
* @returns `load()` resolving the catalog, or undefined when it is unavailable.
|
|
111
|
+
*/
|
|
112
|
+
export function createModelCatalog(ctx) {
|
|
113
|
+
let cached
|
|
114
|
+
let loadedAt = 0
|
|
115
|
+
let pending
|
|
116
|
+
return {
|
|
117
|
+
/**
|
|
118
|
+
* @param fresh - skip the short-lived cache (used before applying a pick).
|
|
119
|
+
* @returns `{ groups, failures }` as the Web picker gets it, or undefined.
|
|
120
|
+
*/
|
|
121
|
+
async load(fresh = false) {
|
|
122
|
+
if (!fresh && cached !== undefined && Date.now() - loadedAt < CATALOG_TTL_MS) return cached
|
|
123
|
+
pending ??= (async () => {
|
|
124
|
+
const controller = ctx.get('sessionController')
|
|
125
|
+
if (typeof controller?.modelCatalog !== 'function') return undefined
|
|
126
|
+
const catalog = await controller.modelCatalog()
|
|
127
|
+
cached = { groups: Array.isArray(catalog?.groups) ? catalog.groups : [], failures: Array.isArray(catalog?.failures) ? catalog.failures : [] }
|
|
128
|
+
loadedAt = Date.now()
|
|
129
|
+
return cached
|
|
130
|
+
})().finally(() => { pending = undefined })
|
|
131
|
+
try {
|
|
132
|
+
return await pending
|
|
133
|
+
} catch {
|
|
134
|
+
return cached
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Find a catalog entry for a selection.
|
|
142
|
+
* @param catalog - loaded catalog.
|
|
143
|
+
* @param selection - provider and model.
|
|
144
|
+
* @returns `{ group, model }`, or undefined.
|
|
145
|
+
*/
|
|
146
|
+
export function findModel(catalog, selection) {
|
|
147
|
+
const group = catalog?.groups.find((entry) => entry.id === selection?.provider)
|
|
148
|
+
const model = group?.models.find((entry) => entry.id === selection?.model)
|
|
149
|
+
return model === undefined ? undefined : { group, model }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve a provider callback key.
|
|
154
|
+
* @param catalog - loaded catalog.
|
|
155
|
+
* @param key - `catalogKey(provider)`.
|
|
156
|
+
* @returns the provider group, or undefined.
|
|
157
|
+
*/
|
|
158
|
+
export function providerByKey(catalog, key) {
|
|
159
|
+
return catalog?.groups.find((group) => catalogKey(group.id) === key)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve a model callback key.
|
|
164
|
+
* @param catalog - loaded catalog.
|
|
165
|
+
* @param key - `catalogKey(provider, model)`.
|
|
166
|
+
* @returns `{ group, model }`, or undefined.
|
|
167
|
+
*/
|
|
168
|
+
export function modelByKey(catalog, key) {
|
|
169
|
+
for (const group of catalog?.groups ?? []) {
|
|
170
|
+
const model = group.models.find((entry) => catalogKey(group.id, entry.id) === key)
|
|
171
|
+
if (model !== undefined) return { group, model }
|
|
172
|
+
}
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Display name of a selection's model, as the Web picker shows it.
|
|
178
|
+
* @param catalog - loaded catalog, or undefined.
|
|
179
|
+
* @param selection - provider and model.
|
|
180
|
+
* @returns the name, or the raw model id.
|
|
181
|
+
*/
|
|
182
|
+
export function modelName(catalog, selection) {
|
|
183
|
+
if (selection === undefined) return '?'
|
|
184
|
+
return findModel(catalog, selection)?.model.name ?? selection.model
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Effort choices for one catalog model, the same list the Web picker builds:
|
|
189
|
+
* a provider-default entry when the model has no default effort, then its
|
|
190
|
+
* efforts. A model without reasoning has none.
|
|
191
|
+
* @param model - catalog model entry.
|
|
192
|
+
* @param defaultLabel - label for the provider default.
|
|
193
|
+
* @returns `{ id, name }` entries; `id` undefined means the provider default.
|
|
194
|
+
*/
|
|
195
|
+
export function effortChoices(model, defaultLabel) {
|
|
196
|
+
const reasoning = model?.reasoning
|
|
197
|
+
if (reasoning === undefined) return []
|
|
198
|
+
return [
|
|
199
|
+
...(reasoning.defaultEffort === undefined ? [{ id: undefined, name: defaultLabel }] : []),
|
|
200
|
+
...reasoning.efforts.map((effort) => ({ id: effort.id, name: effort.name })),
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Display name of a selection's effective effort.
|
|
206
|
+
* @param catalog - loaded catalog, or undefined.
|
|
207
|
+
* @param selection - current selection.
|
|
208
|
+
* @param defaultLabel - label for the provider default.
|
|
209
|
+
* @returns the name.
|
|
210
|
+
*/
|
|
211
|
+
export function effortName(catalog, selection, defaultLabel) {
|
|
212
|
+
const reasoning = findModel(catalog, selection)?.model.reasoning
|
|
213
|
+
const effective = selection?.reasoningEffort ?? reasoning?.defaultEffort
|
|
214
|
+
if (effective === undefined) return defaultLabel
|
|
215
|
+
return reasoning?.efforts.find((effort) => effort.id === effective)?.name ?? effective
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The selection a model pick makes, by the Web picker's rule: the same model
|
|
220
|
+
* keeps its effort, another model starts at its default effort.
|
|
221
|
+
* @param current - the session's current selection.
|
|
222
|
+
* @param provider - picked provider id.
|
|
223
|
+
* @param model - picked catalog model entry.
|
|
224
|
+
* @returns the selection to apply.
|
|
225
|
+
*/
|
|
226
|
+
export function pickedSelection(current, provider, model) {
|
|
227
|
+
const same = current?.provider === provider && current?.model === model.id
|
|
228
|
+
const reasoningEffort = same ? current.reasoningEffort ?? model.reasoning?.defaultEffort : model.reasoning?.defaultEffort
|
|
229
|
+
return toSelection({ provider, model: model.id, reasoningEffort })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Cut one page out of a list.
|
|
234
|
+
* @param items - all entries.
|
|
235
|
+
* @param page - requested page, clamped to the valid range.
|
|
236
|
+
* @returns the page's items, its index and the page count.
|
|
237
|
+
*/
|
|
238
|
+
export function pageOf(items, page) {
|
|
239
|
+
const pages = Math.max(1, Math.ceil(items.length / MODEL_PAGE_SIZE))
|
|
240
|
+
const index = Math.min(Math.max(0, Number.isSafeInteger(page) ? page : 0), pages - 1)
|
|
241
|
+
return { items: items.slice(index * MODEL_PAGE_SIZE, (index + 1) * MODEL_PAGE_SIZE), page: index, pages }
|
|
242
|
+
}
|