chenbhao commited on
Commit
76777b5
·
1 Parent(s): d0fbd76

feat: /telegram backend load

Browse files
src/commands.ts CHANGED
@@ -43,6 +43,7 @@ import share from './commands/share/index.js'
43
  import skills from './commands/skills/index.js'
44
  import status from './commands/status/index.js'
45
  import tasks from './commands/tasks/index.js'
 
46
  import teleport from './commands/teleport/index.js'
47
  /* eslint-disable @typescript-eslint/no-require-imports */
48
  const agentsPlatform =
@@ -302,6 +303,7 @@ const COMMANDS = memoize((): Command[] => [
302
  statusline,
303
  stickers,
304
  tag,
 
305
  theme,
306
  feedback,
307
  review,
 
43
  import skills from './commands/skills/index.js'
44
  import status from './commands/status/index.js'
45
  import tasks from './commands/tasks/index.js'
46
+ import telegram from './commands/telegram/index.js'
47
  import teleport from './commands/teleport/index.js'
48
  /* eslint-disable @typescript-eslint/no-require-imports */
49
  const agentsPlatform =
 
303
  statusline,
304
  stickers,
305
  tag,
306
+ telegram,
307
  theme,
308
  feedback,
309
  review,
src/commands/telegram/index.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Command } from '../../commands.js'
2
+
3
+ const telegram = {
4
+ type: 'local-jsx',
5
+ name: 'telegram',
6
+ description: 'Connect a Telegram bot to this session',
7
+ load: () => import('./telegram.js'),
8
+ } satisfies Command
9
+
10
+ export default telegram
src/commands/telegram/telegram.tsx ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from 'react'
2
+ import { Box, Text, useInput } from '../../ink.js'
3
+ import { Dialog } from '../../components/design-system/Dialog.js'
4
+ import { ListItem } from '../../components/design-system/ListItem.js'
5
+ import type { LocalJSXCommandContext } from '../../commands.js'
6
+ import type { LocalJSXCommandOnDone } from '../../types/command.js'
7
+ import { telegramService } from '../../services/telegram/TelegramService.js'
8
+ import {
9
+ clearTelegramConfig,
10
+ formatAllowedTelegramUserIds,
11
+ getTelegramConfig,
12
+ maskTelegramToken,
13
+ parseAllowedTelegramUserIds,
14
+ saveTelegramConfig,
15
+ } from '../../services/telegram/telegramConfig.js'
16
+
17
+ type Step =
18
+ | { type: 'menu' }
19
+ | { type: 'edit-token' }
20
+ | { type: 'edit-user-ids' }
21
+ | { type: 'confirm-clear' }
22
+
23
+ type Notice = {
24
+ text: string
25
+ tone: 'success' | 'error' | 'info'
26
+ }
27
+
28
+ function TextInput({
29
+ title,
30
+ hint,
31
+ initialValue,
32
+ masked = false,
33
+ onSubmit,
34
+ onCancel,
35
+ }: {
36
+ title: string
37
+ hint: string
38
+ initialValue?: string
39
+ masked?: boolean
40
+ onSubmit: (value: string) => void
41
+ onCancel: () => void
42
+ }) {
43
+ const [value, setValue] = React.useState(initialValue ?? '')
44
+ const [cursorOffset, setCursorOffset] = React.useState(
45
+ (initialValue ?? '').length,
46
+ )
47
+
48
+ useInput((input, key) => {
49
+ if (key.escape) {
50
+ onCancel()
51
+ return
52
+ }
53
+
54
+ if (key.return) {
55
+ onSubmit(value.trim())
56
+ return
57
+ }
58
+
59
+ if (key.leftArrow) {
60
+ setCursorOffset(current => Math.max(0, current - 1))
61
+ return
62
+ }
63
+
64
+ if (key.rightArrow) {
65
+ setCursorOffset(current => Math.min(value.length, current + 1))
66
+ return
67
+ }
68
+
69
+ if (key.backspace || key.delete) {
70
+ if (cursorOffset === 0) return
71
+ const nextValue =
72
+ value.slice(0, cursorOffset - 1) + value.slice(cursorOffset)
73
+ setValue(nextValue)
74
+ setCursorOffset(current => Math.max(0, current - 1))
75
+ return
76
+ }
77
+
78
+ if (input) {
79
+ const nextValue =
80
+ value.slice(0, cursorOffset) + input + value.slice(cursorOffset)
81
+ setValue(nextValue)
82
+ setCursorOffset(current => current + input.length)
83
+ }
84
+ })
85
+
86
+ const displayValue = masked ? '*'.repeat(value.length) : value
87
+ const cursorChar =
88
+ cursorOffset < displayValue.length ? displayValue[cursorOffset] : ' '
89
+
90
+ return (
91
+ <Box flexDirection="column" gap={1}>
92
+ <Text bold>{title}</Text>
93
+ <Text dimColor>{hint}</Text>
94
+ <Box flexDirection="column" marginTop={1}>
95
+ <Text dimColor>Value:</Text>
96
+ <Box>
97
+ <Text>{displayValue.slice(0, cursorOffset)}</Text>
98
+ <Text backgroundColor="white" color="black">
99
+ {cursorChar}
100
+ </Text>
101
+ <Text>{displayValue.slice(cursorOffset + 1)}</Text>
102
+ </Box>
103
+ </Box>
104
+ <Text dimColor>[Enter] 保存 · [Esc] 返回</Text>
105
+ </Box>
106
+ )
107
+ }
108
+
109
+ function ConfirmClear({
110
+ onConfirm,
111
+ onCancel,
112
+ }: {
113
+ onConfirm: () => void
114
+ onCancel: () => void
115
+ }) {
116
+ useInput((_, key) => {
117
+ if (key.escape) {
118
+ onCancel()
119
+ return
120
+ }
121
+
122
+ if (key.return) {
123
+ onConfirm()
124
+ }
125
+ })
126
+
127
+ return (
128
+ <Box flexDirection="column" gap={1}>
129
+ <Text color="yellow">这会清空 Telegram Bot Token 和 user id 白名单。</Text>
130
+ <Text dimColor>
131
+ 如果当前 bot 正在运行,会一并停止。按 Enter 确认,Esc 返回。
132
+ </Text>
133
+ </Box>
134
+ )
135
+ }
136
+
137
+ function TelegramDialog({
138
+ onDone,
139
+ }: {
140
+ onDone: LocalJSXCommandOnDone
141
+ }) {
142
+ const serviceState = React.useSyncExternalStore(
143
+ telegramService.subscribe,
144
+ telegramService.getStateSnapshot,
145
+ )
146
+ const [config, setConfig] = React.useState(() => getTelegramConfig())
147
+ const [step, setStep] = React.useState<Step>({ type: 'menu' })
148
+ const [notice, setNotice] = React.useState<Notice | null>(null)
149
+ const [focusIndex, setFocusIndex] = React.useState(0)
150
+ const [busy, setBusy] = React.useState(false)
151
+
152
+ const menuItems = React.useMemo(
153
+ () => [
154
+ {
155
+ label: '设置 Bot Token',
156
+ action: () => setStep({ type: 'edit-token' }),
157
+ },
158
+ {
159
+ label: '设置允许访问的 Telegram user id',
160
+ action: () => setStep({ type: 'edit-user-ids' }),
161
+ },
162
+ {
163
+ label:
164
+ serviceState.status === 'running' ? '停止当前 Telegram Bot' : '启动当前 Telegram Bot',
165
+ action: async () => {
166
+ setBusy(true)
167
+ try {
168
+ if (serviceState.status === 'running') {
169
+ await telegramService.stop()
170
+ setNotice({ text: 'Telegram Bot 已停止。', tone: 'success' })
171
+ } else {
172
+ await telegramService.startFromSavedConfig()
173
+ setNotice({
174
+ text: 'Telegram Bot 已启动,可以在 Telegram 私聊中发送消息。',
175
+ tone: 'success',
176
+ })
177
+ }
178
+ } catch (error) {
179
+ setNotice({
180
+ text: error instanceof Error ? error.message : '启动 Telegram Bot 失败',
181
+ tone: 'error',
182
+ })
183
+ } finally {
184
+ setBusy(false)
185
+ }
186
+ },
187
+ },
188
+ {
189
+ label: '清空 Telegram 配置',
190
+ action: () => setStep({ type: 'confirm-clear' }),
191
+ },
192
+ {
193
+ label: '关闭',
194
+ action: () => onDone(undefined, { display: 'skip' }),
195
+ },
196
+ ],
197
+ [onDone, serviceState.status],
198
+ )
199
+
200
+ React.useEffect(() => {
201
+ setFocusIndex(current => Math.min(current, menuItems.length - 1))
202
+ }, [menuItems.length])
203
+
204
+ const refreshConfig = React.useCallback(() => {
205
+ setConfig(getTelegramConfig())
206
+ }, [])
207
+
208
+ const maybeRestartService = React.useCallback(async () => {
209
+ if (serviceState.status !== 'running') return
210
+ await telegramService.restartFromSavedConfig()
211
+ }, [serviceState.status])
212
+
213
+ const saveToken = React.useCallback(
214
+ async (value: string) => {
215
+ if (!value) {
216
+ setNotice({ text: 'Bot Token 不能为空。', tone: 'error' })
217
+ setStep({ type: 'menu' })
218
+ return
219
+ }
220
+
221
+ setBusy(true)
222
+ try {
223
+ saveTelegramConfig({ botToken: value })
224
+ refreshConfig()
225
+ await maybeRestartService()
226
+ setNotice({ text: 'Bot Token 已保存。', tone: 'success' })
227
+ } catch (error) {
228
+ setNotice({
229
+ text: error instanceof Error ? error.message : '保存 Bot Token 失败',
230
+ tone: 'error',
231
+ })
232
+ } finally {
233
+ setBusy(false)
234
+ setStep({ type: 'menu' })
235
+ }
236
+ },
237
+ [maybeRestartService, refreshConfig],
238
+ )
239
+
240
+ const saveUserIds = React.useCallback(
241
+ async (value: string) => {
242
+ const userIds = parseAllowedTelegramUserIds(value)
243
+ if (userIds.length === 0) {
244
+ setNotice({
245
+ text: '至少需要一个有效的 Telegram user id。',
246
+ tone: 'error',
247
+ })
248
+ setStep({ type: 'menu' })
249
+ return
250
+ }
251
+
252
+ setBusy(true)
253
+ try {
254
+ saveTelegramConfig({ allowedUserIds: userIds })
255
+ refreshConfig()
256
+ await maybeRestartService()
257
+ setNotice({ text: 'Telegram user id 白名单已保存。', tone: 'success' })
258
+ } catch (error) {
259
+ setNotice({
260
+ text: error instanceof Error ? error.message : '保存 Telegram user id 失败',
261
+ tone: 'error',
262
+ })
263
+ } finally {
264
+ setBusy(false)
265
+ setStep({ type: 'menu' })
266
+ }
267
+ },
268
+ [maybeRestartService, refreshConfig],
269
+ )
270
+
271
+ const clearConfigAndStop = React.useCallback(async () => {
272
+ setBusy(true)
273
+ try {
274
+ await telegramService.stop()
275
+ clearTelegramConfig()
276
+ refreshConfig()
277
+ setNotice({ text: 'Telegram 配置已清空。', tone: 'success' })
278
+ } catch (error) {
279
+ setNotice({
280
+ text: error instanceof Error ? error.message : '清空 Telegram 配置失败',
281
+ tone: 'error',
282
+ })
283
+ } finally {
284
+ setBusy(false)
285
+ setStep({ type: 'menu' })
286
+ }
287
+ }, [refreshConfig])
288
+
289
+ useInput((_, key) => {
290
+ if (step.type !== 'menu' || busy) return
291
+
292
+ if (key.escape) {
293
+ onDone(undefined, { display: 'skip' })
294
+ return
295
+ }
296
+
297
+ if (key.upArrow) {
298
+ setFocusIndex(current => (current - 1 + menuItems.length) % menuItems.length)
299
+ return
300
+ }
301
+
302
+ if (key.downArrow) {
303
+ setFocusIndex(current => (current + 1) % menuItems.length)
304
+ return
305
+ }
306
+
307
+ if (key.return) {
308
+ void menuItems[focusIndex]?.action()
309
+ }
310
+ })
311
+
312
+ const noticeColor =
313
+ notice?.tone === 'error'
314
+ ? 'red'
315
+ : notice?.tone === 'success'
316
+ ? 'green'
317
+ : 'cyan'
318
+
319
+ if (step.type === 'edit-token') {
320
+ return (
321
+ <Dialog title="Telegram Bot Token" onCancel={() => setStep({ type: 'menu' })}>
322
+ <TextInput
323
+ title="Bot Token"
324
+ hint="从 BotFather 获取 token。保存后,如当前 bot 正在运行会自动重启。"
325
+ initialValue={config?.botToken}
326
+ masked={true}
327
+ onSubmit={value => {
328
+ void saveToken(value)
329
+ }}
330
+ onCancel={() => setStep({ type: 'menu' })}
331
+ />
332
+ </Dialog>
333
+ )
334
+ }
335
+
336
+ if (step.type === 'edit-user-ids') {
337
+ return (
338
+ <Dialog
339
+ title="Telegram User IDs"
340
+ onCancel={() => setStep({ type: 'menu' })}
341
+ >
342
+ <TextInput
343
+ title="允许访问的 Telegram user id"
344
+ hint="可输入一个或多个 user id,支持空格或逗号分隔。可通过 @userinfobot 查询。"
345
+ initialValue={formatAllowedTelegramUserIds(config?.allowedUserIds)}
346
+ onSubmit={value => {
347
+ void saveUserIds(value)
348
+ }}
349
+ onCancel={() => setStep({ type: 'menu' })}
350
+ />
351
+ </Dialog>
352
+ )
353
+ }
354
+
355
+ if (step.type === 'confirm-clear') {
356
+ return (
357
+ <Dialog title="Clear Telegram Config" onCancel={() => setStep({ type: 'menu' })}>
358
+ <ConfirmClear
359
+ onConfirm={() => {
360
+ void clearConfigAndStop()
361
+ }}
362
+ onCancel={() => setStep({ type: 'menu' })}
363
+ />
364
+ </Dialog>
365
+ )
366
+ }
367
+
368
+ return (
369
+ <Dialog title="Telegram" onCancel={() => onDone(undefined, { display: 'skip' })}>
370
+ <Box flexDirection="column" gap={1}>
371
+ <Text dimColor>
372
+ 在当前 VersperClaw 进程内启动 Telegram 长轮询,并把私聊消息接入当前会话。
373
+ </Text>
374
+ <Text>
375
+ 状态:
376
+ {' '}
377
+ <Text color={serviceState.status === 'running' ? 'green' : 'yellow'}>
378
+ {serviceState.status === 'running'
379
+ ? '运行中'
380
+ : serviceState.status === 'starting'
381
+ ? '启动中'
382
+ : '未启动'}
383
+ </Text>
384
+ </Text>
385
+ <Text>Bot Token: {maskTelegramToken(config?.botToken)}</Text>
386
+ <Text>
387
+ 允许 user id:
388
+ {' '}
389
+ {config?.allowedUserIds?.length
390
+ ? config.allowedUserIds.join(', ')
391
+ : '未配置'}
392
+ </Text>
393
+ <Text>
394
+ 当前 bot:
395
+ {' '}
396
+ {serviceState.botUsername
397
+ ? `@${serviceState.botUsername}`
398
+ : '尚未连接 Telegram'}
399
+ </Text>
400
+ <Text>
401
+ 最近会话:
402
+ {' '}
403
+ {serviceState.activeUserId && serviceState.activeChatId
404
+ ? `user ${serviceState.activeUserId} / chat ${serviceState.activeChatId}`
405
+ : '暂无'}
406
+ </Text>
407
+ {serviceState.lastError ? (
408
+ <Text color="red">最近错误: {serviceState.lastError}</Text>
409
+ ) : null}
410
+ {notice ? <Text color={noticeColor}>{notice.text}</Text> : null}
411
+ {busy ? <Text dimColor>处理中,请稍候...</Text> : null}
412
+ <Box flexDirection="column" marginTop={1}>
413
+ {menuItems.map((item, index) => (
414
+ <ListItem key={item.label} isFocused={focusIndex === index}>
415
+ <Text>{item.label}</Text>
416
+ </ListItem>
417
+ ))}
418
+ </Box>
419
+ <Text dimColor>上下箭头选择,Enter 执行,Esc 关闭</Text>
420
+ </Box>
421
+ </Dialog>
422
+ )
423
+ }
424
+
425
+ export async function call(
426
+ onDone: LocalJSXCommandOnDone,
427
+ _context: LocalJSXCommandContext,
428
+ ): Promise<React.ReactNode> {
429
+ return <TelegramDialog onDone={onDone} />
430
+ }
431
+
432
+ export default call
src/hooks/useTelegramBridge.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react'
2
+ import { telegramService } from '../services/telegram/TelegramService.js'
3
+ import {
4
+ TELEGRAM_CHANNEL_SERVER,
5
+ type TelegramInboundEvent,
6
+ } from '../services/telegram/telegramTypes.js'
7
+ import type { Message } from '../types/message.js'
8
+ import { enqueue } from '../utils/messageQueueManager.js'
9
+ import { getContentText } from '../utils/messages.js'
10
+ import { logForDebugging } from '../utils/debug.js'
11
+
12
+ type Props = {
13
+ messages: Message[]
14
+ isLoading: boolean
15
+ }
16
+
17
+ type ActiveTelegramTurn = {
18
+ chatId: string
19
+ lastAssistantText?: string
20
+ }
21
+
22
+ export function useTelegramBridge({ messages, isLoading }: Props): void {
23
+ const pendingInboundRef = useRef<TelegramInboundEvent[]>([])
24
+ const activeTurnRef = useRef<ActiveTelegramTurn | null>(null)
25
+ const lastProcessedMessageCountRef = useRef(messages.length)
26
+ const previousLoadingRef = useRef(isLoading)
27
+
28
+ useEffect(() => {
29
+ return telegramService.subscribeToInbound(event => {
30
+ pendingInboundRef.current.push(event)
31
+ enqueue({
32
+ value: event.text,
33
+ mode: 'prompt',
34
+ skipSlashCommands: true,
35
+ origin: {
36
+ kind: 'channel',
37
+ server: TELEGRAM_CHANNEL_SERVER,
38
+ } as const,
39
+ })
40
+ })
41
+ }, [])
42
+
43
+ useEffect(() => {
44
+ const newMessages = messages.slice(lastProcessedMessageCountRef.current)
45
+
46
+ for (const message of newMessages) {
47
+ if (
48
+ message.type === 'user' &&
49
+ message.origin?.kind === 'channel' &&
50
+ message.origin.server === TELEGRAM_CHANNEL_SERVER
51
+ ) {
52
+ const inbound = pendingInboundRef.current.shift()
53
+ if (inbound) {
54
+ activeTurnRef.current = {
55
+ chatId: inbound.chatId,
56
+ }
57
+ }
58
+ continue
59
+ }
60
+
61
+ if (message.type === 'assistant' && activeTurnRef.current) {
62
+ const text = getContentText(message.message.content)
63
+ if (text) {
64
+ activeTurnRef.current.lastAssistantText = text
65
+ }
66
+ }
67
+ }
68
+
69
+ lastProcessedMessageCountRef.current = messages.length
70
+ }, [messages])
71
+
72
+ useEffect(() => {
73
+ const wasLoading = previousLoadingRef.current
74
+ previousLoadingRef.current = isLoading
75
+
76
+ if (!wasLoading || isLoading || !activeTurnRef.current) {
77
+ return
78
+ }
79
+
80
+ const { chatId, lastAssistantText } = activeTurnRef.current
81
+ activeTurnRef.current = null
82
+
83
+ void telegramService
84
+ .sendMessage(
85
+ chatId,
86
+ lastAssistantText ?? '这一轮没有可回传的文本结果,请查看本地终端会话。',
87
+ )
88
+ .catch(error => {
89
+ logForDebugging(
90
+ `[telegram] failed to send outbound reply: ${
91
+ error instanceof Error ? error.message : String(error)
92
+ }`,
93
+ { level: 'error' },
94
+ )
95
+ })
96
+ }, [isLoading])
97
+ }
src/screens/REPL.tsx CHANGED
@@ -141,6 +141,7 @@ import { gracefulShutdownSync } from '../utils/gracefulShutdown.js';
141
  import { handlePromptSubmit, type PromptInputHelpers } from '../utils/handlePromptSubmit.js';
142
  import { useQueueProcessor } from '../hooks/useQueueProcessor.js';
143
  import { useMailboxBridge } from '../hooks/useMailboxBridge.js';
 
144
  import { queryCheckpoint, logQueryProfileReport } from '../utils/queryProfiler.js';
145
  import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection } from '../types/message.js';
