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/src/retry.ts ADDED
@@ -0,0 +1,114 @@
1
+ // sendMessage / setMessageReaction retry classifier.
2
+ //
3
+ // RULE: timeout errors are NOT retryable, because the message MAY have been
4
+ // delivered already. Retrying could double-send. Connection errors (ECONNRESET,
5
+ // ENETUNREACH) are retryable because the server never received the request.
6
+ //
7
+ // Pattern from hermes (`base.py:1302` `_send_with_retry`).
8
+
9
+ export type RetryClassification = 'retry' | 'fail' | 'fail-silent'
10
+
11
+ /** Substrings that signal a transient network/connection failure. */
12
+ export const RETRYABLE_PATTERNS = [
13
+ 'connecterror',
14
+ 'connectionerror',
15
+ 'connectionreset',
16
+ 'connectionrefused',
17
+ 'connecttimeout',
18
+ 'network',
19
+ 'broken pipe',
20
+ 'remotedisconnected',
21
+ 'eoferror',
22
+ 'enetunreach',
23
+ 'eai_again',
24
+ 'socket hang up',
25
+ 'econnreset',
26
+ 'econnrefused',
27
+ ] as const
28
+
29
+ /** Substrings that signal a true delivery-status-unknown timeout (NOT retryable). */
30
+ export const TIMEOUT_PATTERNS = [
31
+ 'timed out',
32
+ 'readtimeout',
33
+ 'writetimeout',
34
+ 'etimedout',
35
+ 'request timeout',
36
+ 'aborted',
37
+ ] as const
38
+
39
+ /** User-facing notice when retries exhaust mid-stream. Hermes-aligned text. */
40
+ export const DELIVERY_FAILURE_NOTICE =
41
+ '⚠️ Message delivery failed after multiple attempts. Please try again. Your request was processed but the response could not be sent.'
42
+
43
+ export function isRetryable(err: unknown): boolean {
44
+ const lower = errorMessage(err).toLowerCase()
45
+ return RETRYABLE_PATTERNS.some(p => lower.includes(p))
46
+ }
47
+
48
+ export function isTimeout(err: unknown): boolean {
49
+ const lower = errorMessage(err).toLowerCase()
50
+ return TIMEOUT_PATTERNS.some(p => lower.includes(p))
51
+ }
52
+
53
+ export function isReplyNotFound(err: unknown): boolean {
54
+ const lower = errorMessage(err).toLowerCase()
55
+ return (
56
+ lower.includes('reply message not found') ||
57
+ lower.includes('replied message not found') ||
58
+ lower.includes('message to be replied')
59
+ )
60
+ }
61
+
62
+ export function isThreadNotFound(err: unknown): boolean {
63
+ const lower = errorMessage(err).toLowerCase()
64
+ return lower.includes('thread') && lower.includes('not found')
65
+ }
66
+
67
+ export function classifyError(err: unknown): RetryClassification {
68
+ if (isTimeout(err)) return 'fail'
69
+ if (isRetryable(err)) return 'retry'
70
+ const lower = errorMessage(err).toLowerCase()
71
+ if (
72
+ lower.includes('forbidden') ||
73
+ lower.includes('chat not found') ||
74
+ lower.includes('blocked')
75
+ ) {
76
+ return 'fail-silent'
77
+ }
78
+ if (lower.includes('bad request') || lower.includes('400')) return 'fail'
79
+ return 'retry'
80
+ }
81
+
82
+ export interface RetryOpts {
83
+ /** Max retry attempts. Default 2 (so 3 total attempts). */
84
+ maxRetries?: number
85
+ /** Base delay in ms; doubles per attempt. Default 250. */
86
+ baseDelayMs?: number
87
+ }
88
+
89
+ export async function sendWithRetry<T>(fn: () => Promise<T>, opts: RetryOpts = {}): Promise<T> {
90
+ const maxRetries = opts.maxRetries ?? 2
91
+ const baseDelay = opts.baseDelayMs ?? 250
92
+ let lastErr: unknown
93
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
94
+ try {
95
+ return await fn()
96
+ } catch (err) {
97
+ lastErr = err
98
+ const verdict = classifyError(err)
99
+ if (verdict !== 'retry' || attempt === maxRetries) throw err
100
+ await new Promise(r => setTimeout(r, baseDelay * 2 ** attempt))
101
+ }
102
+ }
103
+ throw lastErr
104
+ }
105
+
106
+ function errorMessage(err: unknown): string {
107
+ if (err instanceof Error) return err.message
108
+ if (typeof err === 'string') return err
109
+ try {
110
+ return JSON.stringify(err)
111
+ } catch {
112
+ return String(err)
113
+ }
114
+ }
@@ -0,0 +1,128 @@
1
+ import type { PairingStore } from 'nebula-ai-core'
2
+ import type { TelegramInboundEvent } from './types'
3
+
4
+ /**
5
+ * MVP filter: only DMs from authorized users are dispatched. Group chat,
6
+ * channel posts, forwarded messages, and bot-to-bot messages are dropped.
7
+ *
8
+ * Authorization (hermes default-deny model):
9
+ * - If `allowedUserIds` contains the sender, accept.
10
+ * - Else if `pairingStore` is provided and the sender is approved, accept.
11
+ * - Else if `pairingStore` is provided, generate a pairing code and return
12
+ * `{ ok: false, action: 'send-pairing-code', code }` so the listener can
13
+ * DM the code to the sender. The operator approves out-of-band via
14
+ * `nebula pairing approve telegram <code>`.
15
+ * - Else (no allowlist + no pairing) reject with `no-allowlist-default-deny`.
16
+ *
17
+ * Returns null when the message should be dropped (with reason in debug logs).
18
+ * Returns a normalized TelegramInboundEvent when accepted.
19
+ */
20
+ export interface SanitizeOpts {
21
+ allowedUserIds: number[]
22
+ /** Hard cap on text length. Default 2000 chars (TG max is 4096). */
23
+ maxTextLength?: number
24
+ /** Optional pairing store. When present, unknown senders get a pairing code. */
25
+ pairingStore?: Pick<PairingStore, 'isApproved' | 'generateCode'>
26
+ /** Platform key passed to pairingStore (always 'telegram' for this plugin). */
27
+ pairingPlatform?: string
28
+ }
29
+
30
+ export interface SanitizeInput {
31
+ chatType: 'private' | 'group' | 'supergroup' | 'channel'
32
+ chatId: number
33
+ fromId: number | null
34
+ fromIsBot: boolean
35
+ fromUsername: string | null
36
+ fromFirstName: string | null
37
+ fromLastName: string | null
38
+ text: string | null
39
+ messageId: number
40
+ forwardedFrom: unknown
41
+ mediaGroupId: string | null
42
+ }
43
+
44
+ export type SanitizeResult =
45
+ | { ok: true; event: TelegramInboundEvent }
46
+ | {
47
+ ok: false
48
+ reason: SanitizeReason
49
+ action?: 'send-pairing-code'
50
+ code?: string
51
+ pairedUserId?: number
52
+ pairedUserName?: string | null
53
+ }
54
+
55
+ export type SanitizeReason =
56
+ | 'not-private-chat'
57
+ | 'sender-is-bot'
58
+ | 'sender-not-allowed'
59
+ | 'no-allowlist-default-deny'
60
+ | 'pairing-rate-limited'
61
+ | 'forwarded-message'
62
+ | 'no-text'
63
+ | 'no-sender-id'
64
+ | 'media-group'
65
+
66
+ export function sanitizeInbound(input: SanitizeInput, opts: SanitizeOpts): SanitizeResult {
67
+ if (input.chatType !== 'private') return { ok: false, reason: 'not-private-chat' }
68
+ if (input.fromIsBot) return { ok: false, reason: 'sender-is-bot' }
69
+ if (input.fromId === null) return { ok: false, reason: 'no-sender-id' }
70
+ if (input.forwardedFrom != null) return { ok: false, reason: 'forwarded-message' }
71
+ if (input.mediaGroupId != null) return { ok: false, reason: 'media-group' }
72
+ if (typeof input.text !== 'string' || input.text.trim().length === 0) {
73
+ return { ok: false, reason: 'no-text' }
74
+ }
75
+
76
+ const platform = opts.pairingPlatform ?? 'telegram'
77
+ const inAllowlist = opts.allowedUserIds.includes(input.fromId)
78
+ const pairingApproved = opts.pairingStore?.isApproved(platform, String(input.fromId)) ?? false
79
+
80
+ if (!inAllowlist && !pairingApproved) {
81
+ if (opts.pairingStore) {
82
+ const code = opts.pairingStore.generateCode(
83
+ platform,
84
+ String(input.fromId),
85
+ input.fromUsername ?? formatDisplayName(input.fromFirstName, input.fromLastName) ?? '',
86
+ )
87
+ if (code) {
88
+ return {
89
+ ok: false,
90
+ reason: 'sender-not-allowed',
91
+ action: 'send-pairing-code',
92
+ code,
93
+ pairedUserId: input.fromId,
94
+ pairedUserName:
95
+ input.fromUsername ?? formatDisplayName(input.fromFirstName, input.fromLastName),
96
+ }
97
+ }
98
+ return { ok: false, reason: 'pairing-rate-limited' }
99
+ }
100
+ if (opts.allowedUserIds.length === 0) {
101
+ return { ok: false, reason: 'no-allowlist-default-deny' }
102
+ }
103
+ return { ok: false, reason: 'sender-not-allowed' }
104
+ }
105
+
106
+ const cap = opts.maxTextLength ?? 2000
107
+ let text = input.text
108
+ if (text.length > cap) text = `${text.slice(0, cap)}\n[message truncated]`
109
+ const displayName = formatDisplayName(input.fromFirstName, input.fromLastName)
110
+ return {
111
+ ok: true,
112
+ event: {
113
+ chatId: input.chatId,
114
+ userId: input.fromId,
115
+ username: input.fromUsername,
116
+ displayName,
117
+ text,
118
+ messageId: input.messageId,
119
+ ts: Date.now(),
120
+ },
121
+ }
122
+ }
123
+
124
+ function formatDisplayName(first: string | null, last: string | null): string | null {
125
+ const parts = [first, last].filter((s): s is string => typeof s === 'string' && s.length > 0)
126
+ if (parts.length === 0) return null
127
+ return parts.join(' ')
128
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure helpers for building stable session keys per inbound surface. The brain
3
+ * prompt's `<channel source="telegram" chat="..." user="...">` wrapping uses
4
+ * these so memory writes can be partitioned per chat (future) and rate
5
+ * limiting can scope per chat.
6
+ *
7
+ * Format mirrors hermes:
8
+ * agent:<name>:telegram:dm:<chatId>
9
+ * agent:<name>:telegram:group:<chatId>:<threadId> (post-MVP)
10
+ *
11
+ * Pure — no IO, no mutation. Safe to test exhaustively.
12
+ */
13
+ export interface BuildSessionKeyInput {
14
+ agentName: string
15
+ chatId: number
16
+ threadId?: number
17
+ isGroup?: boolean
18
+ }
19
+
20
+ export function buildSessionKey(input: BuildSessionKeyInput): string {
21
+ const safeName = sanitizeAgentName(input.agentName)
22
+ if (input.isGroup) {
23
+ const t = input.threadId ?? 0
24
+ return `agent:${safeName}:telegram:group:${input.chatId}:${t}`
25
+ }
26
+ return `agent:${safeName}:telegram:dm:${input.chatId}`
27
+ }
28
+
29
+ /**
30
+ * Strip characters that would confuse memory paths or prompt parsing.
31
+ * Allow lowercase alpha + digits + hyphen only. Empty string falls back to
32
+ * "nebula" so the key is always well-formed.
33
+ */
34
+ export function sanitizeAgentName(raw: string): string {
35
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9-]/g, '')
36
+ return cleaned.length > 0 ? cleaned : 'nebula'
37
+ }
@@ -0,0 +1,102 @@
1
+ // Active-session tracker for telegram turns.
2
+ //
3
+ // Mirrors hermes base.py:1417-1488. The synchronous mark-active BEFORE async
4
+ // dispatch is the load-bearing detail: without it, two messages in the same
5
+ // event-loop tick can both pass the active-check and both spawn brain turns.
6
+ //
7
+ // Bypass commands: hermes-derived (/approve, /deny, /status, /stop, /new,
8
+ // /reset, /background, /restart) plus v0.20.0 additions (/yolo, /perms) so
9
+ // operators can flip permission mode from their phone without restarting.
10
+
11
+ export const BYPASS_COMMANDS = [
12
+ '/stop',
13
+ '/new',
14
+ '/reset',
15
+ '/status',
16
+ '/approve',
17
+ '/deny',
18
+ '/background',
19
+ '/restart',
20
+ '/yolo',
21
+ '/perms',
22
+ ] as const
23
+
24
+ export type BypassCommand = (typeof BYPASS_COMMANDS)[number]
25
+
26
+ export interface ParsedBypass {
27
+ command: BypassCommand
28
+ args: string[]
29
+ }
30
+
31
+ /**
32
+ * Detect a bypass command at the start of an inbound message. Returns the
33
+ * canonical lowercase command + whitespace-split args, or null when the
34
+ * message isn't a bypass command. v0.20.0 changed the return shape from
35
+ * `BypassCommand | null` to `ParsedBypass | null` so handlers (especially
36
+ * `/perms <mode>`) can read the args alongside the name.
37
+ */
38
+ export function parseBypassCommand(text: string): ParsedBypass | null {
39
+ const trimmed = text.trim()
40
+ if (!trimmed.startsWith('/')) return null
41
+ const parts = trimmed.split(/\s+/)
42
+ const head = parts[0]?.toLowerCase()
43
+ if (!head) return null
44
+ if ((BYPASS_COMMANDS as readonly string[]).includes(head)) {
45
+ return { command: head as BypassCommand, args: parts.slice(1) }
46
+ }
47
+ return null
48
+ }
49
+
50
+ export interface ActiveSession {
51
+ abortCtrl: AbortController | null
52
+ startedAt: number
53
+ }
54
+
55
+ /**
56
+ * Per-session-key state machine. `markActive` MUST be called synchronously
57
+ * BEFORE any async dispatch (the race-window-closing detail). `markIdle`
58
+ * fires from the dispatch's `finally`. `setPending` queues an interrupt
59
+ * payload; the dispatcher reads it on the next iteration.
60
+ */
61
+ export class ActiveSessionTracker {
62
+ readonly #sessions = new Map<string, ActiveSession>()
63
+ readonly #pending = new Map<string, unknown>()
64
+
65
+ isActive(key: string): boolean {
66
+ return this.#sessions.has(key)
67
+ }
68
+
69
+ markActive(key: string, abortCtrl: AbortController | null = null): void {
70
+ this.#sessions.set(key, { abortCtrl, startedAt: Date.now() })
71
+ }
72
+
73
+ markIdle(key: string): void {
74
+ this.#sessions.delete(key)
75
+ }
76
+
77
+ getAbortController(key: string): AbortController | null {
78
+ return this.#sessions.get(key)?.abortCtrl ?? null
79
+ }
80
+
81
+ /** Called by `/stop` bypass: aborts the active turn for `key` if any. */
82
+ abortActive(key: string): boolean {
83
+ const session = this.#sessions.get(key)
84
+ if (!session?.abortCtrl) return false
85
+ session.abortCtrl.abort()
86
+ return true
87
+ }
88
+
89
+ setPending(key: string, event: unknown): void {
90
+ this.#pending.set(key, event)
91
+ }
92
+
93
+ takePending(key: string): unknown | undefined {
94
+ const v = this.#pending.get(key)
95
+ this.#pending.delete(key)
96
+ return v
97
+ }
98
+
99
+ activeKeys(): string[] {
100
+ return Array.from(this.#sessions.keys())
101
+ }
102
+ }
package/src/types.ts ADDED
@@ -0,0 +1,144 @@
1
+ import type { PairingStore } from 'nebula-ai-core'
2
+
3
+ /**
4
+ * Side-band runtime context for plugin-telegram. The CLI (chat.tsx, local
5
+ * mode) or harness (build-runtime.ts, sandbox mode) builds this and attaches
6
+ * it to the plugin context as `(ctx as any).telegram` before loadPlugins.
7
+ *
8
+ * Without this side-band, the plugin registers nothing (soft-init for unit
9
+ * tests / non-telegram contexts). Mirrors the comms / onchain pattern.
10
+ */
11
+ export interface TelegramRuntimeContext {
12
+ /** Bot token from @BotFather, post-decryption. NEVER goes to activity log. */
13
+ botToken: string
14
+ /**
15
+ * Telegram user IDs allowed to DM this bot. Anyone else's messages are
16
+ * dropped silently (no reply, no reaction, no log entry beyond a debug line).
17
+ */
18
+ allowedUserIds: number[]
19
+ /**
20
+ * Agent's display name (e.g. "specter", "enigma"). Used in session-key
21
+ * formatting so each agent's TG context is distinct in the brain prompt.
22
+ */
23
+ agentName: string
24
+ /**
25
+ * Brain dispatch callback. The listener invokes this when a debounced inbound
26
+ * is ready. The CLI or harness implementation:
27
+ * 1. wraps `text` in a `<channel source="telegram" ...>` prompt fragment
28
+ * 2. fires brain.infer with `source: 'telegram'`
29
+ * 3. flushes per-turn sync
30
+ * 4. returns the assistant string for the listener to send back via TG
31
+ */
32
+ dispatchUserMessage: (input: TelegramDispatchInput) => Promise<TelegramDispatchResult>
33
+ /** Optional hook fired before reaction transitions to 👀. CLI may push a TUI row. */
34
+ onProcessingStart?: (chatId: number, messageId: number) => Promise<void> | void
35
+ /** Optional hook fired after reaction transitions to 👍/👎. */
36
+ onProcessingEnd?: (chatId: number, messageId: number, ok: boolean) => Promise<void> | void
37
+ /** Verbose grammy logs. Default false. */
38
+ debug?: boolean
39
+ /**
40
+ * Optional pairing store. When present, unknown senders get a pairing code
41
+ * via DM and the operator approves via `nebula pairing approve telegram <code>`.
42
+ * When absent, the listener uses static allowlist only (default-deny on empty).
43
+ */
44
+ pairingStore?: PairingStore
45
+ /**
46
+ * Optional approval bridge. When present, the listener fills the inner
47
+ * `sendApproval` + `installCallbackHandler` slots on start so chat-telegram
48
+ * (or the harness build-runtime in sandbox mode) can swap a TG-side
49
+ * permission prompter at the start of a turn. When absent, the local TUI
50
+ * modal handles all approvals as before.
51
+ */
52
+ approvalBridge?: TelegramApprovalBridge
53
+ /**
54
+ * v0.24.12: outbound slot the listener fills on `start()` with a method
55
+ * that broadcasts a short text to every allowed operator chat. The
56
+ * gateway uses it to forward unsolicited brain prompts (clarify on
57
+ * autonomous market wakes) when no TUI is connected. When absent, the
58
+ * gateway logs the question to activity-log only.
59
+ */
60
+ operatorNotifier?: OperatorNotifierSlot
61
+ }
62
+
63
+ /** Mutable slot the listener fills on start so the gateway can broadcast clarify questions. */
64
+ export interface OperatorNotifierSlot {
65
+ current: ((text: string) => Promise<void>) | null
66
+ }
67
+
68
+ export type ApprovalChoiceKind = 'once' | 'session' | 'always' | 'deny'
69
+
70
+ /**
71
+ * Mutable bridge object created by the dispatcher (chat-telegram or
72
+ * harness/build-runtime) and filled by the listener on start. The dispatcher
73
+ * holds the resolver Map; the listener holds the bot. They cooperate via this
74
+ * bridge so the inline-keyboard approval can roundtrip TG → brain → TG.
75
+ */
76
+ export interface TelegramApprovalBridge {
77
+ /** Filled by listener.start(). Sends the approval inline keyboard. */
78
+ sendApproval: {
79
+ current: ((chatId: number, text: string, approvalId: string) => Promise<void>) | null
80
+ }
81
+ /**
82
+ * Filled by listener.start(). Lets the dispatcher install a single
83
+ * callback_query handler that the listener fans out per click. Returns
84
+ * an unregister function.
85
+ */
86
+ installCallbackHandler: {
87
+ current:
88
+ | ((
89
+ handler: (approvalId: string, choice: ApprovalChoiceKind, fromUserId: number) => void,
90
+ ) => () => void)
91
+ | null
92
+ }
93
+ }
94
+
95
+ /** Tool-call lifecycle event observed by the TG dispatcher for live UI rendering. */
96
+ export interface TelegramToolEvent {
97
+ kind: 'start' | 'end'
98
+ tool: string
99
+ callId: string
100
+ argsPreview?: string
101
+ ok?: boolean
102
+ }
103
+
104
+ export interface TelegramDispatchInput {
105
+ /** Composed text after debounce flush; safe to feed into brain prompt. */
106
+ text: string
107
+ /** TG numeric chat id (== user id for 1-on-1 DMs). */
108
+ chatId: number
109
+ /** TG numeric user id of the sender (always in `allowedUserIds`). */
110
+ userId: number
111
+ /** Display username of the sender (no `@` prefix), or null if unset. */
112
+ username: string | null
113
+ /** Display first/last name of the sender, or null. */
114
+ displayName: string | null
115
+ /** TG message id of the LATEST fragment in the debounced burst. Used for reactions. */
116
+ latestMessageId: number
117
+ /** Stable session key for this chat: `agent:<name>:telegram:dm:<chatId>`. */
118
+ sessionKey: string
119
+ /**
120
+ * Per-turn observer of tool-call lifecycle. Listener supplies this so it
121
+ * can stream progress to a TG message as the brain works through the turn.
122
+ * Dispatch implementation (chat-telegram.ts in local mode, build-runtime.ts
123
+ * in sandbox mode) must forward this to `brain.infer({ onToolEvent: ... })`.
124
+ * Errors swallowed; observer must NEVER block dispatch.
125
+ */
126
+ onToolEvent?: (ev: TelegramToolEvent) => void
127
+ }
128
+
129
+ export interface TelegramDispatchResult {
130
+ /** Assistant text to echo back to the user. Empty string skips the reply. */
131
+ response: string
132
+ /** Optional Mantle mainnet sync tx hash, surfaced as a footer if non-empty. */
133
+ syncTx?: string
134
+ }
135
+
136
+ export interface TelegramInboundEvent {
137
+ chatId: number
138
+ userId: number
139
+ username: string | null
140
+ displayName: string | null
141
+ text: string
142
+ messageId: number
143
+ ts: number
144
+ }
package/src/typing.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Typing-indicator loop for the active brain turn. TG's `chat_action="typing"`
3
+ * auto-expires after ~5 seconds, so we refresh on a 4.5s interval. Fires once
4
+ * immediately so the user sees `typing...` within the first message-handler tick.
5
+ *
6
+ * Errors are swallowed: if `sendChatAction` rate-limits or fails the network
7
+ * call, the loop keeps running and the brain dispatch must NEVER block on a
8
+ * cosmetic indicator.
9
+ *
10
+ * Mirrors hermes' `_keep_typing` (gateway/platforms/base.py), but uses
11
+ * `setInterval` instead of an asyncio task. `clearInterval(timer)` from the
12
+ * returned cancel fn is idempotent.
13
+ */
14
+ import type { Bot } from 'grammy'
15
+
16
+ const TYPING_REFRESH_MS = 4_500
17
+
18
+ export function startTypingLoop(bot: Bot, chatId: number): () => void {
19
+ const fire = (): void => {
20
+ void bot.api.sendChatAction(chatId, 'typing').catch(() => {
21
+ /* cosmetic; never block dispatch */
22
+ })
23
+ }
24
+ fire()
25
+ const timer = setInterval(fire, TYPING_REFRESH_MS)
26
+ return () => {
27
+ clearInterval(timer)
28
+ }
29
+ }
30
+
31
+ export const TYPING_REFRESH_INTERVAL_MS = TYPING_REFRESH_MS