@soffits/dsh-telegram-bot 0.4.0 → 0.4.2
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 +1 -1
- package/README.md +1 -1
- package/client.js +335 -1
- package/package.json +3 -1
- package/src/app.js +156 -38
- package/src/client.js +335 -1
- package/src/constants.js +6 -0
- package/src/models.js +11 -26
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
|
|
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
|
|
@@ -70,6 +70,19 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
loadingSettings: 'Loading settings…',
|
|
71
71
|
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
|
|
72
72
|
readOnly: 'This deployment stores settings read-only.',
|
|
73
|
+
postTitle: 'Sent to Telegram',
|
|
74
|
+
postTopic: 'topic',
|
|
75
|
+
postJob: 'job',
|
|
76
|
+
postSession: 'session',
|
|
77
|
+
postOtherSession: 'another session',
|
|
78
|
+
postSticker: 'Sticker',
|
|
79
|
+
postFile: 'file',
|
|
80
|
+
postCopy: 'Copy',
|
|
81
|
+
postCopied: 'Copied',
|
|
82
|
+
postCode: 'Code',
|
|
83
|
+
postWrap: 'Wrap',
|
|
84
|
+
postUnwrap: 'Unwrap',
|
|
85
|
+
postFootnotes: 'Footnotes',
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
const ru = {
|
|
@@ -127,6 +140,19 @@ window.__ModuleLoader__.load({
|
|
|
127
140
|
loadingSettings: 'Загрузка настроек…',
|
|
128
141
|
unavailable: 'Плагин не загружен, поэтому настройки сейчас недоступны.',
|
|
129
142
|
readOnly: 'Этот deployment хранит настройки только для чтения.',
|
|
143
|
+
postTitle: 'Отправлено в Telegram',
|
|
144
|
+
postTopic: 'тема',
|
|
145
|
+
postJob: 'задача',
|
|
146
|
+
postSession: 'сессия',
|
|
147
|
+
postOtherSession: 'другая сессия',
|
|
148
|
+
postSticker: 'Стикер',
|
|
149
|
+
postFile: 'файл',
|
|
150
|
+
postCopy: 'Копировать',
|
|
151
|
+
postCopied: 'Скопировано',
|
|
152
|
+
postCode: 'Код',
|
|
153
|
+
postWrap: 'Переносить',
|
|
154
|
+
postUnwrap: 'Не переносить',
|
|
155
|
+
postFootnotes: 'Сноски',
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
const CSS = `
|
|
@@ -730,6 +756,293 @@ window.__ModuleLoader__.load({
|
|
|
730
756
|
)
|
|
731
757
|
}
|
|
732
758
|
|
|
759
|
+
// ---- Telegram posts in the Chat transcript ----
|
|
760
|
+
//
|
|
761
|
+
// A send into a topic leaves a user-role copy in the topic's session, so
|
|
762
|
+
// the model knows what was said there. DSH's Chat would draw an ordinary
|
|
763
|
+
// user message as the user's own bubble; these Definitions claim the copy
|
|
764
|
+
// and draw it as what it is: something sent to Telegram, on the
|
|
765
|
+
// assistant's side, pictures included. The recorded message is unchanged.
|
|
766
|
+
|
|
767
|
+
/** Chat node kind, conversation Definition kind and Host source kind of a post. */
|
|
768
|
+
const POST_KIND = 'telegram-post'
|
|
769
|
+
/** Request-id prefix of posts recorded before they had their own source kind. */
|
|
770
|
+
const LEGACY_POST_RPC = 'telegram-post:'
|
|
771
|
+
/** First text line of every post: what the model reads as its sender. */
|
|
772
|
+
const HEADER_LINE = /^\[(Sent to the Telegram [^\n]*)\]$/u
|
|
773
|
+
/** Line naming a sent sticker; its still picture follows as an image. */
|
|
774
|
+
const STICKER_LINE = /^\[Sticker ([^\n]*)\]$/u
|
|
775
|
+
/** The header's own words, for posts that carry no structured source. */
|
|
776
|
+
const HEADER_PARTS = /^Sent to the Telegram topic (?:"(.*)"|(\d+))(?: by (.*))?$/u
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Whether a durable message source marks a topic send's copy.
|
|
780
|
+
* @param source - `user/message` source.
|
|
781
|
+
* @returns true for posts, old and new.
|
|
782
|
+
*/
|
|
783
|
+
function isPostSource(source) {
|
|
784
|
+
if (typeof source !== 'object' || source === null) return false
|
|
785
|
+
if (source.kind === POST_KIND) return true
|
|
786
|
+
return source.kind === 'user' && typeof source.rpcId === 'string' && source.rpcId.startsWith(LEGACY_POST_RPC)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Claim a post's `user/message` on the Chat target. Only append-origin
|
|
791
|
+
* copies are claimed: a replacement copy (compaction) belongs to the
|
|
792
|
+
* system-prompt Definition and is never drawn.
|
|
793
|
+
* @param event - Client history event.
|
|
794
|
+
* @returns the match, or null when the event is not a post.
|
|
795
|
+
*/
|
|
796
|
+
function matchPost(event) {
|
|
797
|
+
if (event?.type !== 'user/message' || event.surfaceOp !== 'append') return null
|
|
798
|
+
const data = event.data
|
|
799
|
+
if (data === null || typeof data !== 'object' || !isPostSource(data.source)) return null
|
|
800
|
+
return { id: String(data.id), role: 'start', exclusive: true }
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Who sent a post and where, from its source or, for old records, from
|
|
805
|
+
* the header line the model reads.
|
|
806
|
+
* @param source - the post's source.
|
|
807
|
+
* @param header - header line without brackets, when present.
|
|
808
|
+
* @returns `{ topic, threadId, job, label, fromSession, self }`; unknown fields are null.
|
|
809
|
+
*/
|
|
810
|
+
function postMeta(source, header) {
|
|
811
|
+
if (source?.kind === POST_KIND) {
|
|
812
|
+
return {
|
|
813
|
+
topic: typeof source.topic === 'string' ? source.topic : null,
|
|
814
|
+
threadId: typeof source.threadId === 'number' ? source.threadId : null,
|
|
815
|
+
job: typeof source.job === 'string' ? source.job : null,
|
|
816
|
+
label: typeof source.label === 'string' ? source.label : null,
|
|
817
|
+
fromSession: typeof source.fromSession === 'string' ? source.fromSession : null,
|
|
818
|
+
self: source.self === true,
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const meta = { topic: null, threadId: null, job: null, label: null, fromSession: null, self: false }
|
|
822
|
+
const parts = header === null ? null : HEADER_PARTS.exec(header)
|
|
823
|
+
if (parts === null) return meta
|
|
824
|
+
if (parts[1] !== undefined) meta.topic = parts[1]
|
|
825
|
+
else meta.threadId = Number(parts[2])
|
|
826
|
+
const by = parts[3]
|
|
827
|
+
if (by === undefined) return meta
|
|
828
|
+
if (by === 'this session') {
|
|
829
|
+
meta.self = true
|
|
830
|
+
return meta
|
|
831
|
+
}
|
|
832
|
+
const job = /^job "(.*?)"(?: from session (\S+))?$/u.exec(by)
|
|
833
|
+
const session = /^session (\S+)$/u.exec(by)
|
|
834
|
+
if (job !== null) {
|
|
835
|
+
meta.job = job[1]
|
|
836
|
+
meta.fromSession = job[2] ?? null
|
|
837
|
+
} else if (session !== null) {
|
|
838
|
+
meta.fromSession = session[1]
|
|
839
|
+
} else {
|
|
840
|
+
meta.label = by
|
|
841
|
+
}
|
|
842
|
+
return meta
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Split a post's recorded content into what the transcript shows.
|
|
847
|
+
* @param content - the message's content blocks.
|
|
848
|
+
* @param source - the message's source.
|
|
849
|
+
* @returns `{ meta, text, stickers, images, files }`: the body without
|
|
850
|
+
* its header and sticker lines, sticker descriptions, image sources
|
|
851
|
+
* for the gallery and file attachments.
|
|
852
|
+
*/
|
|
853
|
+
function readPost(content, source) {
|
|
854
|
+
const blocks = Array.isArray(content) ? content : []
|
|
855
|
+
const lines = blocks
|
|
856
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
857
|
+
.map((block) => block.text)
|
|
858
|
+
.join('\n')
|
|
859
|
+
.split('\n')
|
|
860
|
+
const first = HEADER_LINE.exec(lines[0] ?? '')
|
|
861
|
+
if (first !== null) lines.shift()
|
|
862
|
+
const stickers = []
|
|
863
|
+
const body = []
|
|
864
|
+
for (const line of lines) {
|
|
865
|
+
const sticker = STICKER_LINE.exec(line)
|
|
866
|
+
if (sticker !== null) stickers.push(sticker[1])
|
|
867
|
+
else body.push(line)
|
|
868
|
+
}
|
|
869
|
+
const images = []
|
|
870
|
+
const files = []
|
|
871
|
+
for (const block of blocks) {
|
|
872
|
+
if (block?.attachment === undefined || block.attachment === null) continue
|
|
873
|
+
if (block.type === 'image') {
|
|
874
|
+
const name = block.attachment.name
|
|
875
|
+
images.push(typeof name === 'string' && name.length > 0 ? { attachment: block.attachment, label: name } : { attachment: block.attachment })
|
|
876
|
+
} else if (block.type === 'file') {
|
|
877
|
+
files.push(block.attachment)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
meta: postMeta(source, first === null ? null : first[1]),
|
|
882
|
+
text: body.join('\n').trim(),
|
|
883
|
+
stickers,
|
|
884
|
+
images,
|
|
885
|
+
files,
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Chat Definition for topic sends. The row stays in its Turn outside the
|
|
891
|
+
* folded work rows (`turn`): it is something sent, not a step of work,
|
|
892
|
+
* and it neither opens nor joins a Turn's input.
|
|
893
|
+
*/
|
|
894
|
+
const postDefinition = {
|
|
895
|
+
kind: POST_KIND,
|
|
896
|
+
target: 'chat',
|
|
897
|
+
match: { 'user/message': matchPost },
|
|
898
|
+
start: (_context, match) => {
|
|
899
|
+
const event = match.event
|
|
900
|
+
return {
|
|
901
|
+
seq: event.seq,
|
|
902
|
+
time: event.time,
|
|
903
|
+
...readPost(event.data.content, event.data.source),
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
update: (context) => context.state,
|
|
907
|
+
buildViewNode: (context) => {
|
|
908
|
+
if (context.state === undefined) return null
|
|
909
|
+
const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
|
|
910
|
+
return {
|
|
911
|
+
key: context.key,
|
|
912
|
+
kind: POST_KIND,
|
|
913
|
+
id: context.id,
|
|
914
|
+
target: 'chat',
|
|
915
|
+
anchorSeq: context.state.seq,
|
|
916
|
+
location,
|
|
917
|
+
visibility: 'visible',
|
|
918
|
+
groupTree: location.kind === 'turn' || location.kind === 'step' ? 'turn' : undefined,
|
|
919
|
+
data: context.state,
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Where and from whom, as short header pieces.
|
|
926
|
+
* @param meta - see postMeta.
|
|
927
|
+
* @param t - locale reader.
|
|
928
|
+
* @returns `{ where, who, whoTitle }`; `who` is null for the session's
|
|
929
|
+
* own sends, which already sit on its side of the transcript.
|
|
930
|
+
*/
|
|
931
|
+
function postLabels(meta, t) {
|
|
932
|
+
const where = meta.topic !== null ? meta.topic : meta.threadId !== null ? `${t('postTopic')} ${meta.threadId}` : null
|
|
933
|
+
let who = null
|
|
934
|
+
let whoTitle
|
|
935
|
+
if (meta.job !== null) {
|
|
936
|
+
who = `${t('postJob')} ${meta.job}`
|
|
937
|
+
if (meta.fromSession !== null) whoTitle = `${t('postSession')} ${meta.fromSession}`
|
|
938
|
+
} else if (!meta.self && meta.fromSession !== null) {
|
|
939
|
+
who = t('postOtherSession')
|
|
940
|
+
whoTitle = `${t('postSession')} ${meta.fromSession}`
|
|
941
|
+
} else if (!meta.self && meta.label !== null) {
|
|
942
|
+
who = meta.label
|
|
943
|
+
}
|
|
944
|
+
return { where, who, whoTitle }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Clock and full date of the post, in the browser's locale.
|
|
949
|
+
* @param time - event time (epoch milliseconds or ISO text).
|
|
950
|
+
* @returns `{ clock, iso, full }`, or null for a missing time.
|
|
951
|
+
*/
|
|
952
|
+
function postTime(time) {
|
|
953
|
+
if (time === undefined || time === null) return null
|
|
954
|
+
const date = new Date(time)
|
|
955
|
+
if (Number.isNaN(date.getTime())) return null
|
|
956
|
+
return {
|
|
957
|
+
clock: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
958
|
+
iso: date.toISOString(),
|
|
959
|
+
full: date.toLocaleString(),
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Transcript styles; theme tokens keep it in step with light and dark. */
|
|
964
|
+
const POST_CSS = `
|
|
965
|
+
.dtb-post{display:flex;flex-direction:column;gap:6px;min-width:0;padding:2px 0 2px 12px;border-left:2px solid var(--dsw-alias-border-l2,rgba(127,127,127,.35))}
|
|
966
|
+
.dtb-postHead{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px))}
|
|
967
|
+
.dtb-postIcon{display:inline-flex;flex:none}
|
|
968
|
+
.dtb-postTitle{flex:none}
|
|
969
|
+
.dtb-postSep{flex:none;width:2px;height:2px;border-radius:1px;background:var(--dsw-alias-label-caption,currentColor)}
|
|
970
|
+
.dtb-postMeta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
971
|
+
.dtb-postTime{flex:none;margin-left:auto;font:var(--dsw-font-xxs-12,12px/16px inherit)}
|
|
972
|
+
.dtb-postBody{min-width:0;color:var(--dsw-alias-label-primary)}
|
|
973
|
+
.dtb-postPlain{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
974
|
+
.dtb-postCaption{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}
|
|
975
|
+
.dtb-postFiles{display:flex;flex-wrap:wrap;gap:6px}
|
|
976
|
+
.dtb-postFile{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.35));border-radius:var(--dsw-radius-md,8px);padding:4px 10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
|
|
977
|
+
`
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Markdown control labels from this plugin's dictionary.
|
|
981
|
+
* @param t - locale reader.
|
|
982
|
+
*/
|
|
983
|
+
function postMarkdownLabels(t) {
|
|
984
|
+
return {
|
|
985
|
+
code: {
|
|
986
|
+
copyLabel: t('postCopy'),
|
|
987
|
+
copiedLabel: t('postCopied'),
|
|
988
|
+
toolbarLabels: { codeLabel: t('postCode'), wrapLabel: t('postWrap'), unwrapLabel: t('postUnwrap') },
|
|
989
|
+
},
|
|
990
|
+
footnotes: t('postFootnotes'),
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Keyed Chat renderer of one post.
|
|
996
|
+
* @param props - `node`, `loadImage`, `renderFactorySlot` and `t` from the Chat node slot.
|
|
997
|
+
*/
|
|
998
|
+
function TelegramPostView(props) {
|
|
999
|
+
const { node, loadImage, renderFactorySlot, t } = props
|
|
1000
|
+
const data = node.data
|
|
1001
|
+
const labels = React.useMemo(() => postMarkdownLabels(t), [t])
|
|
1002
|
+
const { where, who, whoTitle } = postLabels(data.meta, t)
|
|
1003
|
+
const time = postTime(data.time)
|
|
1004
|
+
const Icon = primitives.IconPaperPlaneOutlineRegular
|
|
1005
|
+
const Markdown = primitives.MarkdownText
|
|
1006
|
+
const head = [
|
|
1007
|
+
h('span', { key: 'icon', className: 'dtb-postIcon', 'aria-hidden': true }, Icon === undefined ? null : h(Icon, { size: 14 })),
|
|
1008
|
+
h('span', { key: 'title', className: 'dtb-postTitle' }, t('postTitle')),
|
|
1009
|
+
]
|
|
1010
|
+
if (where !== null) {
|
|
1011
|
+
head.push(h('span', { key: 'sep-where', className: 'dtb-postSep', 'aria-hidden': true }))
|
|
1012
|
+
head.push(h('span', { key: 'where', className: 'dtb-postMeta' }, where))
|
|
1013
|
+
}
|
|
1014
|
+
if (who !== null) {
|
|
1015
|
+
head.push(h('span', { key: 'sep-who', className: 'dtb-postSep', 'aria-hidden': true }))
|
|
1016
|
+
head.push(h('span', { key: 'who', className: 'dtb-postMeta', title: whoTitle }, who))
|
|
1017
|
+
}
|
|
1018
|
+
if (time !== null) head.push(h('time', { key: 'time', className: 'dtb-postTime', dateTime: time.iso, title: time.full }, time.clock))
|
|
1019
|
+
return h(
|
|
1020
|
+
'div',
|
|
1021
|
+
{ className: 'dtb-post', 'data-telegram-post': true },
|
|
1022
|
+
h('div', { className: 'dtb-postHead' }, ...head),
|
|
1023
|
+
data.text.length === 0
|
|
1024
|
+
? null
|
|
1025
|
+
: h(
|
|
1026
|
+
'div',
|
|
1027
|
+
{ className: 'dtb-postBody' },
|
|
1028
|
+
Markdown === undefined ? h('div', { className: 'dtb-postPlain' }, data.text) : h(Markdown, { text: data.text, labels }),
|
|
1029
|
+
),
|
|
1030
|
+
data.images.length === 0 || typeof renderFactorySlot !== 'function'
|
|
1031
|
+
? null
|
|
1032
|
+
: renderFactorySlot('conversation.images.content', { images: data.images, loadImage, align: 'start' }),
|
|
1033
|
+
...data.stickers.map((sticker, index) => h('div', { key: `sticker-${index}`, className: 'dtb-postCaption' }, `${t('postSticker')} ${sticker}`)),
|
|
1034
|
+
data.files.length === 0
|
|
1035
|
+
? null
|
|
1036
|
+
: h(
|
|
1037
|
+
'div',
|
|
1038
|
+
{ className: 'dtb-postFiles' },
|
|
1039
|
+
...data.files.map((file, index) =>
|
|
1040
|
+
h('span', { key: `file-${index}`, className: 'dtb-postFile', title: file.name }, `📎 ${file.name ?? t('postFile')}`),
|
|
1041
|
+
),
|
|
1042
|
+
),
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
733
1046
|
/** Required client services. */
|
|
734
1047
|
const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'configForms']
|
|
735
1048
|
|
|
@@ -799,11 +1112,32 @@ window.__ModuleLoader__.load({
|
|
|
799
1112
|
),
|
|
800
1113
|
'dsh-telegram-bot: bundle settings',
|
|
801
1114
|
)
|
|
1115
|
+
|
|
1116
|
+
if (typeof document !== 'undefined') {
|
|
1117
|
+
ctx.effect(() => {
|
|
1118
|
+
const style = document.createElement('style')
|
|
1119
|
+
style.dataset.plugin = '@soffits/dsh-telegram-bot'
|
|
1120
|
+
style.textContent = POST_CSS
|
|
1121
|
+
document.head.appendChild(style)
|
|
1122
|
+
return () => style.remove()
|
|
1123
|
+
}, 'dsh-telegram-bot: post styles')
|
|
1124
|
+
}
|
|
1125
|
+
// Chat may be absent (a client without the transcript); settings still load.
|
|
1126
|
+
ctx.inject(['uiConversation'], (scope) => {
|
|
1127
|
+
scope.uiConversation.events.register(postDefinition)
|
|
1128
|
+
})
|
|
1129
|
+
ctx.slots.inject('conversation.chat.node', () =>
|
|
1130
|
+
ctx.slots.register({ name: 'conversation.chat.node', key: POST_KIND, locale: NS }, TelegramPostView),
|
|
1131
|
+
)
|
|
802
1132
|
}
|
|
803
1133
|
|
|
804
1134
|
exports.NS = NS
|
|
805
1135
|
exports.apply = apply
|
|
806
1136
|
exports.inject = inject
|
|
1137
|
+
exports.postDefinition = postDefinition
|
|
1138
|
+
exports.readPost = readPost
|
|
1139
|
+
exports.postLabels = postLabels
|
|
1140
|
+
exports.TelegramPostView = TelegramPostView
|
|
807
1141
|
return module.exports
|
|
808
1142
|
},
|
|
809
1143
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soffits/dsh-telegram-bot",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Telegram sessions, topics, model controls and approvals for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
"immediately": true,
|
|
37
37
|
"inject": [
|
|
38
38
|
"@deepseek-ai/dsh-client-locale",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-chat",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
39
41
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
40
42
|
"@deepseek-ai/dsh-client-ui-plugin-manager"
|
|
41
43
|
]
|
package/src/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
FORWARD_BATCH_MS,
|
|
11
11
|
MAX_TELEGRAM_TEXT,
|
|
12
12
|
POLL_TIMEOUT_SECONDS,
|
|
13
|
+
POST_SOURCE_KIND,
|
|
13
14
|
RESTART_DEBOUNCE_MS,
|
|
14
15
|
RETRY_DELAY_MS,
|
|
15
16
|
SILENT_REPLY,
|
|
@@ -45,7 +46,7 @@ import { runProgram, spokenMediaOf, transcribe, transcriptNote } from './voice.j
|
|
|
45
46
|
import { createStickerLibrary, describeSticker, STICKER_PREVIEW_DIR } from './stickers.js'
|
|
46
47
|
import { composePrompt, DOWNLOAD_LIMIT_BYTES, downloadableFile, isForwarded, messageBody, repliedMessage, replyReference, senderLabel } from './message-context.js'
|
|
47
48
|
import { createSentLog } from './sent-log.js'
|
|
48
|
-
import { catalogKey, createModelCatalog, defaultModelOf, effortChoices,
|
|
49
|
+
import { catalogKey, createModelCatalog, defaultModelOf, effortChoices, effortName, findModel, isGoneModel, modelByKey, modelName, pageOf, pickedSelection, providerByKey, sameSelection, sessionModelOf } from './models.js'
|
|
49
50
|
import {
|
|
50
51
|
allowedChatsOf,
|
|
51
52
|
allowedUsersOf,
|
|
@@ -1060,6 +1061,7 @@ export function apply(ctx, config) {
|
|
|
1060
1061
|
if (data.length >= 12 && data.toString('latin1', 0, 4) === 'RIFF' && data.toString('latin1', 8, 12) === 'WEBP') return { mediaType: 'image/webp', ext: '.webp' }
|
|
1061
1062
|
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return { mediaType: 'image/jpeg', ext: '.jpg' }
|
|
1062
1063
|
if (data.length >= 8 && data.toString('latin1', 1, 4) === 'PNG') return { mediaType: 'image/png', ext: '.png' }
|
|
1064
|
+
if (data.length >= 6 && data.toString('latin1', 0, 4) === 'GIF8') return { mediaType: 'image/gif', ext: '.gif' }
|
|
1063
1065
|
return undefined
|
|
1064
1066
|
}
|
|
1065
1067
|
|
|
@@ -1966,18 +1968,43 @@ export function apply(ctx, config) {
|
|
|
1966
1968
|
* @returns the live agent.
|
|
1967
1969
|
*/
|
|
1968
1970
|
/**
|
|
1969
|
-
* Move a
|
|
1970
|
-
*
|
|
1971
|
+
* Move a topic's session off a model the catalog no longer offers (a
|
|
1972
|
+
* removed provider or a dropped model: every turn would fail before any
|
|
1973
|
+
* request, so no fallback can help). The session goes to the current
|
|
1974
|
+
* default through the Web picker's `selectModel`, a stale per-topic pick is
|
|
1975
|
+
* dropped, and the topic gets one line about it. A model that is merely
|
|
1976
|
+
* failing now, or no catalog, changes nothing.
|
|
1977
|
+
* @param chat - chat bridge state.
|
|
1971
1978
|
* @param agent - the session's live agent.
|
|
1972
1979
|
*/
|
|
1973
|
-
async function healRoute(agent) {
|
|
1980
|
+
async function healRoute(chat, agent) {
|
|
1974
1981
|
try {
|
|
1975
|
-
const
|
|
1976
|
-
|
|
1977
|
-
|
|
1982
|
+
const catalog = await modelCatalog.load()
|
|
1983
|
+
const pickKey = `${chat.chatId}:${chat.threadId}`
|
|
1984
|
+
if (isGoneModel(catalog, state.pickedModels?.[pickKey])) {
|
|
1985
|
+
delete state.pickedModels[pickKey]
|
|
1986
|
+
void persistState()
|
|
1987
|
+
}
|
|
1988
|
+
const current = agent.model?.current?.()
|
|
1989
|
+
if (!isGoneModel(catalog, current)) return
|
|
1990
|
+
const fallback = defaultModelOf(ctx)
|
|
1991
|
+
if (fallback === undefined || isGoneModel(catalog, fallback)) return
|
|
1992
|
+
const controller = ctx.get('sessionController')
|
|
1993
|
+
if (typeof controller?.selectModel === 'function') {
|
|
1994
|
+
await controller.selectModel({ sessionId: agent.session?.id ?? chat.sessionId, ...fallback })
|
|
1995
|
+
} else {
|
|
1996
|
+
agent.model.select(fallback)
|
|
1978
1997
|
}
|
|
1998
|
+
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}`)
|
|
1999
|
+
const name = (selection) => findModel(catalog, selection)?.model.name ?? `${selection.provider}/${selection.model}`
|
|
2000
|
+
await send(
|
|
2001
|
+
chat.chatId,
|
|
2002
|
+
`🤖 ${tr(`${escapeHtml(`${current.provider}/${current.model}`)} is gone; this topic now uses ${escapeHtml(name(fallback))}.`, `${escapeHtml(`${current.provider}/${current.model}`)} больше недоступна; топик теперь использует ${escapeHtml(name(fallback))}.`)}`,
|
|
2003
|
+
bridgeAbort.signal,
|
|
2004
|
+
chat.threadId,
|
|
2005
|
+
)
|
|
1979
2006
|
} catch (error) {
|
|
1980
|
-
warn(`could not check the model
|
|
2007
|
+
warn(`could not check the model of ${agent.session?.id ?? agent.id}: ${messageOf(error)}`)
|
|
1981
2008
|
}
|
|
1982
2009
|
}
|
|
1983
2010
|
|
|
@@ -1996,7 +2023,7 @@ export function apply(ctx, config) {
|
|
|
1996
2023
|
if (typeof live.session?.header?.cwd === 'string') chat.cwd = live.session.header.cwd
|
|
1997
2024
|
attachAgent(live, chat)
|
|
1998
2025
|
await syncPermission(chat, live)
|
|
1999
|
-
await healRoute(live)
|
|
2026
|
+
await healRoute(chat, live)
|
|
2000
2027
|
return live
|
|
2001
2028
|
}
|
|
2002
2029
|
try {
|
|
@@ -2006,7 +2033,7 @@ export function apply(ctx, config) {
|
|
|
2006
2033
|
if (typeof agent?.session?.header?.cwd === 'string') chat.cwd = agent.session.header.cwd
|
|
2007
2034
|
attachAgent(agent, chat)
|
|
2008
2035
|
await syncPermission(chat, agent)
|
|
2009
|
-
await healRoute(agent)
|
|
2036
|
+
await healRoute(chat, agent)
|
|
2010
2037
|
info(`resumed Telegram session ${chat.sessionId}`)
|
|
2011
2038
|
return agent
|
|
2012
2039
|
} catch (error) {
|
|
@@ -3336,18 +3363,31 @@ export function apply(ctx, config) {
|
|
|
3336
3363
|
* @param summary - one-line label the Web UI shows.
|
|
3337
3364
|
*/
|
|
3338
3365
|
function recordNote(agent, text, summary) {
|
|
3339
|
-
|
|
3366
|
+
recordMessage(agent, {
|
|
3340
3367
|
id: randomUUID(),
|
|
3341
3368
|
role: 'user',
|
|
3342
3369
|
content: [{ type: 'text', text }],
|
|
3343
3370
|
source: { kind: 'telegram-topic', form: 'notice', summary: summary.slice(0, 120) },
|
|
3344
|
-
}
|
|
3371
|
+
})
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
/**
|
|
3375
|
+
* Put a message into a session without starting a turn: appended now to an
|
|
3376
|
+
* idle session whose surface starts with its system prompt, otherwise queued
|
|
3377
|
+
* for the next step (see recordNote for why). Either way the Web UI shows
|
|
3378
|
+
* it and the model reads it at its next step.
|
|
3379
|
+
* @param agent - the session's live agent.
|
|
3380
|
+
* @param message - a user-role message (`{ id, role, content, source }`).
|
|
3381
|
+
*/
|
|
3382
|
+
function recordMessage(agent, message) {
|
|
3345
3383
|
if (agent.status === 'idle' && typeof agent.session?.append === 'function' && hasSystemHead(agent.session)) {
|
|
3346
3384
|
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
|
3347
3385
|
} else if (typeof agent.inject === 'function') {
|
|
3348
3386
|
agent.inject(message)
|
|
3387
|
+
} else if (typeof agent.send === 'function') {
|
|
3388
|
+
agent.send(message, 'next-step', false)
|
|
3349
3389
|
} else {
|
|
3350
|
-
throw new Error('The session cannot record
|
|
3390
|
+
throw new Error('The session cannot record messages.')
|
|
3351
3391
|
}
|
|
3352
3392
|
}
|
|
3353
3393
|
|
|
@@ -3566,6 +3606,59 @@ export function apply(ctx, config) {
|
|
|
3566
3606
|
return { path, name: basename(path), data }
|
|
3567
3607
|
}
|
|
3568
3608
|
|
|
3609
|
+
/**
|
|
3610
|
+
* The content blocks that show sent files in a session: pictures as images
|
|
3611
|
+
* (the Web UI draws them inline), everything else as file attachments. A
|
|
3612
|
+
* picture DSH refuses as an image is kept as a file; a file that cannot be
|
|
3613
|
+
* stored at all becomes a line naming it, so the rest is still recorded.
|
|
3614
|
+
* @param files - `{ name, data }` as read for upload.
|
|
3615
|
+
* @returns content blocks, in file order.
|
|
3616
|
+
*/
|
|
3617
|
+
async function recordedFiles(files) {
|
|
3618
|
+
const store = ctx.get('attachments')
|
|
3619
|
+
const blocks = []
|
|
3620
|
+
for (const file of files) {
|
|
3621
|
+
const image = sniffImage(file.data)
|
|
3622
|
+
try {
|
|
3623
|
+
if (store === undefined) throw new Error('no attachment store')
|
|
3624
|
+
if (image !== undefined) {
|
|
3625
|
+
try {
|
|
3626
|
+
blocks.push({ type: 'image', attachment: await store.saveImage({ data: file.data, mediaType: image.mediaType, name: file.name }) })
|
|
3627
|
+
continue
|
|
3628
|
+
} catch (error) {
|
|
3629
|
+
warn(`${file.name} kept as a file, not an image: ${messageOf(error)}`)
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
blocks.push({ type: 'file', attachment: await store.saveFile({ data: file.data, name: file.name }) })
|
|
3633
|
+
} catch (error) {
|
|
3634
|
+
warn(`${file.name} not stored in the session: ${messageOf(error)}`)
|
|
3635
|
+
blocks.push({ type: 'text', text: `[File ${file.name} was sent but could not be stored here: ${messageOf(error)}]` })
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
return blocks
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
/**
|
|
3642
|
+
* The sticker's still picture as an image block, for the session record.
|
|
3643
|
+
* @param sticker - sticker library entry.
|
|
3644
|
+
* @param signal - request lifetime.
|
|
3645
|
+
* @returns one image block, or none when the sticker has no still picture
|
|
3646
|
+
* or it cannot be stored (the text line still names the sticker).
|
|
3647
|
+
*/
|
|
3648
|
+
async function recordedSticker(sticker, signal) {
|
|
3649
|
+
const store = ctx.get('attachments')
|
|
3650
|
+
if (store === undefined || typeof sticker?.id !== 'string') return []
|
|
3651
|
+
try {
|
|
3652
|
+
const preview = await stickerPreview(sticker, signal)
|
|
3653
|
+
if (preview === undefined) return []
|
|
3654
|
+
const attachment = await store.saveImage({ data: preview.data, mediaType: preview.mediaType, name: `sticker-${sticker.id}${extname(preview.path)}` })
|
|
3655
|
+
return [{ type: 'image', attachment }]
|
|
3656
|
+
} catch (error) {
|
|
3657
|
+
warn(`sticker ${sticker.id} preview not stored in the session: ${messageOf(error)}`)
|
|
3658
|
+
return []
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3569
3662
|
/**
|
|
3570
3663
|
* Remember that a file went to a topic in one turn, so the same file is
|
|
3571
3664
|
* not uploaded twice in that turn (present, telegram_send and reply images
|
|
@@ -3663,6 +3756,33 @@ export function apply(ctx, config) {
|
|
|
3663
3756
|
return parts.join(' ')
|
|
3664
3757
|
}
|
|
3665
3758
|
|
|
3759
|
+
/**
|
|
3760
|
+
* The source of a send's copy in the bound session. It is not `user`, so
|
|
3761
|
+
* DSH does not take the copy for something the user typed (Web UI bubble,
|
|
3762
|
+
* session title, turn outline, last-prompt time) and the mirror skips it.
|
|
3763
|
+
* `form: 'notice'` with `summary` is how DSH's Chat shows it when the
|
|
3764
|
+
* bot's client is not loaded: a one-line notice row. The bot's client
|
|
3765
|
+
* draws it in full, pictures included, on the assistant's side.
|
|
3766
|
+
* @param post - `{ header, threadId, topic, from, self }`; `header` is the
|
|
3767
|
+
* line the model also reads, `from` the request's sender.
|
|
3768
|
+
* @returns the message source.
|
|
3769
|
+
*/
|
|
3770
|
+
function postSource({ header, threadId, topic, from, self }) {
|
|
3771
|
+
const job = typeof from?.job === 'string' && from.job.trim().length > 0 ? from.job.trim() : null
|
|
3772
|
+
const label = typeof from?.label === 'string' && from.label.trim().length > 0 ? from.label.trim() : null
|
|
3773
|
+
return {
|
|
3774
|
+
kind: POST_SOURCE_KIND,
|
|
3775
|
+
form: 'notice',
|
|
3776
|
+
summary: header.slice(0, 120),
|
|
3777
|
+
threadId,
|
|
3778
|
+
topic: topic ?? null,
|
|
3779
|
+
job,
|
|
3780
|
+
label: job === null ? label : null,
|
|
3781
|
+
fromSession: typeof from?.sessionId === 'string' && from.sessionId.length > 0 ? from.sessionId : null,
|
|
3782
|
+
self: self === true,
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3666
3786
|
/**
|
|
3667
3787
|
* An error a retry will not fix: callers such as scheduled-job delivery
|
|
3668
3788
|
* read `retryable: false` and stop trying.
|
|
@@ -3697,8 +3817,8 @@ export function apply(ctx, config) {
|
|
|
3697
3817
|
* thread id; the message goes to the chat the bot saw that thread in,
|
|
3698
3818
|
* or to `chat` when given (needed only when two chats share the id);
|
|
3699
3819
|
* `from` may hold `label` (who sent it), `job` (a job name)
|
|
3700
|
-
* and `sessionId` (the sender session;
|
|
3701
|
-
*
|
|
3820
|
+
* and `sessionId` (the sender session; the bound session sending into
|
|
3821
|
+
* its own topic gets its copy too, labelled "by this session").
|
|
3702
3822
|
* @param signal - request lifetime.
|
|
3703
3823
|
* @returns where it went and whether the session recorded it.
|
|
3704
3824
|
*/
|
|
@@ -3779,31 +3899,29 @@ export function apply(ctx, config) {
|
|
|
3779
3899
|
}
|
|
3780
3900
|
}
|
|
3781
3901
|
}
|
|
3782
|
-
|
|
3902
|
+
// Every send is recorded in the bound session, the sender's own included:
|
|
3903
|
+
// the Web UI shows that session, and a tool call alone shows neither the
|
|
3904
|
+
// picture nor the sticker that went to Telegram.
|
|
3905
|
+
if (typeof sessionId === 'string') {
|
|
3783
3906
|
try {
|
|
3784
3907
|
const chat = await chatFor(chatId, threadId)
|
|
3785
3908
|
const agent = ctx.get('agents')?.get(sessionId) ?? await ensureAgent(chat)
|
|
3786
|
-
const
|
|
3909
|
+
const self = sessionId === request?.from?.sessionId
|
|
3910
|
+
const label = self ? ' by this session' : sender.length > 0 ? ` by ${sender}` : ''
|
|
3787
3911
|
const where = target.name === undefined ? `topic ${threadId}` : `topic "${target.name}"`
|
|
3912
|
+
const header = `Sent to the Telegram ${where}${label}`
|
|
3788
3913
|
const stickerLine = sticker === undefined ? '' : `\n[Sticker ${describeSticker(sticker)}]`
|
|
3789
|
-
const content = [{ type: 'text', text: `[
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
content
|
|
3798
|
-
|
|
3799
|
-
|
|
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.')
|
|
3914
|
+
const content = [{ type: 'text', text: `[${header}]${text.trim().length > 0 ? `\n${text.trim()}` : ''}${stickerLine}` }]
|
|
3915
|
+
content.push(...await recordedFiles(files))
|
|
3916
|
+
if (sticker !== undefined) content.push(...await recordedSticker(sticker, signal))
|
|
3917
|
+
// Recording never starts a turn. The model reads it as user-role
|
|
3918
|
+
// context; the source says it is a post, not something the user typed.
|
|
3919
|
+
recordMessage(agent, {
|
|
3920
|
+
id: randomUUID(),
|
|
3921
|
+
role: 'user',
|
|
3922
|
+
content,
|
|
3923
|
+
source: postSource({ header, threadId, topic: target.name, from: request?.from, self }),
|
|
3924
|
+
})
|
|
3807
3925
|
recorded = true
|
|
3808
3926
|
} catch (error) {
|
|
3809
3927
|
// The message is already in Telegram; report the missing record
|
|
@@ -3825,7 +3943,7 @@ export function apply(ctx, config) {
|
|
|
3825
3943
|
job: typeof request?.from?.job === 'string' && request.from.job.trim().length > 0 ? request.from.job.trim() : null,
|
|
3826
3944
|
label: typeof request?.from?.label === 'string' ? request.from.label : null,
|
|
3827
3945
|
boundSession: typeof sessionId === 'string' ? sessionId : null,
|
|
3828
|
-
recorded
|
|
3946
|
+
recorded,
|
|
3829
3947
|
})
|
|
3830
3948
|
} catch (error) {
|
|
3831
3949
|
reportError('sent-log', error)
|
|
@@ -6095,7 +6213,7 @@ export function apply(ctx, config) {
|
|
|
6095
6213
|
/** Model-facing tool for sending into a topic; see sendToTopic. */
|
|
6096
6214
|
const sendTool = {
|
|
6097
6215
|
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,
|
|
6216
|
+
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
6217
|
parameters: {
|
|
6100
6218
|
type: 'object',
|
|
6101
6219
|
properties: {
|
|
@@ -6128,7 +6246,7 @@ export function apply(ctx, config) {
|
|
|
6128
6246
|
/** Model-facing tool for the sticker library; see stickers.js. */
|
|
6129
6247
|
const stickerTool = {
|
|
6130
6248
|
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.',
|
|
6249
|
+
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
6250
|
parameters: {
|
|
6133
6251
|
type: 'object',
|
|
6134
6252
|
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
|
|
@@ -70,6 +70,19 @@ window.__ModuleLoader__.load({
|
|
|
70
70
|
loadingSettings: 'Loading settings…',
|
|
71
71
|
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
|
|
72
72
|
readOnly: 'This deployment stores settings read-only.',
|
|
73
|
+
postTitle: 'Sent to Telegram',
|
|
74
|
+
postTopic: 'topic',
|
|
75
|
+
postJob: 'job',
|
|
76
|
+
postSession: 'session',
|
|
77
|
+
postOtherSession: 'another session',
|
|
78
|
+
postSticker: 'Sticker',
|
|
79
|
+
postFile: 'file',
|
|
80
|
+
postCopy: 'Copy',
|
|
81
|
+
postCopied: 'Copied',
|
|
82
|
+
postCode: 'Code',
|
|
83
|
+
postWrap: 'Wrap',
|
|
84
|
+
postUnwrap: 'Unwrap',
|
|
85
|
+
postFootnotes: 'Footnotes',
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
const ru = {
|
|
@@ -127,6 +140,19 @@ window.__ModuleLoader__.load({
|
|
|
127
140
|
loadingSettings: 'Загрузка настроек…',
|
|
128
141
|
unavailable: 'Плагин не загружен, поэтому настройки сейчас недоступны.',
|
|
129
142
|
readOnly: 'Этот deployment хранит настройки только для чтения.',
|
|
143
|
+
postTitle: 'Отправлено в Telegram',
|
|
144
|
+
postTopic: 'тема',
|
|
145
|
+
postJob: 'задача',
|
|
146
|
+
postSession: 'сессия',
|
|
147
|
+
postOtherSession: 'другая сессия',
|
|
148
|
+
postSticker: 'Стикер',
|
|
149
|
+
postFile: 'файл',
|
|
150
|
+
postCopy: 'Копировать',
|
|
151
|
+
postCopied: 'Скопировано',
|
|
152
|
+
postCode: 'Код',
|
|
153
|
+
postWrap: 'Переносить',
|
|
154
|
+
postUnwrap: 'Не переносить',
|
|
155
|
+
postFootnotes: 'Сноски',
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
const CSS = `
|
|
@@ -730,6 +756,293 @@ window.__ModuleLoader__.load({
|
|
|
730
756
|
)
|
|
731
757
|
}
|
|
732
758
|
|
|
759
|
+
// ---- Telegram posts in the Chat transcript ----
|
|
760
|
+
//
|
|
761
|
+
// A send into a topic leaves a user-role copy in the topic's session, so
|
|
762
|
+
// the model knows what was said there. DSH's Chat would draw an ordinary
|
|
763
|
+
// user message as the user's own bubble; these Definitions claim the copy
|
|
764
|
+
// and draw it as what it is: something sent to Telegram, on the
|
|
765
|
+
// assistant's side, pictures included. The recorded message is unchanged.
|
|
766
|
+
|
|
767
|
+
/** Chat node kind, conversation Definition kind and Host source kind of a post. */
|
|
768
|
+
const POST_KIND = 'telegram-post'
|
|
769
|
+
/** Request-id prefix of posts recorded before they had their own source kind. */
|
|
770
|
+
const LEGACY_POST_RPC = 'telegram-post:'
|
|
771
|
+
/** First text line of every post: what the model reads as its sender. */
|
|
772
|
+
const HEADER_LINE = /^\[(Sent to the Telegram [^\n]*)\]$/u
|
|
773
|
+
/** Line naming a sent sticker; its still picture follows as an image. */
|
|
774
|
+
const STICKER_LINE = /^\[Sticker ([^\n]*)\]$/u
|
|
775
|
+
/** The header's own words, for posts that carry no structured source. */
|
|
776
|
+
const HEADER_PARTS = /^Sent to the Telegram topic (?:"(.*)"|(\d+))(?: by (.*))?$/u
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Whether a durable message source marks a topic send's copy.
|
|
780
|
+
* @param source - `user/message` source.
|
|
781
|
+
* @returns true for posts, old and new.
|
|
782
|
+
*/
|
|
783
|
+
function isPostSource(source) {
|
|
784
|
+
if (typeof source !== 'object' || source === null) return false
|
|
785
|
+
if (source.kind === POST_KIND) return true
|
|
786
|
+
return source.kind === 'user' && typeof source.rpcId === 'string' && source.rpcId.startsWith(LEGACY_POST_RPC)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Claim a post's `user/message` on the Chat target. Only append-origin
|
|
791
|
+
* copies are claimed: a replacement copy (compaction) belongs to the
|
|
792
|
+
* system-prompt Definition and is never drawn.
|
|
793
|
+
* @param event - Client history event.
|
|
794
|
+
* @returns the match, or null when the event is not a post.
|
|
795
|
+
*/
|
|
796
|
+
function matchPost(event) {
|
|
797
|
+
if (event?.type !== 'user/message' || event.surfaceOp !== 'append') return null
|
|
798
|
+
const data = event.data
|
|
799
|
+
if (data === null || typeof data !== 'object' || !isPostSource(data.source)) return null
|
|
800
|
+
return { id: String(data.id), role: 'start', exclusive: true }
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Who sent a post and where, from its source or, for old records, from
|
|
805
|
+
* the header line the model reads.
|
|
806
|
+
* @param source - the post's source.
|
|
807
|
+
* @param header - header line without brackets, when present.
|
|
808
|
+
* @returns `{ topic, threadId, job, label, fromSession, self }`; unknown fields are null.
|
|
809
|
+
*/
|
|
810
|
+
function postMeta(source, header) {
|
|
811
|
+
if (source?.kind === POST_KIND) {
|
|
812
|
+
return {
|
|
813
|
+
topic: typeof source.topic === 'string' ? source.topic : null,
|
|
814
|
+
threadId: typeof source.threadId === 'number' ? source.threadId : null,
|
|
815
|
+
job: typeof source.job === 'string' ? source.job : null,
|
|
816
|
+
label: typeof source.label === 'string' ? source.label : null,
|
|
817
|
+
fromSession: typeof source.fromSession === 'string' ? source.fromSession : null,
|
|
818
|
+
self: source.self === true,
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const meta = { topic: null, threadId: null, job: null, label: null, fromSession: null, self: false }
|
|
822
|
+
const parts = header === null ? null : HEADER_PARTS.exec(header)
|
|
823
|
+
if (parts === null) return meta
|
|
824
|
+
if (parts[1] !== undefined) meta.topic = parts[1]
|
|
825
|
+
else meta.threadId = Number(parts[2])
|
|
826
|
+
const by = parts[3]
|
|
827
|
+
if (by === undefined) return meta
|
|
828
|
+
if (by === 'this session') {
|
|
829
|
+
meta.self = true
|
|
830
|
+
return meta
|
|
831
|
+
}
|
|
832
|
+
const job = /^job "(.*?)"(?: from session (\S+))?$/u.exec(by)
|
|
833
|
+
const session = /^session (\S+)$/u.exec(by)
|
|
834
|
+
if (job !== null) {
|
|
835
|
+
meta.job = job[1]
|
|
836
|
+
meta.fromSession = job[2] ?? null
|
|
837
|
+
} else if (session !== null) {
|
|
838
|
+
meta.fromSession = session[1]
|
|
839
|
+
} else {
|
|
840
|
+
meta.label = by
|
|
841
|
+
}
|
|
842
|
+
return meta
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Split a post's recorded content into what the transcript shows.
|
|
847
|
+
* @param content - the message's content blocks.
|
|
848
|
+
* @param source - the message's source.
|
|
849
|
+
* @returns `{ meta, text, stickers, images, files }`: the body without
|
|
850
|
+
* its header and sticker lines, sticker descriptions, image sources
|
|
851
|
+
* for the gallery and file attachments.
|
|
852
|
+
*/
|
|
853
|
+
function readPost(content, source) {
|
|
854
|
+
const blocks = Array.isArray(content) ? content : []
|
|
855
|
+
const lines = blocks
|
|
856
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
857
|
+
.map((block) => block.text)
|
|
858
|
+
.join('\n')
|
|
859
|
+
.split('\n')
|
|
860
|
+
const first = HEADER_LINE.exec(lines[0] ?? '')
|
|
861
|
+
if (first !== null) lines.shift()
|
|
862
|
+
const stickers = []
|
|
863
|
+
const body = []
|
|
864
|
+
for (const line of lines) {
|
|
865
|
+
const sticker = STICKER_LINE.exec(line)
|
|
866
|
+
if (sticker !== null) stickers.push(sticker[1])
|
|
867
|
+
else body.push(line)
|
|
868
|
+
}
|
|
869
|
+
const images = []
|
|
870
|
+
const files = []
|
|
871
|
+
for (const block of blocks) {
|
|
872
|
+
if (block?.attachment === undefined || block.attachment === null) continue
|
|
873
|
+
if (block.type === 'image') {
|
|
874
|
+
const name = block.attachment.name
|
|
875
|
+
images.push(typeof name === 'string' && name.length > 0 ? { attachment: block.attachment, label: name } : { attachment: block.attachment })
|
|
876
|
+
} else if (block.type === 'file') {
|
|
877
|
+
files.push(block.attachment)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
meta: postMeta(source, first === null ? null : first[1]),
|
|
882
|
+
text: body.join('\n').trim(),
|
|
883
|
+
stickers,
|
|
884
|
+
images,
|
|
885
|
+
files,
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Chat Definition for topic sends. The row stays in its Turn outside the
|
|
891
|
+
* folded work rows (`turn`): it is something sent, not a step of work,
|
|
892
|
+
* and it neither opens nor joins a Turn's input.
|
|
893
|
+
*/
|
|
894
|
+
const postDefinition = {
|
|
895
|
+
kind: POST_KIND,
|
|
896
|
+
target: 'chat',
|
|
897
|
+
match: { 'user/message': matchPost },
|
|
898
|
+
start: (_context, match) => {
|
|
899
|
+
const event = match.event
|
|
900
|
+
return {
|
|
901
|
+
seq: event.seq,
|
|
902
|
+
time: event.time,
|
|
903
|
+
...readPost(event.data.content, event.data.source),
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
update: (context) => context.state,
|
|
907
|
+
buildViewNode: (context) => {
|
|
908
|
+
if (context.state === undefined) return null
|
|
909
|
+
const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
|
|
910
|
+
return {
|
|
911
|
+
key: context.key,
|
|
912
|
+
kind: POST_KIND,
|
|
913
|
+
id: context.id,
|
|
914
|
+
target: 'chat',
|
|
915
|
+
anchorSeq: context.state.seq,
|
|
916
|
+
location,
|
|
917
|
+
visibility: 'visible',
|
|
918
|
+
groupTree: location.kind === 'turn' || location.kind === 'step' ? 'turn' : undefined,
|
|
919
|
+
data: context.state,
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Where and from whom, as short header pieces.
|
|
926
|
+
* @param meta - see postMeta.
|
|
927
|
+
* @param t - locale reader.
|
|
928
|
+
* @returns `{ where, who, whoTitle }`; `who` is null for the session's
|
|
929
|
+
* own sends, which already sit on its side of the transcript.
|
|
930
|
+
*/
|
|
931
|
+
function postLabels(meta, t) {
|
|
932
|
+
const where = meta.topic !== null ? meta.topic : meta.threadId !== null ? `${t('postTopic')} ${meta.threadId}` : null
|
|
933
|
+
let who = null
|
|
934
|
+
let whoTitle
|
|
935
|
+
if (meta.job !== null) {
|
|
936
|
+
who = `${t('postJob')} ${meta.job}`
|
|
937
|
+
if (meta.fromSession !== null) whoTitle = `${t('postSession')} ${meta.fromSession}`
|
|
938
|
+
} else if (!meta.self && meta.fromSession !== null) {
|
|
939
|
+
who = t('postOtherSession')
|
|
940
|
+
whoTitle = `${t('postSession')} ${meta.fromSession}`
|
|
941
|
+
} else if (!meta.self && meta.label !== null) {
|
|
942
|
+
who = meta.label
|
|
943
|
+
}
|
|
944
|
+
return { where, who, whoTitle }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Clock and full date of the post, in the browser's locale.
|
|
949
|
+
* @param time - event time (epoch milliseconds or ISO text).
|
|
950
|
+
* @returns `{ clock, iso, full }`, or null for a missing time.
|
|
951
|
+
*/
|
|
952
|
+
function postTime(time) {
|
|
953
|
+
if (time === undefined || time === null) return null
|
|
954
|
+
const date = new Date(time)
|
|
955
|
+
if (Number.isNaN(date.getTime())) return null
|
|
956
|
+
return {
|
|
957
|
+
clock: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
958
|
+
iso: date.toISOString(),
|
|
959
|
+
full: date.toLocaleString(),
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Transcript styles; theme tokens keep it in step with light and dark. */
|
|
964
|
+
const POST_CSS = `
|
|
965
|
+
.dtb-post{display:flex;flex-direction:column;gap:6px;min-width:0;padding:2px 0 2px 12px;border-left:2px solid var(--dsw-alias-border-l2,rgba(127,127,127,.35))}
|
|
966
|
+
.dtb-postHead{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px))}
|
|
967
|
+
.dtb-postIcon{display:inline-flex;flex:none}
|
|
968
|
+
.dtb-postTitle{flex:none}
|
|
969
|
+
.dtb-postSep{flex:none;width:2px;height:2px;border-radius:1px;background:var(--dsw-alias-label-caption,currentColor)}
|
|
970
|
+
.dtb-postMeta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
971
|
+
.dtb-postTime{flex:none;margin-left:auto;font:var(--dsw-font-xxs-12,12px/16px inherit)}
|
|
972
|
+
.dtb-postBody{min-width:0;color:var(--dsw-alias-label-primary)}
|
|
973
|
+
.dtb-postPlain{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
974
|
+
.dtb-postCaption{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}
|
|
975
|
+
.dtb-postFiles{display:flex;flex-wrap:wrap;gap:6px}
|
|
976
|
+
.dtb-postFile{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.35));border-radius:var(--dsw-radius-md,8px);padding:4px 10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
|
|
977
|
+
`
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Markdown control labels from this plugin's dictionary.
|
|
981
|
+
* @param t - locale reader.
|
|
982
|
+
*/
|
|
983
|
+
function postMarkdownLabels(t) {
|
|
984
|
+
return {
|
|
985
|
+
code: {
|
|
986
|
+
copyLabel: t('postCopy'),
|
|
987
|
+
copiedLabel: t('postCopied'),
|
|
988
|
+
toolbarLabels: { codeLabel: t('postCode'), wrapLabel: t('postWrap'), unwrapLabel: t('postUnwrap') },
|
|
989
|
+
},
|
|
990
|
+
footnotes: t('postFootnotes'),
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Keyed Chat renderer of one post.
|
|
996
|
+
* @param props - `node`, `loadImage`, `renderFactorySlot` and `t` from the Chat node slot.
|
|
997
|
+
*/
|
|
998
|
+
function TelegramPostView(props) {
|
|
999
|
+
const { node, loadImage, renderFactorySlot, t } = props
|
|
1000
|
+
const data = node.data
|
|
1001
|
+
const labels = React.useMemo(() => postMarkdownLabels(t), [t])
|
|
1002
|
+
const { where, who, whoTitle } = postLabels(data.meta, t)
|
|
1003
|
+
const time = postTime(data.time)
|
|
1004
|
+
const Icon = primitives.IconPaperPlaneOutlineRegular
|
|
1005
|
+
const Markdown = primitives.MarkdownText
|
|
1006
|
+
const head = [
|
|
1007
|
+
h('span', { key: 'icon', className: 'dtb-postIcon', 'aria-hidden': true }, Icon === undefined ? null : h(Icon, { size: 14 })),
|
|
1008
|
+
h('span', { key: 'title', className: 'dtb-postTitle' }, t('postTitle')),
|
|
1009
|
+
]
|
|
1010
|
+
if (where !== null) {
|
|
1011
|
+
head.push(h('span', { key: 'sep-where', className: 'dtb-postSep', 'aria-hidden': true }))
|
|
1012
|
+
head.push(h('span', { key: 'where', className: 'dtb-postMeta' }, where))
|
|
1013
|
+
}
|
|
1014
|
+
if (who !== null) {
|
|
1015
|
+
head.push(h('span', { key: 'sep-who', className: 'dtb-postSep', 'aria-hidden': true }))
|
|
1016
|
+
head.push(h('span', { key: 'who', className: 'dtb-postMeta', title: whoTitle }, who))
|
|
1017
|
+
}
|
|
1018
|
+
if (time !== null) head.push(h('time', { key: 'time', className: 'dtb-postTime', dateTime: time.iso, title: time.full }, time.clock))
|
|
1019
|
+
return h(
|
|
1020
|
+
'div',
|
|
1021
|
+
{ className: 'dtb-post', 'data-telegram-post': true },
|
|
1022
|
+
h('div', { className: 'dtb-postHead' }, ...head),
|
|
1023
|
+
data.text.length === 0
|
|
1024
|
+
? null
|
|
1025
|
+
: h(
|
|
1026
|
+
'div',
|
|
1027
|
+
{ className: 'dtb-postBody' },
|
|
1028
|
+
Markdown === undefined ? h('div', { className: 'dtb-postPlain' }, data.text) : h(Markdown, { text: data.text, labels }),
|
|
1029
|
+
),
|
|
1030
|
+
data.images.length === 0 || typeof renderFactorySlot !== 'function'
|
|
1031
|
+
? null
|
|
1032
|
+
: renderFactorySlot('conversation.images.content', { images: data.images, loadImage, align: 'start' }),
|
|
1033
|
+
...data.stickers.map((sticker, index) => h('div', { key: `sticker-${index}`, className: 'dtb-postCaption' }, `${t('postSticker')} ${sticker}`)),
|
|
1034
|
+
data.files.length === 0
|
|
1035
|
+
? null
|
|
1036
|
+
: h(
|
|
1037
|
+
'div',
|
|
1038
|
+
{ className: 'dtb-postFiles' },
|
|
1039
|
+
...data.files.map((file, index) =>
|
|
1040
|
+
h('span', { key: `file-${index}`, className: 'dtb-postFile', title: file.name }, `📎 ${file.name ?? t('postFile')}`),
|
|
1041
|
+
),
|
|
1042
|
+
),
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
733
1046
|
/** Required client services. */
|
|
734
1047
|
const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'configForms']
|
|
735
1048
|
|
|
@@ -799,11 +1112,32 @@ window.__ModuleLoader__.load({
|
|
|
799
1112
|
),
|
|
800
1113
|
'dsh-telegram-bot: bundle settings',
|
|
801
1114
|
)
|
|
1115
|
+
|
|
1116
|
+
if (typeof document !== 'undefined') {
|
|
1117
|
+
ctx.effect(() => {
|
|
1118
|
+
const style = document.createElement('style')
|
|
1119
|
+
style.dataset.plugin = '@soffits/dsh-telegram-bot'
|
|
1120
|
+
style.textContent = POST_CSS
|
|
1121
|
+
document.head.appendChild(style)
|
|
1122
|
+
return () => style.remove()
|
|
1123
|
+
}, 'dsh-telegram-bot: post styles')
|
|
1124
|
+
}
|
|
1125
|
+
// Chat may be absent (a client without the transcript); settings still load.
|
|
1126
|
+
ctx.inject(['uiConversation'], (scope) => {
|
|
1127
|
+
scope.uiConversation.events.register(postDefinition)
|
|
1128
|
+
})
|
|
1129
|
+
ctx.slots.inject('conversation.chat.node', () =>
|
|
1130
|
+
ctx.slots.register({ name: 'conversation.chat.node', key: POST_KIND, locale: NS }, TelegramPostView),
|
|
1131
|
+
)
|
|
802
1132
|
}
|
|
803
1133
|
|
|
804
1134
|
exports.NS = NS
|
|
805
1135
|
exports.apply = apply
|
|
806
1136
|
exports.inject = inject
|
|
1137
|
+
exports.postDefinition = postDefinition
|
|
1138
|
+
exports.readPost = readPost
|
|
1139
|
+
exports.postLabels = postLabels
|
|
1140
|
+
exports.TelegramPostView = TelegramPostView
|
|
807
1141
|
return module.exports
|
|
808
1142
|
},
|
|
809
1143
|
})
|
package/src/constants.js
CHANGED
|
@@ -19,6 +19,12 @@ export const FORWARD_BATCH_MS = 1200
|
|
|
19
19
|
/** Loader row id and settings namespace; the npm package name is @soffits/dsh-telegram-bot. */
|
|
20
20
|
export const name = 'telegram-bot'
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Source kind of the copy a topic send leaves in the bound session; the
|
|
24
|
+
* client draws these as posts on the assistant's side.
|
|
25
|
+
*/
|
|
26
|
+
export const POST_SOURCE_KIND = 'telegram-post'
|
|
27
|
+
|
|
22
28
|
/** Command menu shown by Telegram clients while the user types `/`. */
|
|
23
29
|
const BOT_COMMANDS = [
|
|
24
30
|
{ command: 'start', description: 'Start the bot and show help' },
|
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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* @
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
/**
|