nebula-ai-plugin-telegram 0.1.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 ADDED
@@ -0,0 +1,31 @@
1
+ # nebula-ai-plugin-telegram
2
+
3
+ Telegram channel for **nebula**. DM your bot from any phone; the agent replies —
4
+ with the **same** policy → simulation → approval gates, surfaced as inline-keyboard
5
+ approvals so you can authorize material-risk actions from your pocket.
6
+
7
+ ## Highlights
8
+
9
+ - **Long-poll outbound only** — works without exposing an inbound port.
10
+ - **Allowlisted DMs** — only configured `allowedUserIds` reach the brain.
11
+ - **Reactions as feedback** — 👀 on processing start, 👍 on success, 👎 on error.
12
+ - **Per-chat debounce** — a 600ms quiet window collapses fragmented typing into one turn.
13
+ - **Rate-limited** — 30 messages / 60s per user via token bucket.
14
+ - **Inline-keyboard approvals** — the operator approves risky tool calls from their phone.
15
+
16
+ ## Quickstart
17
+
18
+ ```bash
19
+ # either: set TELEGRAM_BOT_TOKEN (+ optional TELEGRAM_CHAT_ID) in your env
20
+ # or: nebula telegram setup # one-time interactive: bot token + allowed user IDs
21
+ nebula # start the TUI; the listener boots automatically
22
+ # DM your bot from your phone — the agent replies.
23
+ ```
24
+
25
+ ## Install
26
+
27
+ Auto-installed with [`nebula-treasury`](https://www.npmjs.com/package/nebula-treasury).
28
+ Or directly: `bun add nebula-ai-plugin-telegram`.
29
+
30
+ Built on [grammy](https://grammy.dev) (TS-first, bun-compatible). See the
31
+ [root README](https://github.com/rstfulzz/nebula#readme).
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "nebula-ai-plugin-telegram",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Telegram channel for nebula — long-poll bot, debounced dispatch, inline-keyboard approvals, allowlist",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/rstfulzz/nebula",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rstfulzz/nebula.git",
11
+ "directory": "packages/plugin-telegram"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/rstfulzz/nebula/issues"
15
+ },
16
+ "keywords": [
17
+ "nebula",
18
+ "ai",
19
+ "agent",
20
+ "telegram",
21
+ "grammy",
22
+ "plugin"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "engines": {
28
+ "bun": ">=1.1"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "!src/**/*.test.ts",
33
+ "README.md"
34
+ ],
35
+ "main": "./src/index.ts",
36
+ "types": "./src/index.ts",
37
+ "scripts": {
38
+ "build": "tsc -b",
39
+ "test": "bun test"
40
+ },
41
+ "dependencies": {
42
+ "nebula-ai-core": "0.1.0",
43
+ "grammy": "^1.42.0",
44
+ "zod": "^3.23.8"
45
+ }
46
+ }
@@ -0,0 +1,108 @@
1
+ // Inline-keyboard approval for tool calls when the active session is on TG.
2
+ //
3
+ // Pattern from hermes telegram.py:1080-1132 (button layout) + 1462-1473
4
+ // (callback re-validation). 4 buttons in 2 rows: Once / Session / Always /
5
+ // Deny. Callback data format: `ea:<once|session|always|deny>:<approvalId>`.
6
+ //
7
+ // The handler re-validates the clicker against `allowedUserIds` because
8
+ // inline-keyboard buttons are visible to any chat-member but only authorized
9
+ // users may click. One-shot pop pattern: the resolver Map drops the entry
10
+ // after the first match so a stale double-click can't re-resolve.
11
+
12
+ import type { InlineKeyboardMarkup } from 'grammy/types'
13
+
14
+ export type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
15
+
16
+ export const APPROVAL_CALLBACK_PREFIX = 'ea'
17
+
18
+ export function buildApprovalKeyboard(approvalId: string): InlineKeyboardMarkup {
19
+ return {
20
+ inline_keyboard: [
21
+ [
22
+ { text: '✅ Allow Once', callback_data: makeCallbackData('once', approvalId) },
23
+ { text: '✅ Session', callback_data: makeCallbackData('session', approvalId) },
24
+ ],
25
+ [
26
+ { text: '✅ Always', callback_data: makeCallbackData('always', approvalId) },
27
+ { text: '❌ Deny', callback_data: makeCallbackData('deny', approvalId) },
28
+ ],
29
+ ],
30
+ }
31
+ }
32
+
33
+ function makeCallbackData(choice: ApprovalChoice, approvalId: string): string {
34
+ // 64-byte callback_data limit — approvalId must stay short. We use a
35
+ // monotonic counter (e.g. `a-12345`) so well within budget.
36
+ return `${APPROVAL_CALLBACK_PREFIX}:${choice}:${approvalId}`
37
+ }
38
+
39
+ export interface ParsedCallback {
40
+ choice: ApprovalChoice
41
+ approvalId: string
42
+ }
43
+
44
+ export function parseCallbackData(data: string | undefined): ParsedCallback | null {
45
+ if (!data) return null
46
+ const parts = data.split(':')
47
+ if (parts.length !== 3) return null
48
+ if (parts[0] !== APPROVAL_CALLBACK_PREFIX) return null
49
+ const choice = parts[1]
50
+ const approvalId = parts[2]
51
+ if (
52
+ !approvalId ||
53
+ (choice !== 'once' && choice !== 'session' && choice !== 'always' && choice !== 'deny')
54
+ ) {
55
+ return null
56
+ }
57
+ return { choice: choice as ApprovalChoice, approvalId }
58
+ }
59
+
60
+ export type ResolveOutcome =
61
+ | { kind: 'resolved'; approvalId: string; choice: ApprovalChoice; clicker: number }
62
+ | { kind: 'unauthorized'; approvalId: string; clicker: number }
63
+ | { kind: 'unknown-approval'; approvalId: string; clicker: number }
64
+ | { kind: 'malformed' }
65
+
66
+ export interface HandleCallbackInput {
67
+ callbackData: string | undefined
68
+ fromUserId: number
69
+ allowedUserIds: number[]
70
+ pendingApprovals: Map<string, (choice: ApprovalChoice) => void>
71
+ }
72
+
73
+ /**
74
+ * Decide what to do with a `callback_query`. The bot handler should call this
75
+ * pure function then `answerCallbackQuery` based on the outcome.
76
+ */
77
+ export function handleApprovalCallback(input: HandleCallbackInput): ResolveOutcome {
78
+ const parsed = parseCallbackData(input.callbackData)
79
+ if (!parsed) return { kind: 'malformed' }
80
+
81
+ if (input.allowedUserIds.length > 0 && !input.allowedUserIds.includes(input.fromUserId)) {
82
+ return { kind: 'unauthorized', approvalId: parsed.approvalId, clicker: input.fromUserId }
83
+ }
84
+
85
+ const resolver = input.pendingApprovals.get(parsed.approvalId)
86
+ if (!resolver) {
87
+ return { kind: 'unknown-approval', approvalId: parsed.approvalId, clicker: input.fromUserId }
88
+ }
89
+
90
+ // One-shot pop closes the race against double-clicks
91
+ input.pendingApprovals.delete(parsed.approvalId)
92
+ resolver(parsed.choice)
93
+ return {
94
+ kind: 'resolved',
95
+ approvalId: parsed.approvalId,
96
+ choice: parsed.choice,
97
+ clicker: input.fromUserId,
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Mint a new approval id. Monotonic counter as a string so callback_data
103
+ * stays short. Caller seeds and increments.
104
+ */
105
+ export function makeApprovalIdFactory(): () => string {
106
+ let next = 1
107
+ return () => `a-${next++}`
108
+ }
@@ -0,0 +1,63 @@
1
+ // Long-message chunking with (1/N) (2/N) suffix.
2
+ //
3
+ // Pattern from hermes telegram.py:829-836. Telegram's hard limit is 4096
4
+ // characters per message. We split at 4000 (leave room for the suffix) and
5
+ // attach `(1/N)` `(2/N)` `(N/N)` to each chunk. We avoid breaking inside
6
+ // fenced code blocks so the runtime grammar stays intact across chunks.
7
+
8
+ const DEFAULT_MAX_LEN = 4000
9
+
10
+ export interface SplitOpts {
11
+ maxLen?: number
12
+ /** Add `(1/N)` suffixes to multi-chunk output. Default true. */
13
+ numbered?: boolean
14
+ }
15
+
16
+ export function splitMessage(text: string, opts: SplitOpts = {}): string[] {
17
+ const maxLen = opts.maxLen ?? DEFAULT_MAX_LEN
18
+ const numbered = opts.numbered ?? true
19
+
20
+ if (text.length <= maxLen) return [text]
21
+
22
+ const chunks: string[] = []
23
+ let cursor = 0
24
+ while (cursor < text.length) {
25
+ let end = Math.min(cursor + maxLen, text.length)
26
+
27
+ // Avoid splitting inside a fenced code block — if there's an unclosed
28
+ // ``` between cursor and end, back up to the last newline before end.
29
+ if (end < text.length) {
30
+ const segment = text.slice(cursor, end)
31
+ const fencesInSegment = (segment.match(/```/g) || []).length
32
+ if (fencesInSegment % 2 === 1) {
33
+ const lastNewline = text.lastIndexOf('\n', end - 1)
34
+ if (lastNewline > cursor) end = lastNewline
35
+ } else {
36
+ // Prefer to split on word boundary when possible
37
+ const lastSpace = text.lastIndexOf(' ', end)
38
+ const lastNewline = text.lastIndexOf('\n', end)
39
+ const splitAt = Math.max(lastSpace, lastNewline)
40
+ if (splitAt > cursor + Math.floor(maxLen / 2)) {
41
+ end = splitAt
42
+ }
43
+ }
44
+ }
45
+
46
+ chunks.push(text.slice(cursor, end))
47
+ cursor = end
48
+ // Skip the leading whitespace at the new cursor (we split on it)
49
+ while (cursor < text.length && (text[cursor] === ' ' || text[cursor] === '\n')) cursor++
50
+ }
51
+
52
+ if (!numbered || chunks.length === 1) return chunks
53
+ const total = chunks.length
54
+ return chunks.map((c, i) => `${c} (${i + 1}/${total})`)
55
+ }
56
+
57
+ /**
58
+ * If a chunk is going through MarkdownV2 mode, the parens in `(N/N)` need to
59
+ * be escaped. Hermes telegram.py:836.
60
+ */
61
+ export function escapeChunkSuffixForMarkdownV2(text: string): string {
62
+ return text.replace(/\s\((\d+)\/(\d+)\)$/, ' \\($1/$2\\)')
63
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Builds the Telegram BotCommand list registered via `bot.api.setMyCommands`.
3
+ * Sourced from the shared `nebula-ai-core` registry, filtered to
4
+ * surfaces:['tg']. Telegram clips command names to 32 chars and descriptions
5
+ * to 256, so we trim defensively. Argument hints are folded into the
6
+ * description because grammY's BotCommand has no separate hint field.
7
+ */
8
+
9
+ import { commandsForSurface } from 'nebula-ai-core'
10
+
11
+ export interface TelegramBotCommand {
12
+ command: string
13
+ description: string
14
+ }
15
+
16
+ const NAME_LIMIT = 32
17
+ const DESC_LIMIT = 256
18
+
19
+ export function buildTelegramCommands(): TelegramBotCommand[] {
20
+ const out: TelegramBotCommand[] = []
21
+ for (const c of commandsForSurface('tg')) {
22
+ const name = c.name.slice(0, NAME_LIMIT)
23
+ const hint = c.argHint ? ` <${c.argHint}>` : ''
24
+ const description = `${c.description}${hint}`.slice(0, DESC_LIMIT)
25
+ out.push({ command: name, description })
26
+ }
27
+ return out
28
+ }
@@ -0,0 +1,105 @@
1
+ // Per-chat fragment buffer with adaptive quiet-period.
2
+ //
3
+ // Pattern from hermes telegram.py:2257: 600ms default quiet period; bump to
4
+ // 2000ms when the last fragment is >= 4000 chars (TG client splitting a long
5
+ // paste into adjacent updates). Carries sender metadata through the flush
6
+ // boundary so the dispatcher gets correct username/displayName attribution.
7
+
8
+ export interface DebounceOpts {
9
+ /** Quiet-period in ms before flushing. Default 600. */
10
+ quietPeriodMs?: number
11
+ /** Adaptive delay when last fragment is >= adaptiveSplitThreshold. Default 2000. */
12
+ adaptiveDelayMs?: number
13
+ /** Char length that triggers adaptive delay. Default 4000. */
14
+ adaptiveSplitThreshold?: number
15
+ /** Max chars to buffer per chat before forced flush. Default 6000. */
16
+ maxBufferChars?: number
17
+ }
18
+
19
+ export interface BufferedFragment {
20
+ text: string
21
+ messageId: number
22
+ ts: number
23
+ userId: number
24
+ username: string | null
25
+ displayName: string | null
26
+ }
27
+
28
+ export interface FlushedBatch {
29
+ /** Joined text with newline separators. */
30
+ text: string
31
+ /** Latest message id in the burst (used for reactions). */
32
+ latestMessageId: number
33
+ /** Earliest fragment timestamp. */
34
+ firstFragmentTs: number
35
+ /** Count of fragments coalesced. */
36
+ fragmentCount: number
37
+ /** Sender userId from the latest fragment. */
38
+ userId: number
39
+ /** Sender username (no `@`) from the latest fragment, or null. */
40
+ username: string | null
41
+ /** Sender display name from the latest fragment, or null. */
42
+ displayName: string | null
43
+ }
44
+
45
+ export class DebounceBuffer {
46
+ private readonly quietPeriodMs: number
47
+ private readonly adaptiveDelayMs: number
48
+ private readonly adaptiveSplitThreshold: number
49
+ private readonly maxBufferChars: number
50
+ private readonly chats = new Map<
51
+ number,
52
+ { fragments: BufferedFragment[]; timer: ReturnType<typeof setTimeout> | null }
53
+ >()
54
+ private readonly onFlush: (chatId: number, batch: FlushedBatch) => void
55
+
56
+ constructor(onFlush: (chatId: number, batch: FlushedBatch) => void, opts: DebounceOpts = {}) {
57
+ this.quietPeriodMs = opts.quietPeriodMs ?? 600
58
+ this.adaptiveDelayMs = opts.adaptiveDelayMs ?? 2000
59
+ this.adaptiveSplitThreshold = opts.adaptiveSplitThreshold ?? 4000
60
+ this.maxBufferChars = opts.maxBufferChars ?? 6000
61
+ this.onFlush = onFlush
62
+ }
63
+
64
+ push(chatId: number, frag: BufferedFragment): void {
65
+ const entry = this.chats.get(chatId) ?? { fragments: [], timer: null }
66
+ entry.fragments.push(frag)
67
+ if (entry.timer) clearTimeout(entry.timer)
68
+ const totalChars = entry.fragments.reduce((n, f) => n + f.text.length, 0)
69
+ if (totalChars >= this.maxBufferChars) {
70
+ this.chats.set(chatId, entry)
71
+ this.flush(chatId)
72
+ return
73
+ }
74
+ const delay =
75
+ frag.text.length >= this.adaptiveSplitThreshold ? this.adaptiveDelayMs : this.quietPeriodMs
76
+ entry.timer = setTimeout(() => this.flush(chatId), delay)
77
+ this.chats.set(chatId, entry)
78
+ }
79
+
80
+ flush(chatId: number): void {
81
+ const entry = this.chats.get(chatId)
82
+ if (!entry) return
83
+ if (entry.timer) clearTimeout(entry.timer)
84
+ if (entry.fragments.length === 0) {
85
+ this.chats.delete(chatId)
86
+ return
87
+ }
88
+ const last = entry.fragments[entry.fragments.length - 1]!
89
+ const batch: FlushedBatch = {
90
+ text: entry.fragments.map(f => f.text).join('\n'),
91
+ latestMessageId: last.messageId,
92
+ firstFragmentTs: entry.fragments[0]!.ts,
93
+ fragmentCount: entry.fragments.length,
94
+ userId: last.userId,
95
+ username: last.username,
96
+ displayName: last.displayName,
97
+ }
98
+ this.chats.delete(chatId)
99
+ this.onFlush(chatId, batch)
100
+ }
101
+
102
+ flushAll(): void {
103
+ for (const chatId of [...this.chats.keys()]) this.flush(chatId)
104
+ }
105
+ }
package/src/format.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Brain-prompt channel formatting. Mirrors plugin-comms's `<channel source=...>`
3
+ * envelope so the brain can pattern-match across A2A and TG surfaces.
4
+ *
5
+ * Inbound message text from TG is UNTRUSTED user content. We wrap it in
6
+ * channel tags so prompt-injection attempts stay quoted and the brain treats
7
+ * the content as data, not instruction.
8
+ */
9
+ export interface FormatTelegramChannelInput {
10
+ chatId: number
11
+ username: string | null
12
+ displayName: string | null
13
+ text: string
14
+ }
15
+
16
+ export function formatTelegramChannel(input: FormatTelegramChannelInput): string {
17
+ const user = input.username ?? input.displayName ?? `id:${input.chatId}`
18
+ const safeUser = escapeAttr(user)
19
+ const safeText = escapeText(input.text)
20
+ return `<channel source="telegram" chat="${input.chatId}" user="${safeUser}">${safeText}</channel>`
21
+ }
22
+
23
+ /**
24
+ * Inverse of `formatTelegramChannel`: strip the channel envelope and return
25
+ * the raw inner text. Used by the gateway's TG slot to feed bypass-command
26
+ * parsing the un-wrapped string (the wrapper would make `parseBypassCommand`'s
27
+ * `startsWith('/')` check fail and silently drop `/yolo` etc).
28
+ *
29
+ * v0.22.0: extracted into plugin-telegram so the regex source lives next to
30
+ * its forward counterpart `formatTelegramChannel`. If we ever change the
31
+ * envelope shape (add fields, swap quoting), both stay in sync.
32
+ *
33
+ * Returns the input unchanged when there is no envelope (idempotent — safe to
34
+ * call on already-stripped or non-TG input).
35
+ */
36
+ const CHANNEL_ENVELOPE_RE = /^<channel[^>]*>([\s\S]*)<\/channel>$/
37
+ export function stripTelegramChannelEnvelope(text: string): string {
38
+ return text.replace(CHANNEL_ENVELOPE_RE, '$1')
39
+ }
40
+
41
+ /**
42
+ * One-line preview of an inbound TG message for TUI rows + activity log.
43
+ * Truncated to 80 chars; never includes the bot token or any envelope bytes.
44
+ */
45
+ export function formatInboundPreview(input: FormatTelegramChannelInput): string {
46
+ const user = input.username ?? input.displayName ?? `id:${input.chatId}`
47
+ const oneLine = input.text.replace(/\s+/g, ' ').trim()
48
+ const cut = oneLine.length > 80 ? `${oneLine.slice(0, 77)}...` : oneLine
49
+ return `tg @${user}: ${cut}`
50
+ }
51
+
52
+ function escapeAttr(s: string): string {
53
+ return s
54
+ .replace(/&/g, '&amp;')
55
+ .replace(/"/g, '&quot;')
56
+ .replace(/</g, '&lt;')
57
+ .replace(/>/g, '&gt;')
58
+ }
59
+
60
+ function escapeText(s: string): string {
61
+ // Only escape angle brackets so the brain can't be tricked by literal
62
+ // </channel> in user content. Ampersands stay raw to preserve readability.
63
+ return s.replace(/</g, '&lt;').replace(/>/g, '&gt;')
64
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Brain-prompt fragment appended ONLY when telegram is loaded. Mirrors the
3
+ * `MARKETPLACE_GUIDANCE` and `ONCHAIN_GUIDANCE` patterns in plugin-comms /
4
+ * plugin-onchain.
5
+ *
6
+ * Goal: tune the brain's tone for phone-screen consumption when responding to
7
+ * a TG-sourced turn. Without this, replies leak laptop-style markdown tables
8
+ * and 200-line code blocks that render as garbage in TG.
9
+ */
10
+ export const TELEGRAM_GUIDANCE = `# Telegram channel
11
+ When you receive a turn whose channel is \`<channel source="telegram" ...>\`, you are responding into a phone-app surface. Apply these constraints:
12
+
13
+ - Keep responses short. Most TG users read on a phone screen.
14
+ - No markdown tables. TG renders them as raw pipes.
15
+ - No long code blocks (>20 lines). Summarize or attach a file via \`agent.send_file\` if the comms plugin is loaded.
16
+ - Tool-call output is fine but truncate aggressively before quoting it back.
17
+ - Reactions (eye/thumbs-up/thumbs-down) are added by the gateway; do not put emojis at the start of replies.
18
+ - Operator may DM through TG even when their laptop is closed. Treat every TG message as authoritative; do not gate on operator confirmation.
19
+
20
+ When the channel source is \`stdin\` (operator typing in the local TUI), full markdown is fine since laptops render it.`
package/src/index.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * nebula-ai-plugin-telegram
3
+ *
4
+ * Long-poll Telegram bot listener. Operator DMs `@nebula_<name>_bot` from any
5
+ * phone; the agent (running in Mantle Sandbox or local) replies via the same
6
+ * brain that handles stdin TUI turns.
7
+ *
8
+ * Required side-band ctx (`(ctx as any).telegram` field set by chat.tsx in
9
+ * local mode or build-runtime.ts in sandbox mode):
10
+ *
11
+ * - botToken, allowedUserIds, agentName
12
+ * - dispatchUserMessage: invoked per debounced inbound; runs brain.infer
13
+ * - onProcessingStart, onProcessingEnd: optional hooks for TUI surfacing
14
+ *
15
+ * Without `ctx.telegram`, the plugin registers nothing (graceful no-op for
16
+ * unit-test loaders).
17
+ */
18
+ import type { NativePlugin } from 'nebula-ai-core'
19
+ import { TelegramListener } from './listener'
20
+ import type { TelegramRuntimeContext } from './types'
21
+
22
+ export type {
23
+ TelegramRuntimeContext,
24
+ TelegramDispatchInput,
25
+ TelegramDispatchResult,
26
+ TelegramInboundEvent,
27
+ TelegramToolEvent,
28
+ } from './types'
29
+ export { ProgressTracker, PROGRESS_EDIT_INTERVAL } from './progress'
30
+ export {
31
+ TelegramListener,
32
+ TELEGRAM_ALLOWED_UPDATES,
33
+ capForTelegram,
34
+ formatApprovalResolution,
35
+ } from './listener'
36
+ export { buildSessionKey, sanitizeAgentName } from './session-key'
37
+ export {
38
+ formatTelegramChannel,
39
+ formatInboundPreview,
40
+ stripTelegramChannelEnvelope,
41
+ } from './format'
42
+ export { RateLimiter } from './limits'
43
+ export { sanitizeInbound, type SanitizeReason, type SanitizeResult } from './sanitize'
44
+ export { formatPairingMessage } from './pairing-flow'
45
+ export {
46
+ ActiveSessionTracker,
47
+ BYPASS_COMMANDS,
48
+ parseBypassCommand,
49
+ type ActiveSession,
50
+ type BypassCommand,
51
+ type ParsedBypass,
52
+ } from './session-state'
53
+ export { buildTelegramCommands, type TelegramBotCommand } from './commands'
54
+ export {
55
+ type ApprovalChoice,
56
+ APPROVAL_CALLBACK_PREFIX,
57
+ buildApprovalKeyboard,
58
+ handleApprovalCallback,
59
+ makeApprovalIdFactory,
60
+ parseCallbackData,
61
+ type ParsedCallback,
62
+ type ResolveOutcome,
63
+ } from './approval-keyboard'
64
+ export {
65
+ escapeMarkdownV2,
66
+ formatMarkdownV2,
67
+ isMarkdownParseError,
68
+ stripMarkdownV2,
69
+ } from './markdown'
70
+ export { escapeChunkSuffixForMarkdownV2, splitMessage, type SplitOpts } from './chunking'
71
+ export type { TelegramApprovalBridge, ApprovalChoiceKind } from './types'
72
+ export { DebounceBuffer } from './debounce'
73
+ export {
74
+ sendWithRetry,
75
+ classifyError,
76
+ isRetryable,
77
+ isTimeout,
78
+ isReplyNotFound,
79
+ isThreadNotFound,
80
+ RETRYABLE_PATTERNS,
81
+ TIMEOUT_PATTERNS,
82
+ DELIVERY_FAILURE_NOTICE,
83
+ } from './retry'
84
+ export {
85
+ acquireTelegramTokenLock,
86
+ BotTokenLockedError,
87
+ clearStaleTelegramTokenLock,
88
+ clearWebhookBeforePolling,
89
+ classifyStartFailure,
90
+ TELEGRAM_TOKEN_LOCK_SCOPE,
91
+ type StartFailure,
92
+ type StartFailureKind,
93
+ type TokenLock,
94
+ type AcquireTokenLockOpts,
95
+ } from './recovery'
96
+ export {
97
+ reactProcessing,
98
+ reactSuccess,
99
+ reactError,
100
+ REACTION_PROCESSING,
101
+ REACTION_OK,
102
+ REACTION_ERR,
103
+ } from './reactions'
104
+ export { TELEGRAM_GUIDANCE } from './guidance'
105
+ export { startTypingLoop, TYPING_REFRESH_INTERVAL_MS } from './typing'
106
+
107
+ const plugin: NativePlugin = {
108
+ name: 'telegram',
109
+ register: ctx => {
110
+ const tg = (ctx as unknown as { telegram?: TelegramRuntimeContext }).telegram
111
+ if (!tg) return
112
+ const listener = new TelegramListener(tg)
113
+ ctx.registerListener({
114
+ name: 'telegram-bot',
115
+ start: async () => {
116
+ await listener.start()
117
+ },
118
+ stop: async () => {
119
+ await listener.stop()
120
+ },
121
+ } as never)
122
+ },
123
+ }
124
+
125
+ export default plugin
package/src/limits.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Per-user token-bucket rate limiter. Defends against a compromised allowed
3
+ * user account spamming the brain (and burning compute credits).
4
+ *
5
+ * Default: 30 messages per 60 seconds. Excess get dropped + a 👎 reaction (the
6
+ * listener handles the reaction; this module just answers shouldDrop).
7
+ */
8
+ export interface RateLimiterOpts {
9
+ /** Bucket capacity per user. Default 30. */
10
+ capacity?: number
11
+ /** Refill window in ms. Default 60_000. */
12
+ windowMs?: number
13
+ }
14
+
15
+ interface Bucket {
16
+ /** Remaining tokens. */
17
+ tokens: number
18
+ /** Unix-ms timestamp of the last refill. */
19
+ lastRefill: number
20
+ }
21
+
22
+ export class RateLimiter {
23
+ private readonly capacity: number
24
+ private readonly windowMs: number
25
+ private readonly buckets = new Map<number, Bucket>()
26
+
27
+ constructor(opts: RateLimiterOpts = {}) {
28
+ this.capacity = opts.capacity ?? 30
29
+ this.windowMs = opts.windowMs ?? 60_000
30
+ }
31
+
32
+ /**
33
+ * Returns true if this user's message should be DROPPED (bucket empty).
34
+ * Side-effect: consumes one token if not dropped.
35
+ */
36
+ shouldDrop(userId: number, now: number = Date.now()): boolean {
37
+ const b = this.buckets.get(userId) ?? { tokens: this.capacity, lastRefill: now }
38
+ // Refill proportional to elapsed time
39
+ const elapsed = now - b.lastRefill
40
+ if (elapsed > 0) {
41
+ const refill = Math.floor((elapsed / this.windowMs) * this.capacity)
42
+ if (refill > 0) {
43
+ b.tokens = Math.min(this.capacity, b.tokens + refill)
44
+ b.lastRefill = now
45
+ }
46
+ }
47
+ if (b.tokens <= 0) {
48
+ this.buckets.set(userId, b)
49
+ return true
50
+ }
51
+ b.tokens -= 1
52
+ this.buckets.set(userId, b)
53
+ return false
54
+ }
55
+
56
+ reset(userId?: number): void {
57
+ if (userId === undefined) this.buckets.clear()
58
+ else this.buckets.delete(userId)
59
+ }
60
+ }