@coffer-org/plugin-telegram 2.2.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,10 +3,13 @@ import path from 'node:path';
3
3
  import { TelegramClient, Api } from 'teleproto';
4
4
  import { LogLevel } from 'teleproto/extensions/Logger.js';
5
5
  import { StringSession } from 'teleproto/sessions/index.js';
6
- import { NewMessage } from 'teleproto/events/index.js';
7
- import { handleIncoming, loadGatePolicy, makeLiveChannel, chunk } from '@coffer-org/plugin-orchestrator/runtime';
6
+ import { NewMessage, CallbackQuery } from 'teleproto/events/index.js';
7
+ import { Button } from 'teleproto/tl/custom/button.js';
8
+ import { handleIncoming, loadGatePolicy, makeLiveChannel, chunk, listAgentCatalog, isSenderAllowed, liveAgentId, } from '@coffer-org/plugin-orchestrator/runtime';
9
+ import { getThreadState, setThreadState } from '@coffer-org/server/thread-state';
8
10
  import { recordUser, recordAssistant, buildChain } from "./chain-store.js";
9
- import { loadBotConfig, hasCredentials, loadReasoningDisplay } from "./config.js";
11
+ import { loadBotConfig, hasCredentials, loadReasoningDisplay, loadModelStrings } from "./config.js";
12
+ import { buildModelKeyboard, isModelCommand, parseModelCallback } from "./model-command.js";
10
13
  import { getLogger } from '@coffer-org/sdk/logger';
11
14
  const log = getLogger('telegram');
12
15
  const FALLBACK_REPLY_WINDOW_MS = 1_800_000;
@@ -55,7 +58,7 @@ export function renderWithReasoning(mode, max) {
55
58
  };
56
59
  }
57
60
  const TELEGRAM_FORMAT = [
58
- 'CRITICAL OUTPUT FORMAT — Telegram HTML ONLY. Format the reply strictly with Telegram\'s HTML subset (<b>, <i>, <code>, <a>, …) and NOTHING else. NEVER use Markdown — no *bold*, _italic_, **bold**, `code`, ```blocks```, # headings, - bullets, or | tables. Markdown does not render here; it shows up as literal characters and breaks the message. When in doubt, use an HTML tag or plain text.',
61
+ "CRITICAL OUTPUT FORMAT — Telegram HTML ONLY. Format the reply strictly with Telegram's HTML subset (<b>, <i>, <code>, <a>, …) and NOTHING else. NEVER use Markdown — no *bold*, _italic_, **bold**, `code`, ```blocks```, # headings, - bullets, or | tables. Markdown does not render here; it shows up as literal characters and breaks the message. When in doubt, use an HTML tag or plain text.",
59
62
  'Inline styling (tags may nest): <b>bold</b>, <i>italic</i>, <u>underline</u>, <s>strike</s>, <code>inline code</code>, <spoiler>spoiler</spoiler>. Links: <a href="https://…">text</a> (also <a href="tg://user?id=123">mention</a>).',
60
63
  'Blocks: <pre>code block</pre> or, for syntax highlight, <pre><code class="language-python">…</code></pre>. Quotes: <blockquote>quote</blockquote>, or <blockquote expandable>long collapsible quote</blockquote>. Bullet lists: start plain lines with "• ".',
61
64
  'Only those tags are parsed. NOT supported here (do not use): <tg-spoiler>, <span>, <ins>, <strike>, <tg-time> — use <spoiler>, <u>, <s> instead. Avoid <tg-emoji> (needs a custom-emoji id + Premium); use ordinary emoji.',
@@ -74,7 +77,13 @@ export function makeReplyFactory(t, display) {
74
77
  maxLength: TG_MAX,
75
78
  render: renderWithReasoning(display, TG_MAX),
76
79
  });
77
- return display === 'separate' ? ch : { ...ch, segment() { } };
80
+ return display === 'separate'
81
+ ? ch
82
+ : {
83
+ ...ch,
84
+ segment() {
85
+ },
86
+ };
78
87
  };
79
88
  }
80
89
  function mediaInfo(message) {
@@ -159,6 +168,28 @@ export async function startBot(opts = {}) {
159
168
  }
160
169
  }
161
170
  }
