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:
owo
2026-08-14 17:11:13 +08:00
co-authored by Cursor
parent dd72f134fb
commit c2c0db7597
12 changed files with 518 additions and 666 deletions
+151 -81
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()