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/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
|
+
}
|
package/src/sanitize.ts
ADDED
|
@@ -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
|