fix: ship prebuilt lib and drop prepare for one-click git install

Avoids pnpm allowBuilds block on dsh plugin add; README install steps expanded for first-time users.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
owo
2026-08-14 17:16:25 +08:00
co-authored by Cursor
parent c2c0db7597
commit 8ef1622822
15 changed files with 869 additions and 62 deletions
+5
View File
@@ -0,0 +1,5 @@
export declare function isAuthorized(opts: {
allowAllUsers: boolean;
allowedUserIds: number[];
userId?: number;
}): boolean;
+7
View File
@@ -0,0 +1,7 @@
export function isAuthorized(opts) {
if (opts.allowAllUsers)
return true;
if (opts.userId === undefined)
return false;
return opts.allowedUserIds.includes(opts.userId);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Context } from '@deepseek-ai/cordis';
import { type TelegramClientLike, type TelegramUpdate } from './client.js';
export interface TelegramBridgeOptions {
token: string;
allowedUserIds: number[];
allowAllUsers: boolean;
client?: TelegramClientLike;
sleep?: (ms: number) => Promise<void>;
maxMessageLength?: number;
pollingTimeoutSec?: number;
}
export declare class TelegramBridge {
private readonly ctx;
private readonly token;
private readonly allowedUserIds;
private readonly allowAllUsers;
private readonly client;
private readonly sleep;
private readonly maxMessageLength;
private readonly bindings;
private polling;
private offset;
private pollPromise;
private pollAbort;
private disposeSessionListener;
constructor(ctx: Context, options: TelegramBridgeOptions);
start(): void;
stop(): Promise<void>;
processUpdate(update: TelegramUpdate): Promise<void>;
private handleCallback;
private liveAgents;
private findLiveAgent;
private sendSessionPicker;
private sendStatus;
private followupBound;
private pollLoop;
private interruptibleDelay;
private interruptibleSleep;
private onSessionEvent;
private deliver;
private redact;
}
+305
View File
@@ -0,0 +1,305 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
import { SessionId } from '@deepseek-ai/dsh-session/types';
import { isAuthorized } from './auth.js';
import { TelegramClient, } from './client.js';
import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js';
import { markdownToHtml, splitMessage } from './format.js';
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function contentToText(content) {
return content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('');
}
function shortLabel(agent, index) {
const id = String(agent.id);
const tail = id.length > 12 ? id.slice(-12) : id;
const cwd = agent.session?.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 {
ctx;
token;
allowedUserIds;
allowAllUsers;
client;
sleep;
maxMessageLength;
bindings = new Map();
polling = false;
offset;
pollPromise;
pollAbort;
disposeSessionListener;
constructor(ctx, options) {
this.ctx = ctx;
this.token = options.token;
this.allowedUserIds = options.allowedUserIds;
this.allowAllUsers = options.allowAllUsers;
this.client = options.client ?? new TelegramClient(options.token, {
pollingTimeoutSec: options.pollingTimeoutSec ?? 30,
});
this.sleep = options.sleep ?? defaultSleep;
this.maxMessageLength = options.maxMessageLength ?? 4096;
}
start() {
this.disposeSessionListener?.();
this.disposeSessionListener = this.ctx.on('session/event', (session, event) => {
void this.onSessionEvent(session, event).catch((err) => {
this.ctx.logger.error(this.redact(err));
});
});
if (!this.polling) {
this.polling = true;
this.pollAbort = new AbortController();
this.pollPromise = this.pollLoop();
}
}
async stop() {
this.polling = false;
this.pollAbort?.abort();
this.pollAbort = undefined;
this.disposeSessionListener?.();
this.disposeSessionListener = undefined;
// Never dispose host agents — only clear remote bindings.
this.bindings.clear();
if (this.pollPromise) {
await this.pollPromise.catch(() => { });
this.pollPromise = undefined;
}
}
async processUpdate(update) {
if (update.callback_query) {
await this.handleCallback(update);
return;
}
const message = update.message;
if (!message?.text)
return;
const chatId = message.chat.id;
const userId = message.from?.id;
if (!isAuthorized({ allowAllUsers: this.allowAllUsers, allowedUserIds: this.allowedUserIds, userId })) {
await this.client.sendMessage(chatId, MSG.DENIED);
return;
}
const parsed = parseCommand(message.text);
switch (parsed.type) {
case 'start':
await this.client.sendMessage(chatId, MSG.WELCOME);
return;
case 'help':
await this.client.sendMessage(chatId, MSG.HELP);
return;
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.followupBound(chatId, parsed.text);
return;
}
}
async handleCallback(update) {
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));
}
liveAgents() {
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 [];
}
findLiveAgent(sessionId) {
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);
}
async sendSessionPicker(chatId) {
const agents = this.liveAgents();
if (agents.length === 0) {
await this.client.sendMessage(chatId, MSG.NO_LIVE);
return;
}
const keyboard = {
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);
}
async sendStatus(chatId) {
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));
}
async followupBound(chatId, text) {
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);
}
async pollLoop() {
const signal = this.pollAbort?.signal;
let errorCount = 0;
while (this.polling) {
try {
const updates = await this.client.getUpdates(this.offset);
if (!this.polling)
break;
errorCount = 0;
if (updates.length === 0) {
await this.interruptibleDelay(50, signal);
continue;
}
for (const update of updates) {
if (!this.polling)
break;
await this.processUpdate(update);
this.offset = update.update_id + 1;
}
}
catch (err) {
if (!this.polling)
break;
errorCount += 1;
this.ctx.logger.error(this.redact(err));
await this.interruptibleSleep(Math.min(1000 * errorCount, 10_000), signal);
}
}
}
interruptibleDelay(ms, signal) {
if (signal?.aborted || !this.polling)
return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
resolve();
}, { once: true });
});
}
async interruptibleSleep(ms, signal) {
if (signal?.aborted || !this.polling)
return;
await Promise.race([
this.sleep(ms),
new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
signal?.addEventListener('abort', () => resolve(), { once: true });
}),
]);
}
async onSessionEvent(session, event) {
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 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)
return;
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)));
}
}
async deliver(chatId, markdown) {
const chunks = splitMessage(markdown, this.maxMessageLength);
for (const chunk of chunks) {
const html = markdownToHtml(chunk);
try {
await this.client.sendMessage(chatId, html, 'HTML');
}
catch {
try {
await this.client.sendMessage(chatId, chunk);
}
catch (err) {
this.ctx.logger.error(this.redact(err));
}
}
}
}
redact(value) {
const message = value instanceof Error ? value.message : String(value);
return message.split(this.token).join('***');
}
}
+63
View File
@@ -0,0 +1,63 @@
export interface TelegramUser {
id: number;
is_bot?: boolean;
first_name?: string;
username?: string;
}
export interface TelegramChat {
id: number;
type: string;
title?: string;
username?: string;
}
export interface TelegramMessage {
message_id: number;
date: number;
chat: TelegramChat;
from?: TelegramUser;
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 {
fetch?: typeof fetch;
baseUrl?: string;
pollingTimeoutSec?: number;
}
export interface TelegramClientLike {
getMe(): Promise<TelegramUser>;
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
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 declare class TelegramClient implements TelegramClientLike {
private readonly token;
private readonly fetchImpl;
private readonly baseUrl;
private readonly pollingTimeoutSec;
constructor(token: string, options?: TelegramClientOptions);
private redact;
private call;
getMe(): Promise<TelegramUser>;
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
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>;
}
+72
View File
@@ -0,0 +1,72 @@
export class TelegramClient {
token;
fetchImpl;
baseUrl;
pollingTimeoutSec;
constructor(token, options = {}) {
if (!token) {
throw new Error('bot token is required');
}
this.token = token;
this.fetchImpl = options.fetch ?? fetch;
this.baseUrl = options.baseUrl ?? 'https://api.telegram.org';
this.pollingTimeoutSec = options.pollingTimeoutSec ?? 30;
}
redact(message) {
return message.split(this.token).join('***');
}
async call(method, body) {
const url = `${this.baseUrl}/bot${this.token}/${method}`;
try {
const init = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
const response = await this.fetchImpl(url, init);
const json = (await response.json());
if (!json.ok) {
throw new Error(json.description ?? 'Telegram API error');
}
return json.result;
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(this.redact(message));
}
}
async getMe() {
return this.call('getMe');
}
async getUpdates(offset) {
const body = {
timeout: this.pollingTimeoutSec,
allowed_updates: ['message', 'callback_query'],
};
if (offset !== undefined) {
body.offset = offset;
}
return this.call('getUpdates', body);
}
async sendMessage(chatId, text, parseMode, replyMarkup) {
const body = { chat_id: chatId, text };
if (parseMode !== undefined) {
body.parse_mode = parseMode;
}
if (replyMarkup !== undefined) {
body.reply_markup = replyMarkup;
}
return this.call('sendMessage', body);
}
async sendChatAction(chatId, action) {
return this.call('sendChatAction', { chat_id: chatId, action });
}
async answerCallbackQuery(callbackQueryId, text) {
const body = { callback_query_id: callbackQueryId };
if (text !== undefined)
body.text = text;
return this.call('answerCallbackQuery', body);
}
}
+39
View File
@@ -0,0 +1,39 @@
export declare const MSG: {
readonly DENIED: "无权限。";
readonly WELCOME: string;
readonly HELP: string;
readonly NEED_BIND: "尚未绑定本机会话。请先发送 /sessions 选择一个。";
readonly NO_LIVE: "当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。";
readonly BOUND: (label: string) => string;
readonly UNBOUND: "已断开绑定。本机会话仍在运行。";
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
readonly STATUS_BOUND: (label: string) => string;
readonly GONE: "绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。";
readonly unknown: (command: string) => string;
};
export type ParsedCommand = {
type: 'start';
text: string;
} | {
type: 'help';
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 declare function parseCommand(text: string): ParsedCommand;
/** Callback data prefix for session bind buttons. */
export declare const BIND_CB_PREFIX = "bind:";
+55
View File
@@ -0,0 +1,55 @@
export const MSG = {
DENIED: '无权限。',
WELCOME: [
'你好,这是 DeepSeek Harness 的手机遥控器。',
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。',
'发送 /help 查看命令。',
].join('\n'),
HELP: [
'/sessions — 列出本机正在运行的会话并选择附着',
'/status — 查看当前绑定',
'/unbind — 断开绑定(不关闭本机会话)',
'/help — 显示帮助',
'',
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
'仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。',
].join('\n'),
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
NO_LIVE: '当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
BOUND(label) {
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`;
},
UNBOUND: '已断开绑定。本机会话仍在运行。',
STATUS_NONE: '当前未绑定任何本机会话。发送 /sessions 选择。',
STATUS_BOUND(label) {
return `当前绑定:${label}`;
},
GONE: '绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。',
unknown(command) {
return `未知命令 ${command}。发送 /help 查看可用命令。`;
},
};
export function parseCommand(text) {
if (!text.startsWith('/'))
return { type: 'plain', 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 '/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:';
+3
View File
@@ -0,0 +1,3 @@
export declare function escapeHtml(text: string): string;
export declare function markdownToHtml(text: string): string;
export declare function splitMessage(text: string, maxLength: number): string[];
+50
View File
@@ -0,0 +1,50 @@
export function escapeHtml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function inlineToHtml(text) {
let html = escapeHtml(text);
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
return html;
}
export function markdownToHtml(text) {
const parts = text.split(/```[\w-]*/);
if (parts.length % 2 === 0)
return inlineToHtml(text);
let html = '';
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 1) {
const code = parts[i].replace(/^\n/, '').replace(/\n$/, '');
html += `<pre>${escapeHtml(code)}</pre>`;
}
else {
html += inlineToHtml(parts[i]);
}
}
return html;
}
export function splitMessage(text, maxLength) {
if (maxLength < 1)
throw new Error('maxLength must be >= 1');
if (text.length <= maxLength)
return [text];
const chunks = [];
let rest = text;
while (rest.length > maxLength) {
const window = rest.slice(0, maxLength);
const newline = window.lastIndexOf('\n');
const ideographic = window.lastIndexOf('。');
const sentence = window.lastIndexOf('. ');
const breakAt = Math.max(newline, ideographic, sentence);
const cut = breakAt > 0 ? (breakAt === sentence ? breakAt + 2 : breakAt + 1) : maxLength;
chunks.push(rest.slice(0, cut));
rest = rest.slice(cut);
}
chunks.push(rest);
return chunks;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Context } from '@deepseek-ai/cordis';
import Schema from '@deepseek-ai/schemastery';
export declare const name = "dsh-telegram-channel";
export declare const inject: string[];
export interface TelegramChannelConfig {
token?: string;
allowedUserIds?: number[];
allowAllUsers?: boolean;
maxMessageLength?: number;
pollingTimeoutSec?: number;
}
export declare const Config: Schema<TelegramChannelConfig>;
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
export * from './format.js';
export * from './client.js';
export * from './auth.js';
export * from './commands.js';
export * from './bridge.js';
+36
View File
@@ -0,0 +1,36 @@
import Schema from '@deepseek-ai/schemastery';
import { TelegramBridge } from './bridge.js';
export const name = 'dsh-telegram-channel';
export const inject = ['agents'];
export const Config = Schema.object({
token: Schema.string().default(''),
allowedUserIds: Schema.array(Schema.number()).default([]),
allowAllUsers: Schema.boolean().default(false),
maxMessageLength: Schema.number().default(4096),
pollingTimeoutSec: Schema.number().default(30),
});
export function apply(ctx, config) {
const token = (config.token && config.token.length > 0)
? config.token
: (process.env.DSH_TELEGRAM_TOKEN ?? '');
if (!token) {
ctx.logger.error('dsh-telegram-channel: missing bot token (set config.token or DSH_TELEGRAM_TOKEN); polling not started');
return;
}
const bridge = new TelegramBridge(ctx, {
token,
allowedUserIds: config.allowedUserIds ?? [],
allowAllUsers: config.allowAllUsers ?? false,
maxMessageLength: config.maxMessageLength ?? 4096,
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
});
ctx.effect(() => {
bridge.start();
return () => { void bridge.stop(); };
}, 'dsh-telegram-channel.serve');
}
export * from './format.js';
export * from './client.js';
export * from './auth.js';
export * from './commands.js';
export * from './bridge.js';