@coffer-org/plugin-telegram 1.4.0 → 2.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.
package/dist/index.js CHANGED
@@ -12,6 +12,16 @@ export default definePlugin({
12
12
  field.password({ key: 'api_hash', label: 'telegram.settings.api_hash' }),
13
13
  field.password({ key: 'bot_token', label: 'telegram.settings.bot_token' }),
14
14
  field.tel({ key: 'phone', label: 'telegram.settings.phone' }),
15
+ field.select({
16
+ key: 'reasoning_display',
17
+ label: 'telegram.settings.reasoning_display',
18
+ default: 'collapsible',
19
+ options: [
20
+ { value: 'off', title: 'telegram.settings.reasoning_display_off' },
21
+ { value: 'collapsible', title: 'telegram.settings.reasoning_display_collapsible' },
22
+ { value: 'separate', title: 'telegram.settings.reasoning_display_separate' },
23
+ ],
24
+ }),
15
25
  ],
16
26
  }),
17
27
  });
@@ -1,5 +1,11 @@
1
- import type { LoadOpts } from './config.ts';
1
+ import type { LiveChannelOps, RenderFn, ReplyChannel, ReplyContext } from '@coffer-org/plugin-orchestrator/runtime';
2
+ import type { LoadOpts, ReasoningDisplay } from './config.ts';
2
3
  export declare function stripHtml(s: string): string;
4
+ export declare function escHtml(s: string): string;
5
+ export declare function renderWithReasoning(mode: ReasoningDisplay, max: number): RenderFn;
6
+ export declare const TG_MAX = 4096;
7
+ export declare const TG_THROTTLE_MS = 2000;
8
+ export declare function makeReplyFactory(t: LiveChannelOps, display: ReasoningDisplay): (_chatId: string, _ctx: ReplyContext) => ReplyChannel;
3
9
  export type StartOpts = LoadOpts;
4
10
  export declare function startBot(opts?: StartOpts): Promise<boolean>;
5
11
  export declare function stopBot(): Promise<void>;
@@ -4,9 +4,9 @@ import { TelegramClient, Api } from 'teleproto';
4
4
  import { LogLevel } from 'teleproto/extensions/Logger.js';
5
5
  import { StringSession } from 'teleproto/sessions/index.js';
6
6
  import { NewMessage } from 'teleproto/events/index.js';
7
- import { handleIncoming, loadGatePolicy } from '@coffer-org/plugin-orchestrator/runtime';
7
+ import { handleIncoming, loadGatePolicy, makeLiveChannel, chunk } from '@coffer-org/plugin-orchestrator/runtime';
8
8
  import { recordUser, recordAssistant, buildChain } from "./chain-store.js";
9
- import { loadBotConfig, hasCredentials } from "./config.js";
9
+ import { loadBotConfig, hasCredentials, loadReasoningDisplay } from "./config.js";
10
10
  import { getLogger } from '@coffer-org/sdk/logger';
11
11
  const log = getLogger('telegram');
12
12
  const FALLBACK_REPLY_WINDOW_MS = 1_800_000;