146
  import { query } from '../query.js';
 
141
  import { handlePromptSubmit, type PromptInputHelpers } from '../utils/handlePromptSubmit.js';
142
  import { useQueueProcessor } from '../hooks/useQueueProcessor.js';
143
  import { useMailboxBridge } from '../hooks/useMailboxBridge.js';
144
+ import { useTelegramBridge } from '../hooks/useTelegramBridge.js';
145
  import { queryCheckpoint, logQueryProfileReport } from '../utils/queryProfiler.js';
146
  import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection } from '../types/message.js';
147
  import { query } from '../query.js';
src/services/telegram/TelegramService.ts ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { logForDebugging } from '../../utils/debug.js'
2
+ import { getTelegramRuntimeConfig } from './telegramConfig.js'
3
+ import type {
4
+ TelegramGetMeResponse,
5
+ TelegramGetUpdatesResponse,
6
+ TelegramInboundEvent,
7
+ TelegramRuntimeConfig,
8
+ TelegramSendMessageResponse,
9
+ TelegramServiceState,
10
+ TelegramUpdate,
11
+ } from './telegramTypes.js'
12
+
13
+ type Listener = () => void
14
+ type InboundListener = (event: TelegramInboundEvent) => void
15
+
16
+ const TELEGRAM_API_BASE = 'https://api.telegram.org'
17
+ const MAX_TELEGRAM_MESSAGE_LENGTH = 4000
18
+ const POLL_TIMEOUT_SECONDS = 25
19
+ const RETRY_DELAY_MS = 3000
20
+
21
+ function sleep(ms: number): Promise<void> {
22
+ return new Promise(resolve => setTimeout(resolve, ms))
23
+ }
24
+
25
+ function normalizeTelegramError(error: unknown): string {
26
+ if (error instanceof Error) return error.message
27
+ return String(error)
28
+ }
29
+
30
+ function chunkTelegramMessage(text: string): string[] {
31
+ const normalized = text.trim()
32
+ if (!normalized) return ['模型本轮没有返回可发送的文本结果。']
33
+
34
+ const chunks: string[] = []
35
+ for (let i = 0; i < normalized.length; i += MAX_TELEGRAM_MESSAGE_LENGTH) {
36
+ chunks.push(normalized.slice(i, i + MAX_TELEGRAM_MESSAGE_LENGTH))
37
+ }
38
+ return chunks
39
+ }
40
+
41
+ function hasSameConfig(
42
+ left: TelegramRuntimeConfig | undefined,
43
+ right: TelegramRuntimeConfig,
44
+ ): boolean {
45
+ if (!left) return false
46
+ return (
47
+ left.botToken === right.botToken &&
48
+ left.allowedUserIds.join(',') === right.allowedUserIds.join(',')
49
+ )
50
+ }
51
+
52
+ class TelegramService {
53
+ private listeners = new Set<Listener>()
54
+ private inboundListeners = new Set<InboundListener>()
55
+ private state: TelegramServiceState = { status: 'stopped' }
56
+ private config?: TelegramRuntimeConfig
57
+ private abortController: AbortController | null = null
58
+ private runId = 0
59
+ private nextUpdateOffset: number | undefined
60
+
61
+ subscribe = (listener: Listener): (() => void) => {
62
+ this.listeners.add(listener)
63
+ return () => {
64
+ this.listeners.delete(listener)
65
+ }
66
+ }
67
+
68
+ subscribeToInbound = (listener: InboundListener): (() => void) => {
69
+ this.inboundListeners.add(listener)
70
+ return () => {
71
+ this.inboundListeners.delete(listener)
72
+ }
73
+ }
74
+
75
+ getStateSnapshot = (): TelegramServiceState => this.state
76
+
77
+ async start(config: TelegramRuntimeConfig): Promise<void> {
78
+ if (this.state.status === 'running' && hasSameConfig(this.config, config)) {
79
+ return
80
+ }
81
+
82
+ await this.stop()
83
+
84
+ const runId = ++this.runId
85
+ const abortController = new AbortController()
86
+ this.abortController = abortController
87
+ this.config = config
88
+ this.nextUpdateOffset = undefined
89
+
90
+ this.setState({
91
+ status: 'starting',
92
+ lastError: undefined,
93
+ botUsername: undefined,
94
+ botDisplayName: undefined,
95
+ startedAt: undefined,
96
+ activeChatId: undefined,
97
+ activeUserId: undefined,
98
+ })
99
+
100
+ try {
101
+ const response = await this.callTelegram<TelegramGetMeResponse>(
102
+ config,
103
+ 'getMe',
104
+ {},
105
+ abortController.signal,
106
+ )
107
+
108
+ if (runId !== this.runId || abortController.signal.aborted) return
109
+
110
+ this.setState({
111
+ status: 'running',
112
+ botUsername: response.result?.username,
113
+ botDisplayName: response.result?.first_name,
114
+ startedAt: new Date().toISOString(),
115
+ lastError: undefined,
116
+ })
117
+
118
+ logForDebugging(
119
+ `[telegram] connected as @${response.result?.username ?? 'unknown'}`,
120
+ )
121
+
122
+ void this.pollLoop(runId, config, abortController.signal)
123
+ } catch (error) {
124
+ if (abortController.signal.aborted || runId !== this.runId) return
125
+ const message = normalizeTelegramError(error)
126
+ this.setState({
127
+ status: 'stopped',
128
+ lastError: message,
129
+ })
130
+ throw error
131
+ }
132
+ }
133
+
134
+ async startFromSavedConfig(): Promise<void> {
135
+ await this.start(getTelegramRuntimeConfig())
136
+ }
137
+
138
+ async restartFromSavedConfig(): Promise<void> {
139
+ await this.startFromSavedConfig()
140
+ }
141
+
142
+ async stop(): Promise<void> {
143
+ this.runId++
144
+ this.abortController?.abort()
145
+ this.abortController = null
146
+ this.config = undefined
147
+ this.nextUpdateOffset = undefined
148
+
149
+ if (this.state.status !== 'stopped') {
150
+ this.setState({
151
+ ...this.state,
152
+ status: 'stopped',
153
+ startedAt: undefined,
154
+ })
155
+ }
156
+ }
157
+
158
+ async sendMessage(chatId: string, text: string): Promise<void> {
159
+ if (!this.config) {
160
+ throw new Error('Telegram service is not running')
161
+ }
162
+
163
+ for (const chunk of chunkTelegramMessage(text)) {
164
+ await this.callTelegram<TelegramSendMessageResponse>(
165
+ this.config,
166
+ 'sendMessage',
167
+ {
168
+ chat_id: Number(chatId),
169
+ text: chunk,
170
+ },
171
+ )
172
+ }
173
+ }
174
+
175
+ private setState(nextState: TelegramServiceState): void {
176
+ this.state = nextState
177
+ for (const listener of this.listeners) {
178
+ listener()
179
+ }
180
+ }
181
+
182
+ private patchState(patch: Partial<TelegramServiceState>): void {
183
+ this.setState({
184
+ ...this.state,
185
+ ...patch,
186
+ })
187
+ }
188
+
189
+ private emitInbound(event: TelegramInboundEvent): void {
190
+ for (const listener of this.inboundListeners) {
191
+ listener(event)
192
+ }
193
+ }
194
+
195
+ private async pollLoop(
196
+ runId: number,
197
+ config: TelegramRuntimeConfig,
198
+ signal: AbortSignal,
199
+ ): Promise<void> {
200
+ while (!signal.aborted && runId === this.runId) {
201
+ try {
202
+ const response = await this.callTelegram<TelegramGetUpdatesResponse>(
203
+ config,
204
+ 'getUpdates',
205
+ {
206
+ offset: this.nextUpdateOffset,
207
+ timeout: POLL_TIMEOUT_SECONDS,
208
+ allowed_updates: ['message'],
209
+ },
210
+ signal,
211
+ )
212
+
213
+ if (signal.aborted || runId !== this.runId) return
214
+
215
+ if (this.state.lastError) {
216
+ this.patchState({ lastError: undefined })
217
+ }
218
+
219
+ for (const update of response.result ?? []) {
220
+ this.handleUpdate(update, config)
221
+ }
222
+ } catch (error) {
223
+ if (signal.aborted || runId !== this.runId) return
224
+
225
+ const message = normalizeTelegramError(error)
226
+ logForDebugging(`[telegram] polling failed: ${message}`, {
227
+ level: 'error',
228
+ })
229
+ this.patchState({ lastError: message })
230
+ await sleep(RETRY_DELAY_MS)
231
+ }
232
+ }
233
+ }
234
+
235
+ private handleUpdate(
236
+ update: TelegramUpdate,
237
+ config: TelegramRuntimeConfig,
238
+ ): void {
239
+ this.nextUpdateOffset = update.update_id + 1
240
+
241
+ const message = update.message
242
+ const chatId = message?.chat?.id
243
+ const userId = message?.from?.id
244
+
245
+ if (!message || chatId === undefined || userId === undefined) return
246
+ if (message.from?.is_bot) return
247
+ if (message.chat?.type !== 'private') return
248
+
249
+ const normalizedChatId = String(chatId)
250
+ const normalizedUserId = String(userId)
251
+
252
+ if (!config.allowedUserIds.includes(normalizedUserId)) {
253
+ void this.sendMessage(
254
+ normalizedChatId,
255
+ '这个 Telegram user id 尚未被当前 /telegram 配置授权。',
256
+ ).catch(() => {})
257
+ return
258
+ }
259
+
260
+ this.patchState({
261
+ activeChatId: normalizedChatId,
262
+ activeUserId: normalizedUserId,
263
+ })
264
+
265
+ const text = message.text?.trim()
266
+
267
+ if (!text) {
268
+ void this.sendMessage(
269
+ normalizedChatId,
270
+ '当前只支持文本消息,请发送纯文本内容。',
271
+ ).catch(() => {})
272
+ return
273
+ }
274
+
275
+ if (text === '/start') {
276
+ void this.sendMessage(
277
+ normalizedChatId,
278
+ 'Telegram 已连接到当前 VersperClaw 会话。直接发送文本即可开始远程对话。',
279
+ ).catch(() => {})
280
+ return
281
+ }
282
+
283
+ this.emitInbound({
284
+ kind: 'inbound-message',
285
+ chatId: normalizedChatId,
286
+ userId: normalizedUserId,
287
+ text,
288
+ messageId: message.message_id,
289
+ updateId: update.update_id,
290
+ })
291
+ }
292
+
293
+ private async callTelegram<T>(
294
+ config: TelegramRuntimeConfig,
295
+ method: string,
296
+ payload: Record<string, unknown>,
297
+ signal?: AbortSignal,
298
+ ): Promise<T> {
299
+ const response = await fetch(
300
+ `${TELEGRAM_API_BASE}/bot${config.botToken}/${method}`,
301
+ {
302
+ method: 'POST',
303
+ headers: {
304
+ 'content-type': 'application/json',
305
+ },
306
+ body: JSON.stringify(payload),
307
+ signal,
308
+ },
309
+ )
310
+
311
+ if (!response.ok) {
312
+ throw new Error(`Telegram API ${method} failed with HTTP ${response.status}`)
313
+ }
314
+
315
+ const json = await response.json() as {
316
+ ok?: boolean
317
+ description?: string
318
+ }
319
+
320
+ if (!json.ok) {
321
+ throw new Error(
322
+ `Telegram API ${method} failed: ${json.description ?? 'unknown error'}`,
323
+ )
324
+ }
325
+
326
+ return json as T
327
+ }
328
+ }
329
+
330
+ export const telegramService = new TelegramService()
src/services/telegram/telegramConfig.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getGlobalConfig,
3
+ saveGlobalConfig,
4
+ type TelegramConfig,
5
+ } from '../../utils/config.js'
6
+ import type {
7
+ TelegramConfigDraft,
8
+ TelegramRuntimeConfig,
9
+ } from './telegramTypes.js'
10
+
11
+ export function getTelegramConfig(): TelegramConfig | undefined {
12
+ return getGlobalConfig().telegram
13
+ }
14
+
15
+ export function saveTelegramConfig(updates: TelegramConfigDraft): void {
16
+ saveGlobalConfig(current => ({
17
+ ...current,
18
+ telegram: {
19
+ ...(current.telegram ?? {}),
20
+ ...updates,
21
+ updatedAt: new Date().toISOString(),
22
+ },
23
+ }))
24
+ }
25
+
26
+ export function clearTelegramConfig(): void {
27
+ saveGlobalConfig(current => ({
28
+ ...current,
29
+ telegram: undefined,
30
+ }))
31
+ }
32
+
33
+ export function maskTelegramToken(token?: string): string {
34
+ if (!token) return '未配置'
35
+ if (token.length <= 12) return `${token.slice(0, 4)}...`
36
+ return `${token.slice(0, 8)}...${token.slice(-4)}`
37
+ }
38
+
39
+ export function parseAllowedTelegramUserIds(raw: string): string[] {
40
+ return [...new Set(
41
+ raw
42
+ .split(/[\s,]+/)
43
+ .map(part => part.trim())
44
+ .filter(Boolean)
45
+ .filter(part => /^\d+$/.test(part)),
46
+ )]
47
+ }
48
+
49
+ export function formatAllowedTelegramUserIds(userIds?: string[]): string {
50
+ if (!userIds || userIds.length === 0) return ''
51
+ return userIds.join(', ')
52
+ }
53
+
54
+ export function getTelegramRuntimeConfig(): TelegramRuntimeConfig {
55
+ const telegram = getTelegramConfig()
56
+ const botToken = telegram?.botToken?.trim()
57
+ const allowedUserIds = telegram?.allowedUserIds?.filter(Boolean) ?? []
58
+
59
+ if (!botToken) {
60
+ throw new Error('请先在 /telegram 中配置 Bot Token')
61
+ }
62
+
63
+ if (allowedUserIds.length === 0) {
64
+ throw new Error('请先在 /telegram 中配置允许访问的 Telegram user id')
65
+ }
66
+
67
+ return {
68
+ botToken,
69
+ allowedUserIds,
70
+ }
71
+ }
src/services/telegram/telegramTypes.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { TelegramConfig } from '../../utils/config.js'
2
+
3
+ export const TELEGRAM_CHANNEL_SERVER = 'telegram'
4
+
5
+ export type TelegramRuntimeConfig = {
6
+ botToken: string
7
+ allowedUserIds: string[]
8
+ }
9
+
10
+ export type TelegramServiceStatus = 'stopped' | 'starting' | 'running'
11
+
12
+ export type TelegramServiceState = {
13
+ status: TelegramServiceStatus
14
+ botUsername?: string
15
+ botDisplayName?: string
16
+ lastError?: string
17
+ startedAt?: string
18
+ activeChatId?: string
19
+ activeUserId?: string
20
+ }
21
+
22
+ export type TelegramInboundEvent = {
23
+ kind: 'inbound-message'
24
+ chatId: string
25
+ userId: string
26
+ text: string
27
+ messageId: number
28
+ updateId: number
29
+ }
30
+
31
+ export type TelegramUpdate = {
32
+ update_id: number
33
+ message?: {
34
+ message_id: number
35
+ text?: string
36
+ chat?: {
37
+ id?: number
38
+ type?: string
39
+ }
40
+ from?: {
41
+ id?: number
42
+ is_bot?: boolean
43
+ }
44
+ }
45
+ }
46
+
47
+ export type TelegramGetMeResponse = {
48
+ ok: boolean
49
+ description?: string
50
+ result?: {
51
+ id: number
52
+ is_bot: boolean
53
+ first_name?: string
54
+ username?: string
55
+ }
56
+ }
57
+
58
+ export type TelegramGetUpdatesResponse = {
59
+ ok: boolean
60
+ description?: string
61
+ result?: TelegramUpdate[]
62
+ }
63
+
64
+ export type TelegramSendMessageResponse = {
65
+ ok: boolean
66
+ description?: string
67
+ }
68
+
69
+ export type TelegramConfigDraft = Partial<TelegramConfig>
src/utils/config.ts CHANGED
@@ -590,6 +590,18 @@ export type GlobalConfig = {
590
  // CURRENT_MIGRATION_VERSION, runMigrations() skips all sync migrations
591
  // (avoiding 11× saveGlobalConfig lock+re-read on every startup).
592
  migrationVersion?: number
 
 
 
 
 
 
 
 
 
 
 
 
593
  }
594
 
595
  /**
 
590
  // CURRENT_MIGRATION_VERSION, runMigrations() skips all sync migrations
591
  // (avoiding 11× saveGlobalConfig lock+re-read on every startup).
592
  migrationVersion?: number
593
+
594
+ // Telegram bot bridge configuration (/telegram)
595
+ telegram?: TelegramConfig
596
+ }
597
+
598
+ export type TelegramConfig = {
599
+ botToken?: string
600
+ allowedUserIds?: string[]
601
+ autoStart?: boolean
602
+ lastAuthorizedUserId?: string
603
+ lastChatId?: string
604
+ updatedAt?: string
605
  }
606
 
607
  /**