171
+ async function sendModelKeyboard(peer, rows, prompt) {
172
+ if (rows.length === 0) {
173
+ log.warn('/model: no agent presets registered — nothing to show');
174
+ return;
175
+ }
176
+ try {
177
+ await tg.sendMessage(peer, {
178
+ message: prompt,
179
+ buttons: rows.map((row) => row.map((b) => Button.inline(b.text, Buffer.from(b.data, 'utf-8')))),
180
+ });
181
+ }
182
+ catch (err) {
183
+ log.warn(`send /model keyboard: ${err instanceof Error ? err.message : String(err)}`);
184
+ }
185
+ }
186
+ async function answerCallback(query, text) {
187
+ try {
188
+ await query.answer({ message: text });
189
+ }
190
+ catch {
191
+ }
192
+ }
162
193
  function startTypingPeer(peer) {
163
194
  let logged = false;
164
195
  let stopped = false;
@@ -180,9 +211,7 @@ export async function startBot(opts = {}) {
180
211
  }
181
212
  if (stopped)
182
213
  return;
183
- const ping = () => tg
184
- .invoke(new Api.messages.SetTyping({ peer: input, action: new Api.SendMessageTypingAction() }))
185
- .catch(warn);
214
+ const ping = () => tg.invoke(new Api.messages.SetTyping({ peer: input, action: new Api.SendMessageTypingAction() })).catch(warn);
186
215
  void ping();
187
216
  timer = setInterval(() => void ping(), 5000);
188
217
  })();