@@ -29,6 +29,31 @@ export function stripHtml(s) {
29
29
  .replace(/&#39;/g, "'")
30
30
  .replace(/&amp;/g, '&');
31
31
  }
32
+ export function escHtml(s) {
33
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
34
+ }
35
+ const OPEN = '<blockquote expandable>';
36
+ const CLOSE = '</blockquote>\n\n';
37
+ const MIN_REASONING = 100;
38
+ export function renderWithReasoning(mode, max) {
39
+ return ({ text, reasoning }) => {
40
+ const answer = (text ?? '').trim() || '⚠️ the agent returned no response';
41
+ if (mode !== 'collapsible')
42
+ return chunk(answer, max);
43
+ const raw = (reasoning ?? '').trim();
44
+ if (!raw)
45
+ return chunk(answer, max);
46
+ const parts = chunk(answer, max);
47
+ const head = parts[0];
48
+ const room = max - head.length - OPEN.length - CLOSE.length;
49
+ if (room < MIN_REASONING)
50
+ return parts;
51
+ let body = escHtml(raw);
52
+ if (body.length > room)
53
+ body = `…${body.slice(-(room - 1))}`;
54
+ return [`${OPEN}${body}${CLOSE}${head}`, ...parts.slice(1)];
55
+ };
56
+ }
32
57
  const TELEGRAM_FORMAT = [
33
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.',
34
59
  '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>).',
@@ -39,6 +64,19 @@ const TELEGRAM_FORMAT = [
39
64
  'Beyond the tags, use Unicode symbols and emoji to structure and enrich replies — they render everywhere: bullets (•, ▪, –), separators (─────), arrows (→), status marks (✅ ⚠️ ❌ ⏳), and topic emoji (📅 💰 🏥 🛒 🌱 …). They make messages scannable and friendly.',
40
65
  'Keep replies short and skimmable (messages cap at 4096 chars): prefer short paragraphs and "• " bullet lists over long prose.',
41
66
  ].join('\n');
67
+ export const TG_MAX = 4096;
68
+ export const TG_THROTTLE_MS = 2000;
69
+ export function makeReplyFactory(t, display) {
70
+ return (_chatId, _ctx) => {
71
+ const ch = makeLiveChannel({
72
+ ops: t,
73
+ throttleMs: TG_THROTTLE_MS,
74
+ maxLength: TG_MAX,
75
+ render: renderWithReasoning(display, TG_MAX),
76
+ });
77
+ return display === 'separate' ? ch : { ...ch, segment() { } };
78
+ };
79
+ }
42
80
  function loadSessionString(file) {
43
81
  try {
44
82
  return fs.readFileSync(file, 'utf-8');
@@ -197,21 +235,18 @@ export async function startBot(opts = {}) {
197
235
  }
198
236
  };
199
237
  const messages = await buildChain(incomingMsgId, { chatId, fetch, botId: meId ?? '' });
238
+ const reasoningDisplay = await loadReasoningDisplay();
200
239
  const connector = {
201
240
  id: 'telegram',
202
- capabilities: { streaming: true, typing: true, maxMessageLength: 4096 },
203
- async sendMessage(_chatId, msgText) {
204
- const sent = await sendHtml(peer, msgText, Number(incomingMsgId));
205
- if (!sent)
206
- return null;
207
- return { msgId: String(sent.id) };
208
- },
209
- async editMessage(_chatId, editId, msgText) {
210
- await editHtml(peer, Number(editId), msgText);
211
- },
212
- startTyping(_chatId) {
213
- return () => { };
214
- },
241
+ reply: makeReplyFactory({
242
+ send: async (text) => {
243
+ const sent = await sendHtml(peer, text, Number(incomingMsgId));
244
+ return sent ? String(sent.id) : null;
245
+ },
246
+ edit: async (msgId, text) => {
247
+ await editHtml(peer, Number(msgId), text);
248
+ },
249
+ }, reasoningDisplay),
215
250
  async recordAssistant(m) {
216
251
  await recordAssistant({ ...m, chatId, now: Math.floor(Date.now() / 1000) });
217
252
  if (m.botMsgId)
@@ -35,7 +35,9 @@ export async function buildChain(headMsgId, opts) {
35
35
  await putThreadMessage({ connector: CONNECTOR, chatId: opts.chatId, msgId: cur, role, sender: null, text: fetched.text, ts: fetched.date, replyToId: fetched.replyToId });
36
36
  stored = { msgId: cur, role, sender: null, text: fetched.text, ts: fetched.date, replyToId: fetched.replyToId };
37
37
  }
38
- acc.push({ role: stored.role, content: stored.text, sender: stored.sender, msgId: stored.msgId, ts: stored.ts });
38
+ if (stored.role !== 'reasoning') {
39
+ acc.push({ role: stored.role, content: stored.text, sender: stored.sender, msgId: stored.msgId, ts: stored.ts });
40
+ }
39
41
  cur = stored.replyToId;
40
42
  }
41
43
  if (acc.length >= max)
@@ -12,3 +12,6 @@ export interface LoadOpts {
12
12
  }
13
13
  export declare function loadBotConfig(opts?: LoadOpts): BotConfig;
14
14
  export declare function hasCredentials(cfg: Pick<BotConfig, 'apiId' | 'apiHash' | 'botToken' | 'phone'>): boolean;
15
+ export type ReasoningDisplay = 'off' | 'collapsible' | 'separate';
16
+ export declare function toReasoningDisplay(v: unknown): ReasoningDisplay;
17
+ export declare function loadReasoningDisplay(): Promise<ReasoningDisplay>;
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import { getPluginSettings } from '@coffer-org/server/plugin-runtime';
3
4
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
4
5
  export const PLUGIN_ROOT = path.resolve(__dirname, '../..');
5
6
  export function loadBotConfig(opts = {}) {
@@ -16,3 +17,9 @@ export function loadBotConfig(opts = {}) {
16
17
  export function hasCredentials(cfg) {
17
18
  return Boolean(cfg.apiId && cfg.apiHash && (cfg.botToken || cfg.phone));
18
19
  }
20
+ export function toReasoningDisplay(v) {
21
+ return v === 'off' || v === 'separate' ? v : 'collapsible';
22
+ }
23
+ export async function loadReasoningDisplay() {
24
+ return toReasoningDisplay((await getPluginSettings('telegram'))['reasoning_display']);
25
+ }
package/dist/schema.js CHANGED
@@ -4609,6 +4609,25 @@ function internalApiToken(o) {
4609
4609
  ]
4610
4610
  });
4611
4611
  }
4612
+ /**
4613
+ * Internal "connected apps" list — the OAuth clients (claude.ai & co) the user
4614
+ * has authorized for MCP access. Same "internal" caveat and custom-renderer
4615
+ * trick as internalApiToken(): read-only rows + a revoke action, never part of
4616
+ * the parent form's save.
4617
+ */
4618
+ function internalOauthGrants(o) {
4619
+ return group({
4620
+ key: o.key,
4621
+ label: o.label ?? o.key,
4622
+ multiple: true,
4623
+ view: { kind: "internalOauthGrants" },
4624
+ fields: [
4625
+ string({ key: "clientName" }),
4626
+ string({ key: "createdAt" }),
4627
+ string({ key: "lastUsedAt" })
4628
+ ]
4629
+ });
4630
+ }
4612
4631
  var SLUG_RE = /^[a-z0-9-]+$/;
4613
4632
  function slug(raw) {
4614
4633
  const o = normalizeOpts(raw);
@@ -5038,7 +5057,8 @@ var presets = {
5038
5057
  weight,
5039
5058
  dimensions,
5040
5059
  country,
5041
- internalApiToken
5060
+ internalApiToken,
5061
+ internalOauthGrants
5042
5062
  };
5043
5063
  //#endregion
5044
5064
  //#region ../../node_modules/iso-639-1/src/data.js
@@ -5865,7 +5885,7 @@ function group(o) {
5865
5885
  required: o.required,
5866
5886
  unique: r.unique,
5867
5887
  label: o.label,
5868
- icon: v.icon,
5888
+ icon: o.icon,
5869
5889
  display: v.display ?? "wrap",
5870
5890
  kind: v.kind,
5871
5891
  fixed: r.fixed,
@@ -5876,11 +5896,9 @@ function group(o) {
5876
5896
  function row(o) {
5877
5897
  return group({
5878
5898
  label: o.label,
5899
+ icon: o.icon,
5879
5900
  fields: o.fields,
5880
- view: {
5881
- ...o.view,
5882
- display: "scroll"
5883
- }
5901
+ view: { display: "scroll" }
5884
5902
  });
5885
5903
  }
5886
5904
  /** Tabular collection (array of rows → <table>, aligned by columns). Sugar. */
@@ -5888,14 +5906,12 @@ function table(o) {
5888
5906
  return group({
5889
5907
  key: o.key,
5890
5908
  label: o.label,
5909
+ icon: o.icon,
5891
5910
  fields: o.fields,
5892
5911
  multiple: true,
5893
5912
  required: o.required,
5894
5913
  rules: o.rules,
5895
- view: {
5896
- ...o.view,
5897
- display: "table"
5898
- }
5914
+ view: { display: "table" }
5899
5915
  });
5900
5916
  }
5901
5917
  /**
@@ -5911,11 +5927,9 @@ function sheet(o) {
5911
5927
  });
5912
5928
  return group({
5913
5929
  label: o.label,
5930
+ icon: o.icon,
5914
5931
  fields: flat,
5915
- view: {
5916
- ...o.view,
5917
- display: "sheet"
5918
- }
5932
+ view: { display: "sheet" }
5919
5933
  });
5920
5934
  }
5921
5935
  /**
@@ -5947,8 +5961,8 @@ function keyed(o) {
5947
5961
  return {
5948
5962
  ...(o.container ?? group)({
5949
5963
  label: o.label,
5950
- fields: [o.by, ...o.fields],
5951
- view: o.view
5964
+ icon: o.icon,
5965
+ fields: [o.by, ...o.fields]
5952
5966
  }),
5953
5967
  key: o.key,
5954
5968
  multiple: true,
@@ -6384,7 +6398,7 @@ function relation(raw) {
6384
6398
  },
6385
6399
  relation: {
6386
6400
  library: raw.options.library,
6387
- type: raw.options.shelf
6401
+ shelf: raw.options.shelf
6388
6402
  },
6389
6403
  ...multi && { json: true },
6390
6404
  zod: optionalize(s, required)
@@ -6764,15 +6778,44 @@ function period(raw) {
6764
6778
  zod: optionalize(s, required)
6765
6779
  });
6766
6780
  }
6781
+ /** Keys a file entry may carry. Everything else is rejected — see fileEntryIssue. */
6782
+ var FILE_KEYS = /* @__PURE__ */ new Set([
6783
+ "name",
6784
+ "mime",
6785
+ "size"
6786
+ ]);
6787
+ /** Keys that mean "the author pasted a remote address" — the one mistake worth naming. */
6788
+ var FILE_URL_KEYS = [
6789
+ "url",
6790
+ "src",
6791
+ "href",
6792
+ "link"
6793
+ ];
6794
+ /**
6795
+ * A file entry points at a file already uploaded to the server: `{ name }`, where
6796
+ * name is the bare filename returned by POST /api/upload. Anything else — a remote
6797
+ * URL, an extra key, a path — is refused here, so a record can never hold a
6798
+ * reference the server cannot serve. `mime`/`size` are accepted (legacy payloads
6799
+ * and the web uploader send them) but the server overwrites them from disk.
6800
+ * Returns a vmsg code, or null when the entry is well-formed.
6801
+ */
6802
+ function fileEntryIssue(it) {
6803
+ if (typeof it !== "object" || it === null || Array.isArray(it)) return "file_structure";
6804
+ const rec = it;
6805
+ if (FILE_URL_KEYS.some((k) => k in rec)) return "file_remote_url";
6806
+ const name = rec["name"];
6807
+ if (typeof name !== "string" || name === "") return "file_structure";
6808
+ if (name.includes("://") || name.startsWith("//")) return "file_remote_url";
6809
+ if (/[\\/]/.test(name) || name.includes("..")) return "file_name";
6810
+ for (const k of Object.keys(rec)) if (!FILE_KEYS.has(k)) return "file_unknown_key";
6811
+ if (rec["mime"] !== void 0 && typeof rec["mime"] !== "string") return "file_structure";
6812
+ if (rec["size"] !== void 0 && typeof rec["size"] !== "number") return "file_structure";
6813
+ return null;
6814
+ }
6767
6815
  function makeFile(kind, raw) {
6768
6816
  const o = normalizeOpts(raw);
6769
6817
  const required = o.required ?? false;
6770
6818
  const multiple = o.multiple ?? false;
6771
- const rowSchema = object({
6772
- name: string$1().min(1),
6773
- mime: string$1().optional(),
6774
- size: number$1().optional()
6775
- });
6776
6819
  const s = unknown().superRefine((raw, ctx) => {
6777
6820
  if (typeof raw === "string") try {
6778
6821
  JSON.parse(raw);
@@ -6785,12 +6828,15 @@ function makeFile(kind, raw) {
6785
6828
  }
6786
6829
  const parsed = jsonValue(raw);
6787
6830
  const items = Array.isArray(parsed) ? parsed : [parsed];
6788
- for (const it of items) if (!rowSchema.safeParse(it).success) {
6789
- ctx.addIssue({
6790
- code: ZodIssueCode.custom,
6791
- message: vmsg("file_structure")
6792
- });
6793
- return;
6831
+ for (const it of items) {
6832
+ const code = fileEntryIssue(it);
6833
+ if (code) {
6834
+ ctx.addIssue({
6835
+ code: ZodIssueCode.custom,
6836
+ message: vmsg(code)
6837
+ });
6838
+ return;
6839
+ }
6794
6840
  }
6795
6841
  });
6796
6842
  return wrapKey(o, {
@@ -7058,6 +7104,25 @@ var src_default = definePlugin({
7058
7104
  field.tel({
7059
7105
  key: "phone",
7060
7106
  label: "telegram.settings.phone"
7107
+ }),
7108
+ field.select({
7109
+ key: "reasoning_display",
7110
+ label: "telegram.settings.reasoning_display",
7111
+ default: "collapsible",
7112
+ options: [
7113
+ {
7114
+ value: "off",
7115
+ title: "telegram.settings.reasoning_display_off"
7116
+ },
7117
+ {
7118
+ value: "collapsible",
7119
+ title: "telegram.settings.reasoning_display_collapsible"
7120
+ },
7121
+ {
7122
+ value: "separate",
7123
+ title: "telegram.settings.reasoning_display_separate"
7124
+ }
7125
+ ]
7061
7126
  })
7062
7127
  ]
7063
7128
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/plugin-telegram",
3
- "version": "1.4.0",
3
+ "version": "2.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": "^1.4.0",
29
- "@coffer-org/sdk": "^1.5.0",
30
- "@coffer-org/server": "^1.8.0",
28
+ "@coffer-org/plugin-orchestrator": "^2.0.0",
29
+ "@coffer-org/sdk": "^2.0.0",
30
+ "@coffer-org/server": "^2.0.1",
31
31
  "input": "^1.0.1",
32
32
  "teleproto": "^1.228.1"
33
33
  },