@adriangalilea/utils 0.5.0 → 0.7.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/README.md +46 -0
- package/dist/bot/access-control.d.ts +339 -0
- package/dist/bot/access-control.d.ts.map +1 -0
- package/dist/bot/access-control.js +516 -0
- package/dist/bot/access-control.js.map +1 -0
- package/dist/bot/coalesce.d.ts +107 -0
- package/dist/bot/coalesce.d.ts.map +1 -0
- package/dist/bot/coalesce.js +155 -0
- package/dist/bot/coalesce.js.map +1 -0
- package/dist/bot/index.d.ts +18 -0
- package/dist/bot/index.d.ts.map +1 -0
- package/dist/bot/index.js +18 -0
- package/dist/bot/index.js.map +1 -0
- package/dist/bot/kit.d.ts +50 -0
- package/dist/bot/kit.d.ts.map +1 -0
- package/dist/bot/kit.js +52 -0
- package/dist/bot/kit.js.map +1 -0
- package/dist/bot/llm-stream.d.ts +84 -0
- package/dist/bot/llm-stream.d.ts.map +1 -0
- package/dist/bot/llm-stream.js +201 -0
- package/dist/bot/llm-stream.js.map +1 -0
- package/package.json +51 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesce client-split inbound messages.
|
|
3
|
+
*
|
|
4
|
+
* Telegram clients (Desktop / iOS / web) split a single message > 4096
|
|
5
|
+
* chars into multiple `sendMessage` calls before they ever reach the
|
|
6
|
+
* server. The bot receives them as **separate** `message` updates with
|
|
7
|
+
* no marker linking them. This middleware joins them back into one
|
|
8
|
+
* event so your handlers see the full text.
|
|
9
|
+
*
|
|
10
|
+
* user pastes 8000 chars → client splits in 2 → bot gets 2 updates
|
|
11
|
+
* │
|
|
12
|
+
* ▼
|
|
13
|
+
* coalesce middleware
|
|
14
|
+
* │
|
|
15
|
+
* hold + join
|
|
16
|
+
* │
|
|
17
|
+
* ▼
|
|
18
|
+
* handler sees ONE event
|
|
19
|
+
* with full ctx.text
|
|
20
|
+
*
|
|
21
|
+
* ## Detection rule (strict)
|
|
22
|
+
*
|
|
23
|
+
* We coalesce only when ALL hold. Otherwise fragments pass through
|
|
24
|
+
* as separate events — false negatives are preferred over silently
|
|
25
|
+
* merging unrelated messages.
|
|
26
|
+
*
|
|
27
|
+
* 1. Same chat.
|
|
28
|
+
* 2. Same user (override with `acrossUsers: true`).
|
|
29
|
+
* 3. Leading fragment length ≥ `minLeadingLength` (a current
|
|
30
|
+
* guess — see the type definition for the default and the
|
|
31
|
+
* reasoning). Short messages never start a real client split.
|
|
32
|
+
* 4. Each subsequent fragment within `windowMs` of the previous.
|
|
33
|
+
*
|
|
34
|
+
* ## Known caveats
|
|
35
|
+
*
|
|
36
|
+
* - `ctx.entities` is cleared on coalesced messages — per-fragment
|
|
37
|
+
* entity offsets would point at the wrong characters once joined.
|
|
38
|
+
* Plain-text consumers don't care; formatted-input consumers
|
|
39
|
+
* should disable this plugin.
|
|
40
|
+
* - In-memory buffer; doesn't survive bot restart mid-burst.
|
|
41
|
+
*
|
|
42
|
+
* Peer deps: `gramio`.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* import { Bot } from 'gramio'
|
|
46
|
+
* import { coalesceLongMessages } from '@adriangalilea/utils/bot/coalesce'
|
|
47
|
+
*
|
|
48
|
+
* const bot = new Bot(process.env.BOT_TOKEN!)
|
|
49
|
+
* .extend(coalesceLongMessages()) // ← before .on / .command handlers
|
|
50
|
+
* .on('message', (ctx) => {
|
|
51
|
+
* // ctx.text is the full pasted text even if Telegram split it
|
|
52
|
+
* return ctx.send(`got ${ctx.text?.length} chars`)
|
|
53
|
+
* })
|
|
54
|
+
*
|
|
55
|
+
* @example Power-user escape hatch
|
|
56
|
+
*
|
|
57
|
+
* import { isCoalescent } from '@adriangalilea/utils/bot/coalesce'
|
|
58
|
+
*
|
|
59
|
+
* if (isCoalescent(prev, curr)) {
|
|
60
|
+
* // do your own thing
|
|
61
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
import { Plugin } from 'gramio';
|
|
64
|
+
// Current guess. Real Telegram clients split at natural boundaries
|
|
65
|
+
// (newline / paragraph / sentence) before the 4096 cap, but we don't
|
|
66
|
+
// yet have a solid dataset of where they actually land. Adjust as
|
|
67
|
+
// real-world data comes in. Single source of truth — both
|
|
68
|
+
// `isCoalescent` and the middleware read from here.
|
|
69
|
+
const DEFAULT_MIN_LEADING_LENGTH = 3750;
|
|
70
|
+
const DEFAULT_WINDOW_MS = 500;
|
|
71
|
+
const DEFAULT_ACROSS_USERS = false;
|
|
72
|
+
export const isCoalescent = (prev, curr, opts = {}) => {
|
|
73
|
+
const minLeadingLength = opts.minLeadingLength ?? DEFAULT_MIN_LEADING_LENGTH;
|
|
74
|
+
const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
|
|
75
|
+
const acrossUsers = opts.acrossUsers ?? DEFAULT_ACROSS_USERS;
|
|
76
|
+
if (prev.chatId !== curr.chatId)
|
|
77
|
+
return false;
|
|
78
|
+
if (!acrossUsers && prev.userId !== curr.userId)
|
|
79
|
+
return false;
|
|
80
|
+
if (prev.text.length < minLeadingLength)
|
|
81
|
+
return false;
|
|
82
|
+
if (curr.dateMs - prev.dateMs > windowMs)
|
|
83
|
+
return false;
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
export const coalesceLongMessages = (opts = {}) => {
|
|
87
|
+
const minLeadingLength = opts.minLeadingLength ?? DEFAULT_MIN_LEADING_LENGTH;
|
|
88
|
+
const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
|
|
89
|
+
const acrossUsers = opts.acrossUsers ?? DEFAULT_ACROSS_USERS;
|
|
90
|
+
const log = opts.log ?? false;
|
|
91
|
+
const dbg = (msg) => {
|
|
92
|
+
if (log)
|
|
93
|
+
console.error(`[coalesce] ${msg}`);
|
|
94
|
+
};
|
|
95
|
+
// Keyed per `<chatId>:<userId>` (or `<chatId>` when acrossUsers).
|
|
96
|
+
// Buffer lives only as long as fragments are still arriving — gets
|
|
97
|
+
// deleted on flush.
|
|
98
|
+
const buffers = new Map();
|
|
99
|
+
const keyFor = (chatId, userId) => acrossUsers ? `${chatId}` : `${chatId}:${userId}`;
|
|
100
|
+
return new Plugin('@adriangalilea/utils/bot/coalesce').use(async (ctx, next) => {
|
|
101
|
+
if (!ctx.is('message') || ctx.text === undefined)
|
|
102
|
+
return next();
|
|
103
|
+
const key = keyFor(ctx.chat.id, ctx.from.id);
|
|
104
|
+
const existing = buffers.get(key);
|
|
105
|
+
if (existing) {
|
|
106
|
+
// Continuation fragment — fold into the held buffer and reset
|
|
107
|
+
// the timer. No length check on continuations: once a buffer
|
|
108
|
+
// is open, anything within the window joins (the tail of a
|
|
109
|
+
// split is typically short). We don't call next(); this update
|
|
110
|
+
// is swallowed. The first fragment held by `existing.flush`
|
|
111
|
+
// will eventually fire next() with the combined text.
|
|
112
|
+
dbg(`join key=${key} len=${ctx.text.length} buffered=${existing.text.length}→${existing.text.length + ctx.text.length}`);
|
|
113
|
+
clearTimeout(existing.timer);
|
|
114
|
+
existing.text += ctx.text;
|
|
115
|
+
existing.timer = setTimeout(existing.flush, windowMs);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (ctx.text.length < minLeadingLength) {
|
|
119
|
+
// Short message → can never be the leading fragment of a real
|
|
120
|
+
// client split. Zero-latency passthrough.
|
|
121
|
+
dbg(`passthrough key=${key} len=${ctx.text.length} (<${minLeadingLength})`);
|
|
122
|
+
return next();
|
|
123
|
+
}
|
|
124
|
+
// Suspicious leading fragment: hold + start the wait window.
|
|
125
|
+
// Returns a Promise that only resolves after the buffer flushes
|
|
126
|
+
// (next() of THIS ctx is called with the combined text). This
|
|
127
|
+
// keeps gramio's middleware chain awaiting until we're done.
|
|
128
|
+
dbg(`open key=${key} len=${ctx.text.length} (≥${minLeadingLength}, wait ${windowMs}ms)`);
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const buffered = {
|
|
131
|
+
text: ctx.text,
|
|
132
|
+
timer: setTimeout(() => buffered.flush(), windowMs),
|
|
133
|
+
flush: () => {
|
|
134
|
+
// Detach from the map FIRST so any fragment arriving mid-flush
|
|
135
|
+
// starts a fresh buffer instead of re-entering this one.
|
|
136
|
+
dbg(`flush key=${key} total=${buffered.text.length}`);
|
|
137
|
+
buffers.delete(key);
|
|
138
|
+
// gramio's MessageContext exposes `text` as an accessor
|
|
139
|
+
// with both `get` and `set` — assignment is the supported
|
|
140
|
+
// way to override. We don't touch `entities`: fragment-1
|
|
141
|
+
// entities reference fragment-1 text only, but plain-text
|
|
142
|
+
// consumers (the sensible use of this plugin) ignore them.
|
|
143
|
+
// Formatted-input consumers should disable the plugin.
|
|
144
|
+
ctx.text = buffered.text;
|
|
145
|
+
// Hand off to the rest of the chain. Resolve outer Promise
|
|
146
|
+
// once the chain (and any downstream awaits) settles, so
|
|
147
|
+
// gramio considers this middleware fully done.
|
|
148
|
+
Promise.resolve(next()).then(() => resolve(), reject);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
buffers.set(key, buffered);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
//# sourceMappingURL=coalesce.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"coalesce.js","sourceRoot":"","sources":["../../src/bot/coalesce.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram bot plugins for GramIO. Imported as subpaths so this module
|
|
3
|
+
* has zero footprint for consumers that don't use them.
|
|
4
|
+
*
|
|
5
|
+
* Peer deps (all optional): `gramio`, `@gramio/format`, `@gramio/storage`, `marked`.
|
|
6
|
+
*
|
|
7
|
+
* import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
|
|
8
|
+
* import { accessControl } from '@adriangalilea/utils/bot/access-control'
|
|
9
|
+
* import { llmStream } from '@adriangalilea/utils/bot/llm-stream'
|
|
10
|
+
*
|
|
11
|
+
* Or all-in-one (pulls every subpath):
|
|
12
|
+
* import { ... } from '@adriangalilea/utils/bot'
|
|
13
|
+
*/
|
|
14
|
+
export * from './kit.js';
|
|
15
|
+
export * from './access-control.js';
|
|
16
|
+
export * from './coalesce.js';
|
|
17
|
+
export * from './llm-stream.js';
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,cAAc,UAAU,CAAA;AACxB,cAAc,qBAAqB,CAAA;AACnC,cAAc,eAAe,CAAA;AAC7B,cAAc,iBAAiB,CAAA"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram bot plugins for GramIO. Imported as subpaths so this module
|
|
3
|
+
* has zero footprint for consumers that don't use them.
|
|
4
|
+
*
|
|
5
|
+
* Peer deps (all optional): `gramio`, `@gramio/format`, `@gramio/storage`, `marked`.
|
|
6
|
+
*
|
|
7
|
+
* import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
|
|
8
|
+
* import { accessControl } from '@adriangalilea/utils/bot/access-control'
|
|
9
|
+
* import { llmStream } from '@adriangalilea/utils/bot/llm-stream'
|
|
10
|
+
*
|
|
11
|
+
* Or all-in-one (pulls every subpath):
|
|
12
|
+
* import { ... } from '@adriangalilea/utils/bot'
|
|
13
|
+
*/
|
|
14
|
+
export * from './kit.js';
|
|
15
|
+
export * from './access-control.js';
|
|
16
|
+
export * from './coalesce.js';
|
|
17
|
+
export * from './llm-stream.js';
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,cAAc,UAAU,CAAA;AACxB,cAAc,qBAAqB,CAAA;AACnC,cAAc,eAAe,CAAA;AAC7B,cAAc,iBAAiB,CAAA"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foundational helpers every bot wants. Two things:
|
|
3
|
+
*
|
|
4
|
+
* `gracefulStart(bot, opts?)` — wires SIGINT/SIGTERM to bot.stop(),
|
|
5
|
+
* runs an optional shutdown hook, force-kills if it hangs.
|
|
6
|
+
*
|
|
7
|
+
* `adminContext({ adminId? })` — reads admin Telegram id from KEV
|
|
8
|
+
* (`TELEGRAM_ADMIN_ID`) with optional hardcoded fallback. Decorates
|
|
9
|
+
* every context with `ctx.adminId` (number) and `ctx.isAdmin`
|
|
10
|
+
* (boolean). Throws at startup if neither source provides an id.
|
|
11
|
+
*
|
|
12
|
+
* Peer deps: `gramio`.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { Bot } from 'gramio'
|
|
16
|
+
* import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
|
|
17
|
+
*
|
|
18
|
+
* const bot = new Bot(process.env.BOT_TOKEN!)
|
|
19
|
+
* .extend(adminContext({ adminId: 190202471 })) // KEV wins, 190… is fallback
|
|
20
|
+
* .command('whoami', (ctx) => ctx.send(`admin? ${ctx.isAdmin}`))
|
|
21
|
+
*
|
|
22
|
+
* await gracefulStart(bot, { onShutdown: () => db.end() })
|
|
23
|
+
*/
|
|
24
|
+
import type { AnyBot } from 'gramio';
|
|
25
|
+
import { Plugin } from 'gramio';
|
|
26
|
+
export type GracefulStartOptions = {
|
|
27
|
+
/** Runs after `bot.stop()` resolves, before `process.exit`. Close DBs, flush logs. */
|
|
28
|
+
onShutdown?: () => Promise<void> | void;
|
|
29
|
+
/** Process exit code on graceful shutdown. Default 0. */
|
|
30
|
+
exitCode?: number;
|
|
31
|
+
/** Hard-kill after this many ms if shutdown hangs. Default 10000. */
|
|
32
|
+
forceExitAfterMs?: number;
|
|
33
|
+
/** Logger. Default `console.log`. Set `false` to silence. */
|
|
34
|
+
log?: ((msg: string) => void) | false;
|
|
35
|
+
};
|
|
36
|
+
export declare const gracefulStart: (bot: AnyBot, opts?: GracefulStartOptions) => Promise<void>;
|
|
37
|
+
export type AdminContextOptions = {
|
|
38
|
+
/** Hardcoded fallback used when `KEV.TELEGRAM_ADMIN_ID` is unset. */
|
|
39
|
+
adminId?: number;
|
|
40
|
+
};
|
|
41
|
+
export declare const adminContext: (opts?: AdminContextOptions) => Plugin<{}, import("gramio").DeriveDefinitions & {
|
|
42
|
+
global: {
|
|
43
|
+
adminId: number;
|
|
44
|
+
};
|
|
45
|
+
} & {
|
|
46
|
+
global: {
|
|
47
|
+
isAdmin: boolean;
|
|
48
|
+
};
|
|
49
|
+
}, {}>;
|
|
50
|
+
//# sourceMappingURL=kit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kit.d.ts","sourceRoot":"","sources":["../../src/bot/kit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACpC,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAK/B,MAAM,MAAM,oBAAoB,GAAG;IACjC,sFAAsF;IACtF,UAAU,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;IACvC,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qEAAqE;IACrE,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,6DAA6D;IAC7D,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,KAAK,CAAA;CACtC,CAAA;AAED,eAAO,MAAM,aAAa,GACxB,KAAK,MAAM,EACX,OAAM,oBAAyB,KAC9B,OAAO,CAAC,IAAI,CAiCd,CAAA;AAID,MAAM,MAAM,mBAAmB,GAAG;IAChC,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,OAAM,mBAAwB;;;;;;;;MAqB1D,CAAA"}
|
package/dist/bot/kit.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Plugin } from 'gramio';
|
|
2
|
+
import { kev } from '../platform/kev.js';
|
|
3
|
+
export const gracefulStart = async (bot, opts = {}) => {
|
|
4
|
+
const log = opts.log === false ? () => { } : (opts.log ?? ((m) => console.log(m)));
|
|
5
|
+
const forceMs = opts.forceExitAfterMs ?? 10_000;
|
|
6
|
+
let stopping = false;
|
|
7
|
+
const stop = async (signal) => {
|
|
8
|
+
if (stopping)
|
|
9
|
+
return;
|
|
10
|
+
stopping = true;
|
|
11
|
+
log(`[bot] ${signal} received, shutting down…`);
|
|
12
|
+
const force = setTimeout(() => {
|
|
13
|
+
console.error(`[bot] forced exit after ${forceMs}ms`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}, forceMs);
|
|
16
|
+
force.unref?.();
|
|
17
|
+
try {
|
|
18
|
+
await bot.stop();
|
|
19
|
+
await opts.onShutdown?.();
|
|
20
|
+
log('[bot] shutdown clean');
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
console.error('[bot] shutdown error', e);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
clearTimeout(force);
|
|
27
|
+
process.exit(opts.exitCode ?? 0);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
process.on('SIGINT', () => void stop('SIGINT'));
|
|
31
|
+
process.on('SIGTERM', () => void stop('SIGTERM'));
|
|
32
|
+
await bot.start();
|
|
33
|
+
};
|
|
34
|
+
export const adminContext = (opts = {}) => {
|
|
35
|
+
// KEV resolves: process.env → .env (project + monorepo, auto-discovered) → fallback.
|
|
36
|
+
// Cached after first read. `kev.int` panics on non-int strings, so a malformed
|
|
37
|
+
// env var screams immediately rather than producing NaN downstream.
|
|
38
|
+
const adminId = kev.int('TELEGRAM_ADMIN_ID', opts.adminId ?? 0);
|
|
39
|
+
if (!adminId) {
|
|
40
|
+
throw new Error('adminContext: TELEGRAM_ADMIN_ID not set and no adminId fallback. ' +
|
|
41
|
+
'Get your Telegram id from @UserIDentifyBot.');
|
|
42
|
+
}
|
|
43
|
+
return new Plugin('@adriangalilea/utils/bot/admin')
|
|
44
|
+
.decorate({ adminId })
|
|
45
|
+
.derive((ctx) => ({
|
|
46
|
+
// `senderId` is provided by gramio's SenderMixin. It's `undefined` on
|
|
47
|
+
// service-style events without an actor; the strict equality below
|
|
48
|
+
// gives `false` in that case, which is the right answer.
|
|
49
|
+
isAdmin: 'senderId' in ctx && ctx.senderId === adminId,
|
|
50
|
+
}));
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=kit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kit.js","sourceRoot":"","sources":["../../src/bot/kit.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,EAAE,GAAG,EAAE,MAAM,oBAAoB,CAAA;AAexC,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAChC,GAAW,EACX,OAA6B,EAAE,EAChB,EAAE;IACjB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACjF,MAAM,OAAO,GAAG,IAAI,CAAC,gBAAgB,IAAI,MAAM,CAAA;IAE/C,IAAI,QAAQ,GAAG,KAAK,CAAA;IAEpB,MAAM,IAAI,GAAG,KAAK,EAAE,MAAc,EAAE,EAAE;QACpC,IAAI,QAAQ;YAAE,OAAM;QACpB,QAAQ,GAAG,IAAI,CAAA;QACf,GAAG,CAAC,SAAS,MAAM,2BAA2B,CAAC,CAAA;QAE/C,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,OAAO,CAAC,KAAK,CAAC,2BAA2B,OAAO,IAAI,CAAC,CAAA;YACrD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACjB,CAAC,EAAE,OAAO,CAAC,CAAA;QACX,KAAK,CAAC,KAAK,EAAE,EAAE,CAAA;QAEf,IAAI,CAAC;YACH,MAAM,GAAG,CAAC,IAAI,EAAE,CAAA;YAChB,MAAM,IAAI,CAAC,UAAU,EAAE,EAAE,CAAA;YACzB,GAAG,CAAC,sBAAsB,CAAC,CAAA;QAC7B,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,CAAC,KAAK,CAAC,sBAAsB,EAAE,CAAC,CAAC,CAAA;QAC1C,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,KAAK,CAAC,CAAA;YACnB,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAA;QAClC,CAAC;IACH,CAAC,CAAA;IAED,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAA;IAC/C,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC,SAAS,CAAC,CAAC,CAAA;IAEjD,MAAM,GAAG,CAAC,KAAK,EAAE,CAAA;AACnB,CAAC,CAAA;AASD,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,OAA4B,EAAE,EAAE,EAAE;IAC7D,qFAAqF;IACrF,+EAA+E;IAC/E,oEAAoE;IACpE,MAAM,OAAO,GAAG,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAI,CAAC,OAAO,IAAI,CAAC,CAAC,CAAA;IAE/D,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,mEAAmE;YACjE,6CAA6C,CAChD,CAAA;IACH,CAAC;IAED,OAAO,IAAI,MAAM,CAAC,gCAAgC,CAAC;SAChD,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;SACrB,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QAChB,sEAAsE;QACtE,mEAAmE;QACnE,yDAAyD;QACzD,OAAO,EAAE,UAAU,IAAI,GAAG,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;KACvD,CAAC,CAAC,CAAA;AACP,CAAC,CAAA"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM streaming output for GramIO bots.
|
|
3
|
+
*
|
|
4
|
+
* Sends a placeholder, then debounces `editMessageText` calls as the
|
|
5
|
+
* LLM produces chunks. Markdown is parsed locally with
|
|
6
|
+
* `markdownToFormattable` — invalid markup degrades to plain text
|
|
7
|
+
* instead of failing (Telegram's `parse_mode` would reject the whole
|
|
8
|
+
* message). Splits at 4000 chars by promoting the next chunk to a
|
|
9
|
+
* fresh message at a paragraph/line/word boundary.
|
|
10
|
+
*
|
|
11
|
+
* Peer deps: `gramio`, `@gramio/format`, `marked`.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { Bot } from 'gramio'
|
|
15
|
+
* import { llmStream } from '@adriangalilea/utils/bot/llm-stream'
|
|
16
|
+
* import Anthropic from '@anthropic-ai/sdk'
|
|
17
|
+
*
|
|
18
|
+
* const claude = new Anthropic()
|
|
19
|
+
* const bot = new Bot(process.env.BOT_TOKEN!)
|
|
20
|
+
* .extend(llmStream())
|
|
21
|
+
* .on('message', async (ctx) => {
|
|
22
|
+
* const stream = ctx.startStream()
|
|
23
|
+
* const sse = await claude.messages.stream({
|
|
24
|
+
* model: 'claude-opus-4-5',
|
|
25
|
+
* max_tokens: 1024,
|
|
26
|
+
* messages: [{ role: 'user', content: ctx.text ?? '' }],
|
|
27
|
+
* })
|
|
28
|
+
* for await (const chunk of sse) {
|
|
29
|
+
* if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
|
30
|
+
* await stream.append(chunk.delta.text)
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* await stream.end()
|
|
34
|
+
* })
|
|
35
|
+
*
|
|
36
|
+
* bot.start()
|
|
37
|
+
*/
|
|
38
|
+
import { Plugin } from 'gramio';
|
|
39
|
+
export type StreamOptions = {
|
|
40
|
+
/** Debounce window between edits, in ms. Default 800. */
|
|
41
|
+
debounceMs?: number;
|
|
42
|
+
/** Initial placeholder shown until the first chunk arrives. Default "…". */
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
/** Parse buffer as markdown. Default true. Set false for plain text streaming. */
|
|
45
|
+
markdown?: boolean;
|
|
46
|
+
/** Called on edit/send errors after internal recovery (rate limits, etc.). */
|
|
47
|
+
onError?: (err: unknown) => void;
|
|
48
|
+
};
|
|
49
|
+
export declare class MarkdownStreamer {
|
|
50
|
+
private buffer;
|
|
51
|
+
private currentMessageId?;
|
|
52
|
+
private firstSendPromise?;
|
|
53
|
+
private debounceTimer?;
|
|
54
|
+
private inFlight;
|
|
55
|
+
private dirty;
|
|
56
|
+
private ended;
|
|
57
|
+
private chatId;
|
|
58
|
+
private bot;
|
|
59
|
+
private opts;
|
|
60
|
+
constructor(ctx: {
|
|
61
|
+
chat: {
|
|
62
|
+
id: number;
|
|
63
|
+
};
|
|
64
|
+
bot: MarkdownStreamer['bot'];
|
|
65
|
+
}, opts: StreamOptions);
|
|
66
|
+
/** Append a chunk. Schedules a debounced edit. */
|
|
67
|
+
append(text: string): Promise<void>;
|
|
68
|
+
/** Flush any pending edit and close the stream. Idempotent. */
|
|
69
|
+
end(): Promise<void>;
|
|
70
|
+
private scheduleFlush;
|
|
71
|
+
private flushNow;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* GramIO plugin. Adds `ctx.startStream(opts?)` on every message context.
|
|
75
|
+
*
|
|
76
|
+
* Defaults set here apply to every stream; per-call options in
|
|
77
|
+
* `ctx.startStream({...})` override them.
|
|
78
|
+
*/
|
|
79
|
+
export declare const llmStream: (defaults?: StreamOptions) => Plugin<{}, import("gramio").DeriveDefinitions & {
|
|
80
|
+
message: {
|
|
81
|
+
startStream: (opts?: StreamOptions) => MarkdownStreamer;
|
|
82
|
+
};
|
|
83
|
+
}, {}>;
|
|
84
|
+
//# sourceMappingURL=llm-stream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm-stream.d.ts","sourceRoot":"","sources":["../../src/bot/llm-stream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAM/B,MAAM,MAAM,aAAa,GAAG;IAC1B,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kFAAkF;IAClF,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;CACjC,CAAA;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,gBAAgB,CAAC,CAAQ;IACjC,OAAO,CAAC,gBAAgB,CAAC,CAAe;IACxC,OAAO,CAAC,aAAa,CAAC,CAA+B;IACrD,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,KAAK,CAAQ;IAErB,OAAO,CAAC,MAAM,CAAQ;IAItB,OAAO,CAAC,GAAG,CAYV;IACD,OAAO,CAAC,IAAI,CAAyB;gBAGnC,GAAG,EAAE;QAAE,IAAI,EAAE;YAAE,EAAE,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,GAAG,EAAE,gBAAgB,CAAC,KAAK,CAAC,CAAA;KAAE,EAC3D,IAAI,EAAE,aAAa;IAYrB,kDAAkD;IAC5C,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA8CzC,+DAA+D;IACzD,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IAW1B,OAAO,CAAC,aAAa;YAaP,QAAQ;CAmCvB;AAED;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,WAAU,aAAkB;;6BAI9B,aAAa;;MAEhC,CAAA"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM streaming output for GramIO bots.
|
|
3
|
+
*
|
|
4
|
+
* Sends a placeholder, then debounces `editMessageText` calls as the
|
|
5
|
+
* LLM produces chunks. Markdown is parsed locally with
|
|
6
|
+
* `markdownToFormattable` — invalid markup degrades to plain text
|
|
7
|
+
* instead of failing (Telegram's `parse_mode` would reject the whole
|
|
8
|
+
* message). Splits at 4000 chars by promoting the next chunk to a
|
|
9
|
+
* fresh message at a paragraph/line/word boundary.
|
|
10
|
+
*
|
|
11
|
+
* Peer deps: `gramio`, `@gramio/format`, `marked`.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { Bot } from 'gramio'
|
|
15
|
+
* import { llmStream } from '@adriangalilea/utils/bot/llm-stream'
|
|
16
|
+
* import Anthropic from '@anthropic-ai/sdk'
|
|
17
|
+
*
|
|
18
|
+
* const claude = new Anthropic()
|
|
19
|
+
* const bot = new Bot(process.env.BOT_TOKEN!)
|
|
20
|
+
* .extend(llmStream())
|
|
21
|
+
* .on('message', async (ctx) => {
|
|
22
|
+
* const stream = ctx.startStream()
|
|
23
|
+
* const sse = await claude.messages.stream({
|
|
24
|
+
* model: 'claude-opus-4-5',
|
|
25
|
+
* max_tokens: 1024,
|
|
26
|
+
* messages: [{ role: 'user', content: ctx.text ?? '' }],
|
|
27
|
+
* })
|
|
28
|
+
* for await (const chunk of sse) {
|
|
29
|
+
* if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
|
30
|
+
* await stream.append(chunk.delta.text)
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* await stream.end()
|
|
34
|
+
* })
|
|
35
|
+
*
|
|
36
|
+
* bot.start()
|
|
37
|
+
*/
|
|
38
|
+
import { Plugin } from 'gramio';
|
|
39
|
+
import { markdownToFormattable } from '@gramio/format/markdown';
|
|
40
|
+
const MAX_LEN = 4000; // Telegram caps at 4096; leave headroom for entity offsets
|
|
41
|
+
const DEFAULT_DEBOUNCE_MS = 800;
|
|
42
|
+
export class MarkdownStreamer {
|
|
43
|
+
buffer = '';
|
|
44
|
+
currentMessageId;
|
|
45
|
+
firstSendPromise;
|
|
46
|
+
debounceTimer;
|
|
47
|
+
inFlight = false;
|
|
48
|
+
dirty = false;
|
|
49
|
+
ended = false;
|
|
50
|
+
chatId;
|
|
51
|
+
// Match gramio's `bot.api` shape structurally. `text` accepts string or any
|
|
52
|
+
// `Formattable` (from `@gramio/format`) — both stringify safely. We don't
|
|
53
|
+
// import gramio's full Bot type to keep the streamer testable in isolation.
|
|
54
|
+
bot;
|
|
55
|
+
opts;
|
|
56
|
+
constructor(ctx, opts) {
|
|
57
|
+
this.chatId = ctx.chat.id;
|
|
58
|
+
this.bot = ctx.bot;
|
|
59
|
+
this.opts = {
|
|
60
|
+
debounceMs: opts.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
61
|
+
placeholder: opts.placeholder ?? '…',
|
|
62
|
+
markdown: opts.markdown ?? true,
|
|
63
|
+
onError: opts.onError ?? ((e) => console.error('[llm-stream]', e)),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Append a chunk. Schedules a debounced edit. */
|
|
67
|
+
async append(text) {
|
|
68
|
+
if (this.ended)
|
|
69
|
+
throw new Error('stream already ended');
|
|
70
|
+
if (!text)
|
|
71
|
+
return;
|
|
72
|
+
// first chunk: send the placeholder so we have a message_id to edit.
|
|
73
|
+
// Serialized via firstSendPromise so concurrent appends don't double-send.
|
|
74
|
+
if (this.currentMessageId === undefined && !this.firstSendPromise) {
|
|
75
|
+
this.firstSendPromise = (async () => {
|
|
76
|
+
const sent = await this.bot.api.sendMessage({
|
|
77
|
+
chat_id: this.chatId,
|
|
78
|
+
text: this.opts.placeholder,
|
|
79
|
+
});
|
|
80
|
+
this.currentMessageId = sent.message_id;
|
|
81
|
+
})();
|
|
82
|
+
}
|
|
83
|
+
if (this.firstSendPromise)
|
|
84
|
+
await this.firstSendPromise;
|
|
85
|
+
// overflow: freeze current message at last good split, start a new one.
|
|
86
|
+
const next = this.buffer + text;
|
|
87
|
+
if (next.length > MAX_LEN) {
|
|
88
|
+
const splitAt = findSplit(next, MAX_LEN);
|
|
89
|
+
const head = next.slice(0, splitAt);
|
|
90
|
+
const tail = next.slice(splitAt).trimStart();
|
|
91
|
+
this.buffer = head;
|
|
92
|
+
this.dirty = true;
|
|
93
|
+
await this.flushNow(); // commits head into current message
|
|
94
|
+
this.buffer = '';
|
|
95
|
+
this.currentMessageId = undefined;
|
|
96
|
+
this.firstSendPromise = undefined;
|
|
97
|
+
this.dirty = false;
|
|
98
|
+
if (this.debounceTimer) {
|
|
99
|
+
clearTimeout(this.debounceTimer);
|
|
100
|
+
this.debounceTimer = undefined;
|
|
101
|
+
}
|
|
102
|
+
if (tail)
|
|
103
|
+
await this.append(tail);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.buffer = next;
|
|
107
|
+
this.dirty = true;
|
|
108
|
+
this.scheduleFlush();
|
|
109
|
+
}
|
|
110
|
+
/** Flush any pending edit and close the stream. Idempotent. */
|
|
111
|
+
async end() {
|
|
112
|
+
if (this.ended)
|
|
113
|
+
return;
|
|
114
|
+
this.ended = true;
|
|
115
|
+
if (this.debounceTimer) {
|
|
116
|
+
clearTimeout(this.debounceTimer);
|
|
117
|
+
this.debounceTimer = undefined;
|
|
118
|
+
}
|
|
119
|
+
while (this.inFlight)
|
|
120
|
+
await sleep(50);
|
|
121
|
+
if (this.dirty)
|
|
122
|
+
await this.flushNow();
|
|
123
|
+
}
|
|
124
|
+
scheduleFlush() {
|
|
125
|
+
if (this.debounceTimer)
|
|
126
|
+
return;
|
|
127
|
+
this.debounceTimer = setTimeout(async () => {
|
|
128
|
+
this.debounceTimer = undefined;
|
|
129
|
+
if (this.inFlight) {
|
|
130
|
+
this.scheduleFlush();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await this.flushNow();
|
|
134
|
+
if (this.dirty && !this.ended)
|
|
135
|
+
this.scheduleFlush();
|
|
136
|
+
}, this.opts.debounceMs);
|
|
137
|
+
}
|
|
138
|
+
async flushNow() {
|
|
139
|
+
if (!this.dirty)
|
|
140
|
+
return;
|
|
141
|
+
if (this.currentMessageId === undefined) {
|
|
142
|
+
// No active message yet — likely the first send is still in flight
|
|
143
|
+
// or the recovery path cleared it. Caller's next append() will
|
|
144
|
+
// re-create one.
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.inFlight = true;
|
|
148
|
+
this.dirty = false;
|
|
149
|
+
const snapshot = this.buffer;
|
|
150
|
+
try {
|
|
151
|
+
const payload = this.opts.markdown ? markdownToFormattable(snapshot) : snapshot;
|
|
152
|
+
await this.bot.api.editMessageText({
|
|
153
|
+
chat_id: this.chatId,
|
|
154
|
+
message_id: this.currentMessageId,
|
|
155
|
+
text: payload,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
const msg = String(e?.message ?? e);
|
|
160
|
+
if (msg.includes('message is not modified')) {
|
|
161
|
+
// identical content — fine
|
|
162
|
+
}
|
|
163
|
+
else if (msg.includes('message to edit not found')) {
|
|
164
|
+
// message gone (deleted by user, etc.) — restart with a fresh send
|
|
165
|
+
this.currentMessageId = undefined;
|
|
166
|
+
this.firstSendPromise = undefined;
|
|
167
|
+
this.dirty = true;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
this.dirty = true;
|
|
171
|
+
this.opts.onError(e);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
this.inFlight = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* GramIO plugin. Adds `ctx.startStream(opts?)` on every message context.
|
|
181
|
+
*
|
|
182
|
+
* Defaults set here apply to every stream; per-call options in
|
|
183
|
+
* `ctx.startStream({...})` override them.
|
|
184
|
+
*/
|
|
185
|
+
export const llmStream = (defaults = {}) => new Plugin('@adriangalilea/utils/bot/llm-stream').derive('message', (ctx) => ({
|
|
186
|
+
// gramio's `message` scope guarantees `ctx.chat`. `ctx.bot.api` is on
|
|
187
|
+
// every Context. Structural compat → no cast needed.
|
|
188
|
+
startStream: (opts = {}) => new MarkdownStreamer(ctx, { ...defaults, ...opts }),
|
|
189
|
+
}));
|
|
190
|
+
function findSplit(text, maxLen) {
|
|
191
|
+
// Prefer paragraph break, then line, then space. Reject splits in the
|
|
192
|
+
// first half — better to truncate at maxLen than to leave a stub.
|
|
193
|
+
for (const sep of ['\n\n', '\n', ' ']) {
|
|
194
|
+
const idx = text.lastIndexOf(sep, maxLen);
|
|
195
|
+
if (idx > maxLen / 2)
|
|
196
|
+
return idx;
|
|
197
|
+
}
|
|
198
|
+
return maxLen;
|
|
199
|
+
}
|
|
200
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
201
|
+
//# sourceMappingURL=llm-stream.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm-stream.js","sourceRoot":"","sources":["../../src/bot/llm-stream.ts"],"names":[],"mappings":"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"}
|