@@ -219,6 +248,14 @@ export async function startBot(opts = {}) {
219
248
  try {
220
249
  const chatId = String(event.chatId ?? '?');
221
250
  const userId = message.senderId?.toString() ?? '?';
251
+ if (isModelCommand(text)) {
252
+ if (!(await isSenderAllowed('telegram', userId)))
253
+ return;
254
+ const strings = await loadModelStrings();
255
+ const rows = buildModelKeyboard(await listAgentCatalog(), await getThreadState('telegram', chatId));
256
+ await sendModelKeyboard(peer, rows, strings.prompt);
257
+ return;
258
+ }
222
259
  const incomingMsgId = String(message.id);
223
260
  const replyTo = message.replyTo?.replyToMsgId ?? null;
224
261
  let replyToId = replyTo != null ? String(replyTo) : null;
@@ -275,6 +312,8 @@ export async function startBot(opts = {}) {
275
312
  }
276
313
  : undefined;
277
314
  const reasoningDisplay = await loadReasoningDisplay();
315
+ const selection = await getThreadState('telegram', chatId);
316
+ const selectedAgentId = liveAgentId(selection.agentId);
278
317
  const connector = {
279
318
  id: 'telegram',
280
319
  reply: makeReplyFactory({
@@ -299,6 +338,8 @@ export async function startBot(opts = {}) {
299
338
  sender: { id: userId, displayName },
300
339
  messages,
301
340
  ...(prepareAttachments ? { prepareAttachments } : {}),
341
+ ...(selectedAgentId ? { agentId: selectedAgentId } : {}),
342
+ ...(selection.presetId ? { presetId: selection.presetId } : {}),
302
343
  });
303
344
  }
304
345
  finally {
@@ -309,6 +350,30 @@ export async function startBot(opts = {}) {
309
350
  log.error(`message handling: ${err instanceof Error ? err.message : String(err)}`);
310
351
  }
311
352
  }, new NewMessage({}));
353
+ tg.addEventHandler(async (event) => {
354
+ try {
355
+ const data = event.data?.toString('utf-8');
356
+ if (!data)
357
+ return;
358
+ const picked = parseModelCallback(data);
359
+ if (!picked)
360
+ return;
361
+ const senderId = event.senderId?.toString() ?? '?';
362
+ if (!(await isSenderAllowed('telegram', senderId)))
363
+ return;
364
+ if (!liveAgentId(picked.agentId)) {
365
+ log.warn(`/model tap named an unregistered agent: ${picked.agentId}`);
366
+ return;
367
+ }
368
+ const chatId = String(event.chatId ?? '?');
369
+ await setThreadState('telegram', chatId, picked);
370
+ const strings = await loadModelStrings();
371
+ await answerCallback(event, strings.confirm);
372
+ }
373
+ catch (err) {
374
+ log.error(`callback query handling: ${err instanceof Error ? err.message : String(err)}`);
375
+ }
376
+ }, new CallbackQuery({}));
312
377
  clearInterval(healthTimer);
313
378
  healthTimer = setInterval(() => {
314
379
  if (!tg.connected) {
@@ -4,7 +4,17 @@ const log = getLogger('telegram');
4
4
  const CONNECTOR = 'telegram';
5
5
  const DEFAULT_MAX_DEPTH = 30;
6
6
  export async function recordUser(m) {
7
- await putThreadMessage({ connector: CONNECTOR, chatId: m.chatId, msgId: m.msgId, role: 'user', sender: m.sender ?? null, attachments: m.attachments, text: m.text, ts: m.ts, replyToId: m.replyToId });
7
+ await putThreadMessage({
8
+ connector: CONNECTOR,
9
+ chatId: m.chatId,
10
+ msgId: m.msgId,
11
+ role: 'user',
12
+ sender: m.sender ?? null,
13
+ attachments: m.attachments,
14
+ text: m.text,
15
+ ts: m.ts,
16
+ replyToId: m.replyToId,
17
+ });
8
18
  }
9
19
  export async function recordAssistant(m) {
10
20
  if (m.botMsgId == null)
@@ -32,7 +42,16 @@ export async function buildChain(headMsgId, opts) {
32
42
  if (!fetched)
33
43
  break;
34
44
  const role = fetched.senderId === opts.botId ? 'assistant' : 'user';
35
- await putThreadMessage({ connector: CONNECTOR, chatId: opts.chatId, msgId: cur, role, sender: null, text: fetched.text, ts: fetched.date, replyToId: fetched.replyToId });
45
+ await putThreadMessage({
46
+ connector: CONNECTOR,
47
+ chatId: opts.chatId,
48
+ msgId: cur,
49
+ role,
50
+ sender: null,
51
+ text: fetched.text,
52
+ ts: fetched.date,
53
+ replyToId: fetched.replyToId,
54
+ });
36
55
  stored = { msgId: cur, role, sender: null, text: fetched.text, ts: fetched.date, replyToId: fetched.replyToId };
37
56
  }
38
57
  if (stored.role !== 'reasoning') {
@@ -15,3 +15,8 @@ export declare function hasCredentials(cfg: Pick<BotConfig, 'apiId' | 'apiHash'
15
15
  export type ReasoningDisplay = 'off' | 'collapsible' | 'separate';
16
16
  export declare function toReasoningDisplay(v: unknown): ReasoningDisplay;
17
17
  export declare function loadReasoningDisplay(): Promise<ReasoningDisplay>;
18
+ export interface ModelStrings {
19
+ prompt: string;
20
+ confirm: string;
21
+ }
22
+ export declare function loadModelStrings(): Promise<ModelStrings>;
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { getPluginSettings } from '@coffer-org/server/plugin-runtime';
4
+ import { loadPluginI18n } from '@coffer-org/server/plugin-i18n';
4
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
6
  export const PLUGIN_ROOT = path.resolve(__dirname, '../..');
6
7
  export function loadBotConfig(opts = {}) {
@@ -23,3 +24,11 @@ export function toReasoningDisplay(v) {
23
24
  export async function loadReasoningDisplay() {
24
25
  return toReasoningDisplay((await getPluginSettings('telegram'))['reasoning_display']);
25
26
  }
27
+ const MODEL_STRINGS_FALLBACK = { prompt: 'Choose a model:', confirm: 'Done' };
28
+ export async function loadModelStrings() {
29
+ const i18n = await loadPluginI18n(new URL('../../locales/', import.meta.url));
30
+ return {
31
+ prompt: i18n.t('telegram.model.prompt', MODEL_STRINGS_FALLBACK.prompt),
32
+ confirm: i18n.t('telegram.model.confirm', MODEL_STRINGS_FALLBACK.confirm),
33
+ };
34
+ }
@@ -0,0 +1,12 @@
1
+ import type { AgentDescriptor } from '@coffer-org/plugin-orchestrator/runtime';
2
+ import type { ThreadSelection } from '@coffer-org/server/thread-state';
3
+ export interface KeyboardButton {
4
+ text: string;
5
+ data: string;
6
+ }
7
+ export declare function isModelCommand(text: string): boolean;
8
+ export declare function buildModelKeyboard(agents: AgentDescriptor[], selection: ThreadSelection): KeyboardButton[][];
9
+ export declare function parseModelCallback(data: string): {
10
+ agentId: string;
11
+ presetId: string;
12
+ } | null;
@@ -0,0 +1,32 @@
1
+ import { getLogger } from '@coffer-org/sdk/logger';
2
+ const log = getLogger('telegram');
3
+ const MODEL_COMMAND = /^\/model(@[a-z0-9_]{1,32})?(\s|$)/i;
4
+ export function isModelCommand(text) {
5
+ return MODEL_COMMAND.test(text.trim());
6
+ }
7
+ const CALLBACK_MAX_BYTES = 64;
8
+ function callbackBytes(data) {
9
+ return new TextEncoder().encode(data).length;
10
+ }
11
+ export function buildModelKeyboard(agents, selection) {
12
+ const multi = agents.length > 1;
13
+ return agents.flatMap((a) => a.presets.flatMap((p) => {
14
+ const data = `model:${a.id}:${p.id}`;
15
+ if (callbackBytes(data) > CALLBACK_MAX_BYTES) {
16
+ log.warn(`/model: skipping ${a.id}/${p.id} — callback data over ${CALLBACK_MAX_BYTES} bytes`);
17
+ return [];
18
+ }
19
+ const current = a.id === selection.agentId && p.id === selection.presetId;
20
+ const label = multi ? `${a.title} · ${p.title}` : p.title;
21
+ return [[{ text: current ? `✓ ${label}` : label, data }]];
22
+ }));
23
+ }
24
+ export function parseModelCallback(data) {
25
+ const parts = data.split(':');
26
+ if (parts.length !== 3 || parts[0] !== 'model')
27
+ return null;
28
+ const [, agentId, presetId] = parts;
29
+ if (!agentId || !presetId)
30
+ return null;
31
+ return { agentId, presetId };
32
+ }
package/dist/schema.js CHANGED
@@ -4502,6 +4502,8 @@ function normalizeOpts(rawIn) {
4502
4502
  unique: r.unique,
4503
4503
  fixed: r.fixed,
4504
4504
  exts: r.exts,
4505
+ ext: r.ext,
4506
+ maxBytes: r.maxBytes,
4505
4507
  lead: r.lead,
4506
4508
  by: r.by,
4507
4509
  min: r.min,
@@ -4913,6 +4915,42 @@ function snippet(raw) {
4913
4915
  }
4914
4916
  });
4915
4917
  }
4918
+ /** A NUL byte, or U+FFFD left behind by decoding non-UTF-8 bytes as text. */
4919
+ function isBinaryText(v) {
4920
+ return v.includes("\0") || v.includes("�");
4921
+ }
4922
+ function source(raw) {
4923
+ const o = normalizeOpts(raw);
4924
+ const required = o.required ?? false;
4925
+ const ext = o.ext ?? "txt";
4926
+ const maxBytes = o.maxBytes ?? 262144;
4927
+ const s = string$1(reqTypeErr()).superRefine((v, ctx) => {
4928
+ if (isBinaryText(v)) {
4929
+ ctx.addIssue({
4930
+ code: ZodIssueCode.custom,
4931
+ message: vmsg("source_binary")
4932
+ });
4933
+ return;
4934
+ }
4935
+ if (new TextEncoder().encode(v).length > maxBytes) ctx.addIssue({
4936
+ code: ZodIssueCode.custom,
4937
+ message: vmsg("source_too_large", { maxBytes })
4938
+ });
4939
+ });
4940
+ return wrapKey(o, {
4941
+ kind: "source",
4942
+ label: o.label ?? "",
4943
+ required,
4944
+ prim: "text",
4945
+ column: "text",
4946
+ hints: {
4947
+ ext,
4948
+ maxBytes,
4949
+ noEditControl: true
4950
+ },
4951
+ zod: optionalize(s, required)
4952
+ });
4953
+ }
4916
4954
  function rating(raw) {
4917
4955
  const o = normalizeOpts(raw);
4918
4956
  const required = o.required ?? false;
@@ -5049,6 +5087,7 @@ var presets = {
5049
5087
  tags,
5050
5088
  markdown,
5051
5089
  snippet,
5090
+ source,
5052
5091
  rating,
5053
5092
  duration,
5054
5093
  reminder,
@@ -6035,6 +6074,15 @@ function wrapKey(opts, meta) {
6035
6074
  ...m,
6036
6075
  hidden: opts.hidden
6037
6076
  };
6077
+ if (opts.derive) m = {
6078
+ ...m,
6079
+ derive: opts.derive,
6080
+ derived: true,
6081
+ hints: {
6082
+ ...m.hints,
6083
+ noEditControl: true
6084
+ }
6085
+ };
6038
6086
  if (opts.key) return {
6039
6087
  key: opts.key,
6040
6088
  type: m
@@ -6349,11 +6397,18 @@ function triState(raw) {
6349
6397
  zod: optionalize(s, required)
6350
6398
  });
6351
6399
  }
6400
+ /** Inline option entry → OptionItem (plain string = value and label at once). */
6401
+ function toOptionItem(o) {
6402
+ return typeof o === "string" ? {
6403
+ value: o,
6404
+ title: o
6405
+ } : o;
6406
+ }
6352
6407
  function select(raw) {
6353
6408
  const o = normalizeOpts(raw);
6354
6409
  const required = o.required ?? false;
6355
6410
  const source = typeof o.options === "string" ? o.options : null;
6356
- const inlineOpts = Array.isArray(o.options) ? o.options : [];
6411
+ const inlineOpts = Array.isArray(o.options) ? o.options.map(toOptionItem) : [];
6357
6412
  const s = inlineOpts.length > 0 ? _enum(inlineOpts.map((x) => x.value), { error: () => vmsg("enum") }) : string$1(reqErr());
6358
6413
  return wrapKey(o, applyMultiple({
6359
6414
  kind: "select",
@@ -6550,6 +6605,47 @@ function measured(raw) {
6550
6605
  zod: optionalize(s, required)
6551
6606
  });
6552
6607
  }
6608
+ function unit(raw) {
6609
+ const o = normalizeOpts(raw);
6610
+ const required = o.required ?? false;
6611
+ const units = resolveUnits(o.options);
6612
+ const s = string$1(reqErr()).refine((v) => units.some((u) => u.value === v), { message: vmsg("measured_unit") });
6613
+ return wrapKey(o, {
6614
+ kind: "unit",
6615
+ label: o.label ?? "",
6616
+ required,
6617
+ prim: "select",
6618
+ column: "text",
6619
+ hints: { source: typeof o.options === "string" ? o.options : null },
6620
+ options: units.map((u) => ({
6621
+ value: u.value,
6622
+ title: u.label
6623
+ })),
6624
+ zod: optionalize(s, required)
6625
+ });
6626
+ }
6627
+ function amount(raw) {
6628
+ const o = normalizeOpts(raw);
6629
+ const required = o.required ?? false;
6630
+ const cfg = o.config ?? {};
6631
+ let s = number(typeErr());
6632
+ if (cfg.min != null) s = s.min(cfg.min, { message: vmsg("min", { min: cfg.min }) });
6633
+ if (cfg.max != null) s = s.max(cfg.max, { message: vmsg("max", { max: cfg.max }) });
6634
+ return wrapKey(o, {
6635
+ kind: "amount",
6636
+ label: o.label ?? "",
6637
+ required,
6638
+ prim: "number",
6639
+ column: "real",
6640
+ hints: {
6641
+ unitFrom: o.unitFrom,
6642
+ min: cfg.min,
6643
+ max: cfg.max,
6644
+ step: cfg.step ?? "any"
6645
+ },
6646
+ zod: optionalize(s, required)
6647
+ });
6648
+ }
6553
6649
  function money(raw) {
6554
6650
  const o = normalizeOpts(raw);
6555
6651
  const required = o.required ?? false;
@@ -7027,6 +7123,8 @@ var PRIMITIVES = {
7027
7123
  json,
7028
7124
  check,
7029
7125
  measured,
7126
+ unit,
7127
+ amount,
7030
7128
  money,
7031
7129
  code,
7032
7130
  geo,
@@ -7072,7 +7170,7 @@ var field = new Proxy({}, {
7072
7170
  has: (_t, k) => k in composedField()
7073
7171
  });
7074
7172
  function toClient(field) {
7075
- const { kind, label, required, prim, hints, options, relation, virtual, json, hidden } = field;
7173
+ const { kind, label, required, prim, hints, options, relation, virtual, json, hidden, derived } = field;
7076
7174
  return {
7077
7175
  kind,
7078
7176
  label,
@@ -7083,7 +7181,8 @@ function toClient(field) {
7083
7181
  relation,
7084
7182
  virtual,
7085
7183
  json,
7086
- hidden
7184
+ hidden,
7185
+ derived
7087
7186
  };
7088
7187
  }
7089
7188
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/plugin-telegram",
3
- "version": "2.2.3",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -25,9 +25,9 @@
25
25
  "test": "node --import tsx --test \"src/runtime/*.test.ts\""
26
26
  },
27
27
  "dependencies": {
28
- "@coffer-org/plugin-orchestrator": "^2.3.3",
29
- "@coffer-org/sdk": "^2.1.3",
30
- "@coffer-org/server": "^2.5.2",
28
+ "@coffer-org/plugin-orchestrator": "^3.0.0",
29
+ "@coffer-org/sdk": "^3.0.0",
30
+ "@coffer-org/server": "^3.0.0",
31
31
  "input": "^1.0.1",
32
32
  "teleproto": "^1.228.1"
33
33
  },