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 +31 -0
- package/package.json +46 -0
- package/src/approval-keyboard.ts +108 -0
- package/src/chunking.ts +63 -0
- package/src/commands.ts +28 -0
- package/src/debounce.ts +105 -0
- package/src/format.ts +64 -0
- package/src/guidance.ts +20 -0
- package/src/index.ts +125 -0
- package/src/limits.ts +60 -0
- package/src/listener.ts +612 -0
- package/src/markdown.ts +211 -0
- package/src/pairing-flow.ts +31 -0
- package/src/progress.ts +240 -0
- package/src/reactions.ts +54 -0
- package/src/recovery.ts +131 -0
- package/src/retry.ts +114 -0
- package/src/sanitize.ts +128 -0
- package/src/session-key.ts +37 -0
- package/src/session-state.ts +102 -0
- package/src/types.ts +144 -0
- package/src/typing.ts +31 -0
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
|
+
}
|
package/src/chunking.ts
ADDED
|
@@ -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
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -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
|
+
}
|
package/src/debounce.ts
ADDED
|
@@ -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, '&')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
.replace(/</g, '<')
|
|
57
|
+
.replace(/>/g, '>')
|
|
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, '<').replace(/>/g, '>')
|
|
64
|
+
}
|
package/src/guidance.ts
ADDED
|
@@ -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
|
+
}
|