feat: remote-control live DSH Web sessions from Telegram
Attach via /sessions picker instead of creating parallel agents; mirror the same session trajectory to phone and Web (Codex-style remote). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+151
-81
@@ -1,11 +1,16 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
||||
import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { isAuthorized } from './auth.js'
|
||||
import { TelegramClient, type TelegramClientLike, type TelegramUpdate } from './client.js'
|
||||
import { MSG, parseCommand } from './commands.js'
|
||||
import {
|
||||
TelegramClient,
|
||||
type InlineKeyboardMarkup,
|
||||
type TelegramClientLike,
|
||||
type TelegramUpdate,
|
||||
} from './client.js'
|
||||
import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js'
|
||||
import { markdownToHtml, splitMessage } from './format.js'
|
||||
|
||||
export interface TelegramBridgeOptions {
|
||||
@@ -14,17 +19,15 @@ export interface TelegramBridgeOptions {
|
||||
allowAllUsers: boolean
|
||||
client?: TelegramClientLike
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
provider?: string
|
||||
model?: string
|
||||
maxMessageLength?: number
|
||||
pollingTimeoutSec?: number
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
/** chatId → bound live session id (string form of SessionId) */
|
||||
interface Binding {
|
||||
chatId: number
|
||||
handle: AgentHandle
|
||||
sessionId: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SessionLike {
|
||||
@@ -40,6 +43,15 @@ function contentToText(content: readonly ContentBlock[]): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
function shortLabel(agent: Agent, index: number): string {
|
||||
const id = String(agent.id)
|
||||
const tail = id.length > 12 ? id.slice(-12) : id
|
||||
const cwd = (agent.session as { meta?: { cwd?: string } } | undefined)?.meta?.cwd
|
||||
const cwdBit = cwd ? cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : ''
|
||||
const base = cwdBit ? `${cwdBit} · ${tail}` : `session ${index + 1} · ${tail}`
|
||||
return base.length > 60 ? `${base.slice(0, 57)}...` : base
|
||||
}
|
||||
|
||||
export class TelegramBridge {
|
||||
private readonly ctx: Context
|
||||
private readonly token: string
|
||||
@@ -47,12 +59,9 @@ export class TelegramBridge {
|
||||
private readonly allowAllUsers: boolean
|
||||
private readonly client: TelegramClientLike
|
||||
private readonly sleep: (ms: number) => Promise<void>
|
||||
private readonly provider: string
|
||||
private readonly model: string
|
||||
private readonly maxMessageLength: number
|
||||
private readonly cwd: string
|
||||
|
||||
private readonly chats = new Map<string, ChatState>()
|
||||
private readonly bindings = new Map<string, Binding>()
|
||||
private polling = false
|
||||
private offset: number | undefined
|
||||
private pollPromise: Promise<void> | undefined
|
||||
@@ -68,10 +77,7 @@ export class TelegramBridge {
|
||||
pollingTimeoutSec: options.pollingTimeoutSec ?? 30,
|
||||
})
|
||||
this.sleep = options.sleep ?? defaultSleep
|
||||
this.provider = options.provider ?? 'deepseek-official'
|
||||
this.model = options.model ?? 'deepseek-v4-flash'
|
||||
this.maxMessageLength = options.maxMessageLength ?? 4096
|
||||
this.cwd = options.cwd ?? process.cwd()
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -94,9 +100,8 @@ export class TelegramBridge {
|
||||
this.pollAbort = undefined
|
||||
this.disposeSessionListener?.()
|
||||
this.disposeSessionListener = undefined
|
||||
const disposals = [...this.chats.values()].map((chat) => chat.handle.dispose())
|
||||
this.chats.clear()
|
||||
await Promise.all(disposals)
|
||||
// Never dispose host agents — only clear remote bindings.
|
||||
this.bindings.clear()
|
||||
if (this.pollPromise) {
|
||||
await this.pollPromise.catch(() => {})
|
||||
this.pollPromise = undefined
|
||||
@@ -104,6 +109,10 @@ export class TelegramBridge {
|
||||
}
|
||||
|
||||
async processUpdate(update: TelegramUpdate): Promise<void> {
|
||||
if (update.callback_query) {
|
||||
await this.handleCallback(update)
|
||||
return
|
||||
}
|
||||
const message = update.message
|
||||
if (!message?.text) return
|
||||
|
||||
@@ -123,20 +132,133 @@ export class TelegramBridge {
|
||||
case 'help':
|
||||
await this.client.sendMessage(chatId, MSG.HELP)
|
||||
return
|
||||
case 'new':
|
||||
await this.resetChat(chatId)
|
||||
await this.client.sendMessage(chatId, MSG.NEW_SESSION)
|
||||
case 'sessions':
|
||||
await this.sendSessionPicker(chatId)
|
||||
return
|
||||
case 'status':
|
||||
await this.sendStatus(chatId)
|
||||
return
|
||||
case 'unbind':
|
||||
this.bindings.delete(String(chatId))
|
||||
await this.client.sendMessage(chatId, MSG.UNBOUND)
|
||||
return
|
||||
case 'unknown':
|
||||
await this.client.sendMessage(chatId, MSG.unknown(parsed.command))
|
||||
return
|
||||
case 'plain':
|
||||
await this.ensureChat(chatId)
|
||||
await this.followup(chatId, parsed.text)
|
||||
await this.followupBound(chatId, parsed.text)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCallback(update: TelegramUpdate): Promise<void> {
|
||||
const cq = update.callback_query!
|
||||
const userId = cq.from.id
|
||||
const chatId = cq.message?.chat.id
|
||||
if (chatId === undefined) {
|
||||
await this.client.answerCallbackQuery(cq.id)
|
||||
return
|
||||
}
|
||||
if (!isAuthorized({ allowAllUsers: this.allowAllUsers, allowedUserIds: this.allowedUserIds, userId })) {
|
||||
await this.client.answerCallbackQuery(cq.id, MSG.DENIED)
|
||||
await this.client.sendMessage(chatId, MSG.DENIED)
|
||||
return
|
||||
}
|
||||
const data = cq.data ?? ''
|
||||
if (!data.startsWith(BIND_CB_PREFIX)) {
|
||||
await this.client.answerCallbackQuery(cq.id)
|
||||
return
|
||||
}
|
||||
const sessionId = data.slice(BIND_CB_PREFIX.length)
|
||||
const agent = this.findLiveAgent(sessionId)
|
||||
if (!agent) {
|
||||
await this.client.answerCallbackQuery(cq.id, '会话不存在')
|
||||
await this.client.sendMessage(chatId, MSG.GONE)
|
||||
return
|
||||
}
|
||||
const label = shortLabel(agent, 0)
|
||||
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label })
|
||||
await this.client.answerCallbackQuery(cq.id, '已附着')
|
||||
await this.client.sendMessage(chatId, MSG.BOUND(label))
|
||||
}
|
||||
|
||||
private liveAgents(): Agent[] {
|
||||
const agents = this.ctx.agents
|
||||
if (typeof agents.roots === 'function') {
|
||||
const roots = agents.roots()
|
||||
if (roots.length > 0) return roots
|
||||
}
|
||||
if (typeof agents.list === 'function') return agents.list()
|
||||
return []
|
||||
}
|
||||
|
||||
private findLiveAgent(sessionId: string): Agent | undefined {
|
||||
const agents = this.ctx.agents
|
||||
if (typeof agents.get === 'function') {
|
||||
try {
|
||||
const found = agents.get(SessionId(sessionId))
|
||||
if (found) return found
|
||||
} catch {
|
||||
// fall through to list scan
|
||||
}
|
||||
}
|
||||
return this.liveAgents().find((a) => String(a.id) === sessionId)
|
||||
}
|
||||
|
||||
private async sendSessionPicker(chatId: number): Promise<void> {
|
||||
const agents = this.liveAgents()
|
||||
if (agents.length === 0) {
|
||||
await this.client.sendMessage(chatId, MSG.NO_LIVE)
|
||||
return
|
||||
}
|
||||
const keyboard: InlineKeyboardMarkup = {
|
||||
inline_keyboard: agents.map((agent, i) => {
|
||||
const label = shortLabel(agent, i)
|
||||
return [{ text: label, callback_data: `${BIND_CB_PREFIX}${String(agent.id)}` }]
|
||||
}),
|
||||
}
|
||||
await this.client.sendMessage(
|
||||
chatId,
|
||||
`选择要遥控的本机会话(共 ${agents.length} 个):`,
|
||||
undefined,
|
||||
keyboard,
|
||||
)
|
||||
}
|
||||
|
||||
private async sendStatus(chatId: number): Promise<void> {
|
||||
const binding = this.bindings.get(String(chatId))
|
||||
if (!binding) {
|
||||
await this.client.sendMessage(chatId, MSG.STATUS_NONE)
|
||||
return
|
||||
}
|
||||
const stillLive = this.findLiveAgent(binding.sessionId)
|
||||
if (!stillLive) {
|
||||
this.bindings.delete(String(chatId))
|
||||
await this.client.sendMessage(chatId, MSG.GONE)
|
||||
return
|
||||
}
|
||||
await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label))
|
||||
}
|
||||
|
||||
private async followupBound(chatId: number, text: string): Promise<void> {
|
||||
const binding = this.bindings.get(String(chatId))
|
||||
if (!binding) {
|
||||
await this.client.sendMessage(chatId, MSG.NEED_BIND)
|
||||
return
|
||||
}
|
||||
const agent = this.findLiveAgent(binding.sessionId)
|
||||
if (!agent) {
|
||||
this.bindings.delete(String(chatId))
|
||||
await this.client.sendMessage(chatId, MSG.GONE)
|
||||
return
|
||||
}
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
agent.followup(message)
|
||||
}
|
||||
|
||||
private async pollLoop(): Promise<void> {
|
||||
const signal = this.pollAbort?.signal
|
||||
let errorCount = 0
|
||||
@@ -189,74 +311,22 @@ export class TelegramBridge {
|
||||
}
|
||||
|
||||
private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise<void> {
|
||||
const chat = this.findChatBySessionId(session.id)
|
||||
if (!chat) return
|
||||
const id = String(session.id)
|
||||
const targets = [...this.bindings.values()].filter((b) => b.sessionId === id)
|
||||
if (targets.length === 0) return
|
||||
|
||||
if (event.type === 'turn/start') {
|
||||
await this.client.sendChatAction(chat.chatId, 'typing')
|
||||
await Promise.all(targets.map((b) => this.client.sendChatAction(b.chatId, 'typing')))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'assistant/message') {
|
||||
const text = contentToText(event.data.message.content)
|
||||
if (text) {
|
||||
await this.deliver(chat.chatId, text)
|
||||
}
|
||||
if (!text) return
|
||||
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)))
|
||||
}
|
||||
}
|
||||
|
||||
private findChatBySessionId(sessionId: ReturnType<typeof SessionId>): ChatState | undefined {
|
||||
const id = String(sessionId)
|
||||
for (const chat of this.chats.values()) {
|
||||
if (chat.sessionId === id) return chat
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async ensureChat(chatId: number): Promise<ChatState> {
|
||||
const key = String(chatId)
|
||||
const existing = this.chats.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const sessionId = String(SessionId(`telegram:${chatId}`))
|
||||
const handle = await this.createAgent(SessionId(sessionId))
|
||||
const state: ChatState = { chatId, handle, sessionId }
|
||||
this.chats.set(key, state)
|
||||
return state
|
||||
}
|
||||
|
||||
private async resetChat(chatId: number): Promise<void> {
|
||||
const key = String(chatId)
|
||||
const existing = this.chats.get(key)
|
||||
if (existing) {
|
||||
await existing.handle.dispose()
|
||||
this.chats.delete(key)
|
||||
}
|
||||
const sessionId = String(SessionId(`telegram:${chatId}:${Date.now()}`))
|
||||
const handle = await this.createAgent(SessionId(sessionId))
|
||||
this.chats.set(key, { chatId, handle, sessionId })
|
||||
}
|
||||
|
||||
private async createAgent(sessionId: ReturnType<typeof SessionId>): Promise<AgentHandle> {
|
||||
return this.ctx.agents.create({
|
||||
sessionId,
|
||||
meta: { cwd: this.cwd },
|
||||
agentOptions: {
|
||||
provider: this.provider,
|
||||
model: this.model,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async followup(chatId: number, text: string): Promise<void> {
|
||||
const chat = await this.ensureChat(chatId)
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
chat.handle.agent.followup(message)
|
||||
}
|
||||
|
||||
private async deliver(chatId: number, markdown: string): Promise<void> {
|
||||
const chunks = splitMessage(markdown, this.maxMessageLength)
|
||||
for (const chunk of chunks) {
|
||||
|
||||
+40
-3
@@ -20,9 +20,26 @@ export interface TelegramMessage {
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface TelegramCallbackQuery {
|
||||
id: string
|
||||
from: TelegramUser
|
||||
message?: TelegramMessage
|
||||
data?: string
|
||||
}
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id: number
|
||||
message?: TelegramMessage
|
||||
callback_query?: TelegramCallbackQuery
|
||||
}
|
||||
|
||||
export interface InlineKeyboardButton {
|
||||
text: string
|
||||
callback_data: string
|
||||
}
|
||||
|
||||
export interface InlineKeyboardMarkup {
|
||||
inline_keyboard: InlineKeyboardButton[][]
|
||||
}
|
||||
|
||||
export interface TelegramClientOptions {
|
||||
@@ -34,8 +51,14 @@ export interface TelegramClientOptions {
|
||||
export interface TelegramClientLike {
|
||||
getMe(): Promise<TelegramUser>
|
||||
getUpdates(offset?: number): Promise<TelegramUpdate[]>
|
||||
sendMessage(chatId: number, text: string, parseMode?: string): Promise<TelegramMessage>
|
||||
sendMessage(
|
||||
chatId: number,
|
||||
text: string,
|
||||
parseMode?: string,
|
||||
replyMarkup?: InlineKeyboardMarkup,
|
||||
): Promise<TelegramMessage>
|
||||
sendChatAction(chatId: number, action: string): Promise<boolean>
|
||||
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
|
||||
}
|
||||
|
||||
export class TelegramClient implements TelegramClientLike {
|
||||
@@ -87,7 +110,7 @@ export class TelegramClient implements TelegramClientLike {
|
||||
async getUpdates(offset?: number): Promise<TelegramUpdate[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
timeout: this.pollingTimeoutSec,
|
||||
allowed_updates: ['message'],
|
||||
allowed_updates: ['message', 'callback_query'],
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
body.offset = offset
|
||||
@@ -95,15 +118,29 @@ export class TelegramClient implements TelegramClientLike {
|
||||
return this.call<TelegramUpdate[]>('getUpdates', body)
|
||||
}
|
||||
|
||||
async sendMessage(chatId: number, text: string, parseMode?: string): Promise<TelegramMessage> {
|
||||
async sendMessage(
|
||||
chatId: number,
|
||||
text: string,
|
||||
parseMode?: string,
|
||||
replyMarkup?: InlineKeyboardMarkup,
|
||||
): Promise<TelegramMessage> {
|
||||
const body: Record<string, unknown> = { chat_id: chatId, text }
|
||||
if (parseMode !== undefined) {
|
||||
body.parse_mode = parseMode
|
||||
}
|
||||
if (replyMarkup !== undefined) {
|
||||
body.reply_markup = replyMarkup
|
||||
}
|
||||
return this.call<TelegramMessage>('sendMessage', body)
|
||||
}
|
||||
|
||||
async sendChatAction(chatId: number, action: string): Promise<boolean> {
|
||||
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
|
||||
}
|
||||
|
||||
async answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean> {
|
||||
const body: Record<string, unknown> = { callback_query_id: callbackQueryId }
|
||||
if (text !== undefined) body.text = text
|
||||
return this.call<boolean>('answerCallbackQuery', body)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-11
@@ -1,15 +1,30 @@
|
||||
export const MSG = {
|
||||
DENIED: '无权限。',
|
||||
WELCOME:
|
||||
'你好,我是 DeepSeek Harness 渠道机器人。直接发消息开始对话,或发送 /help 查看命令。',
|
||||
WELCOME: [
|
||||
'你好,这是 DeepSeek Harness 的手机遥控器。',
|
||||
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。',
|
||||
'发送 /help 查看命令。',
|
||||
].join('\n'),
|
||||
HELP: [
|
||||
'/start — 开始 / 欢迎',
|
||||
'/new 或 /clear — 新开会话',
|
||||
'/sessions — 列出本机正在运行的会话并选择附着',
|
||||
'/status — 查看当前绑定',
|
||||
'/unbind — 断开绑定(不关闭本机会话)',
|
||||
'/help — 显示帮助',
|
||||
'',
|
||||
'仅白名单用户可对话;会话在内存中,重启 Harness 后需重新发消息建立。',
|
||||
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
||||
'仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。',
|
||||
].join('\n'),
|
||||
NEW_SESSION: '已开启新会话。',
|
||||
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
||||
NO_LIVE: '当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
|
||||
BOUND(label: string): string {
|
||||
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`
|
||||
},
|
||||
UNBOUND: '已断开绑定。本机会话仍在运行。',
|
||||
STATUS_NONE: '当前未绑定任何本机会话。发送 /sessions 选择。',
|
||||
STATUS_BOUND(label: string): string {
|
||||
return `当前绑定:${label}`
|
||||
},
|
||||
GONE: '绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。',
|
||||
unknown(command: string): string {
|
||||
return `未知命令 ${command}。发送 /help 查看可用命令。`
|
||||
},
|
||||
@@ -18,22 +33,33 @@ export const MSG = {
|
||||
export type ParsedCommand =
|
||||
| { type: 'start'; text: string }
|
||||
| { type: 'help'; text: string }
|
||||
| { type: 'new'; text: string }
|
||||
| { type: 'sessions'; text: string }
|
||||
| { type: 'status'; text: string }
|
||||
| { type: 'unbind'; text: string }
|
||||
| { type: 'unknown'; command: string; text: string }
|
||||
| { type: 'plain'; text: string }
|
||||
|
||||
export function parseCommand(text: string): ParsedCommand {
|
||||
if (!text.startsWith('/')) return { type: 'plain', text }
|
||||
const command = text.split(/\s+/)[0] ?? text
|
||||
const raw = text.split(/\s+/)[0] ?? text
|
||||
const command = raw.includes('@') ? raw.slice(0, raw.indexOf('@')) : raw
|
||||
switch (command) {
|
||||
case '/start':
|
||||
return { type: 'start', text }
|
||||
case '/help':
|
||||
return { type: 'help', text }
|
||||
case '/new':
|
||||
case '/clear':
|
||||
return { type: 'new', text }
|
||||
case '/sessions':
|
||||
case '/list':
|
||||
return { type: 'sessions', text }
|
||||
case '/status':
|
||||
return { type: 'status', text }
|
||||
case '/unbind':
|
||||
case '/disconnect':
|
||||
return { type: 'unbind', text }
|
||||
default:
|
||||
return { type: 'unknown', command, text }
|
||||
}
|
||||
}
|
||||
|
||||
/** Callback data prefix for session bind buttons. */
|
||||
export const BIND_CB_PREFIX = 'bind:'
|
||||
|
||||
@@ -9,22 +9,16 @@ export interface TelegramChannelConfig {
|
||||
token?: string
|
||||
allowedUserIds?: number[]
|
||||
allowAllUsers?: boolean
|
||||
provider?: string
|
||||
model?: string
|
||||
maxMessageLength?: number
|
||||
pollingTimeoutSec?: number
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
||||
token: Schema.string().default(''),
|
||||
allowedUserIds: Schema.array(Schema.number()).default([]),
|
||||
allowAllUsers: Schema.boolean().default(false),
|
||||
provider: Schema.string().default('deepseek-official'),
|
||||
model: Schema.string().default('deepseek-v4-flash'),
|
||||
maxMessageLength: Schema.number().default(4096),
|
||||
pollingTimeoutSec: Schema.number().default(30),
|
||||
cwd: Schema.string(),
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: TelegramChannelConfig): void {
|
||||
@@ -41,11 +35,8 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void {
|
||||
token,
|
||||
allowedUserIds: config.allowedUserIds ?? [],
|
||||
allowAllUsers: config.allowAllUsers ?? false,
|
||||
provider: config.provider ?? 'deepseek-official',
|
||||
model: config.model ?? 'deepseek-v4-flash',
|
||||
maxMessageLength: config.maxMessageLength ?? 4096,
|
||||
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
||||
cwd: config.cwd ?? process.cwd(),
|
||||
})
|
||||
ctx.effect(() => {
|
||||
bridge.start()
|
||||
|
||||
Reference in New Issue
Block a user