diff --git a/lib/bridge.js b/lib/bridge.js index a2cf36e..9bea425 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -4,6 +4,7 @@ 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'; +import { buttonLabel, describeAgent, detailLines, displayLabel } from './label.js'; const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function contentToText(content) { return content @@ -11,14 +12,6 @@ function contentToText(content) { .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; @@ -147,7 +140,7 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.GONE); return; } - const label = shortLabel(agent, 0); + const label = displayLabel(describeAgent(agent, 0, this.ctx)); 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)); @@ -183,13 +176,21 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.NO_LIVE); return; } + const described = agents.map((agent, i) => describeAgent(agent, i, this.ctx)); const keyboard = { - inline_keyboard: agents.map((agent, i) => { - const label = shortLabel(agent, i); - return [{ text: label, callback_data: `${BIND_CB_PREFIX}${String(agent.id)}` }]; - }), + inline_keyboard: described.map((parts) => ([{ + text: buttonLabel(parts), + callback_data: `${BIND_CB_PREFIX}${parts.sessionId}`, + }])), }; - await this.client.sendMessage(chatId, `选择要遥控的本机会话(共 ${agents.length} 个):`, undefined, keyboard); + const body = [ + `选择要遥控的本机会话(共 ${agents.length} 个):`, + '', + ...described.map((parts) => detailLines(parts)), + '', + '点下方按钮附着对应会话。', + ].join('\n'); + await this.client.sendMessage(chatId, body, undefined, keyboard); } async sendStatus(chatId) { const binding = this.bindings.get(String(chatId)); diff --git a/lib/index.d.ts b/lib/index.d.ts index 7013bfa..0aabba3 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -15,4 +15,5 @@ export * from './format.js'; export * from './client.js'; export * from './auth.js'; export * from './commands.js'; +export * from './label.js'; export * from './bridge.js'; diff --git a/lib/index.js b/lib/index.js index 99133c5..6c8af91 100644 --- a/lib/index.js +++ b/lib/index.js @@ -45,4 +45,5 @@ export * from './format.js'; export * from './client.js'; export * from './auth.js'; export * from './commands.js'; +export * from './label.js'; export * from './bridge.js'; diff --git a/lib/label.d.ts b/lib/label.d.ts new file mode 100644 index 0000000..3ff0952 --- /dev/null +++ b/lib/label.d.ts @@ -0,0 +1,35 @@ +import type { Context } from '@deepseek-ai/cordis'; +import type { Agent } from '@deepseek-ai/dsh-agent'; +/** Loose session shape so tests and live Session both work. */ +export interface SessionView { + header?: { + cwd?: string; + }; + meta?: { + cwd?: string; + }; + events?: ReadonlyArray<{ + type?: string; + data?: { + title?: string; + }; + }>; +} +export interface AgentLabelParts { + index: number; + title?: string; + cwd?: string; + workspace?: string; + idTail: string; + sessionId: string; +} +export declare function readSessionCwd(session: unknown): string | undefined; +export declare function workspaceName(cwd: string | undefined): string | undefined; +export declare function readSessionTitle(ctx: Context | undefined, session: unknown): string | undefined; +export declare function describeAgent(agent: Agent, index: number, ctx?: Context): AgentLabelParts; +/** Compact label for inline keyboard buttons (Telegram ≤64 chars). */ +export declare function buttonLabel(parts: AgentLabelParts): string; +/** Multi-line detail block for the picker message body. */ +export declare function detailLines(parts: AgentLabelParts): string; +/** Human label stored on bind / status (not length-capped as hard as buttons). */ +export declare function displayLabel(parts: AgentLabelParts): string; diff --git a/lib/label.js b/lib/label.js new file mode 100644 index 0000000..beb4b3b --- /dev/null +++ b/lib/label.js @@ -0,0 +1,107 @@ +const BUTTON_MAX = 64; +export function readSessionCwd(session) { + const s = session; + const cwd = s?.header?.cwd ?? s?.meta?.cwd; + if (typeof cwd !== 'string') + return undefined; + const trimmed = cwd.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} +export function workspaceName(cwd) { + if (!cwd) + return undefined; + const parts = cwd.split(/[/\\]/).filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : undefined; +} +export function readSessionTitle(ctx, session) { + if (!session) + return undefined; + try { + const svc = ctx + ?.sessionTitle; + const snap = svc?.get?.(session); + if (snap?.title && String(snap.title).trim()) + return String(snap.title).trim(); + } + catch { + // optional service + } + try { + const projections = ctx?.sessionProjections; + const projected = projections?.get?.(session, 'title'); + if (typeof projected === 'string' && projected.trim()) + return projected.trim(); + } + catch { + // optional service + } + const events = session.events; + if (Array.isArray(events)) { + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i]; + if (ev?.type === 'session/title' && typeof ev.data?.title === 'string') { + const t = ev.data.title.trim(); + if (t) + return t; + } + } + } + return undefined; +} +export function describeAgent(agent, index, ctx) { + const sessionId = String(agent.id); + const idTail = sessionId.length > 12 ? sessionId.slice(-12) : sessionId; + const cwd = readSessionCwd(agent.session); + const title = readSessionTitle(ctx, agent.session); + return { + index, + title, + cwd, + workspace: workspaceName(cwd), + idTail, + sessionId, + }; +} +/** Compact label for inline keyboard buttons (Telegram ≤64 chars). */ +export function buttonLabel(parts) { + const n = parts.index + 1; + const primary = parts.title || parts.workspace || `session ${n}`; + let text = `${n}. ${primary}`; + if (parts.title && parts.workspace) { + const withWs = `${n}. ${parts.title} · ${parts.workspace}`; + if ([...withWs].length <= BUTTON_MAX) + text = withWs; + } + if ([...text].length <= BUTTON_MAX) + return text; + const chars = [...text]; + return `${chars.slice(0, BUTTON_MAX - 1).join('')}…`; +} +/** Multi-line detail block for the picker message body. */ +export function detailLines(parts) { + const lines = [`${parts.index + 1}. ${parts.title ?? '(无标题)'}`]; + if (parts.cwd) { + lines.push(` 工作区:${parts.cwd}`); + } + else if (parts.workspace) { + lines.push(` 工作区:${parts.workspace}`); + } + else { + lines.push(' 工作区:(未知)'); + } + lines.push(` ID:…${parts.idTail}`); + return lines.join('\n'); +} +/** Human label stored on bind / status (not length-capped as hard as buttons). */ +export function displayLabel(parts) { + const bits = []; + if (parts.title) + bits.push(parts.title); + if (parts.workspace) + bits.push(parts.workspace); + if (bits.length === 0) + bits.push(`session ${parts.index + 1}`); + bits.push(`…${parts.idTail}`); + const text = bits.join(' · '); + return text.length > 120 ? `${text.slice(0, 117)}...` : text; +} diff --git a/src/bridge.ts b/src/bridge.ts index 6b5d7e3..f2b9be9 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -12,6 +12,7 @@ import { } from './client.js' import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js' import { markdownToHtml, splitMessage } from './format.js' +import { buttonLabel, describeAgent, detailLines, displayLabel } from './label.js' export interface TelegramBridgeOptions { token: string @@ -43,15 +44,6 @@ 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 @@ -188,7 +180,7 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.GONE) return } - const label = shortLabel(agent, 0) + const label = displayLabel(describeAgent(agent, 0, this.ctx)) 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)) @@ -223,18 +215,21 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.NO_LIVE) return } + const described = agents.map((agent, i) => describeAgent(agent, i, this.ctx)) 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)}` }] - }), + inline_keyboard: described.map((parts) => ([{ + text: buttonLabel(parts), + callback_data: `${BIND_CB_PREFIX}${parts.sessionId}`, + }])), } - await this.client.sendMessage( - chatId, + const body = [ `选择要遥控的本机会话(共 ${agents.length} 个):`, - undefined, - keyboard, - ) + '', + ...described.map((parts) => detailLines(parts)), + '', + '点下方按钮附着对应会话。', + ].join('\n') + await this.client.sendMessage(chatId, body, undefined, keyboard) } private async sendStatus(chatId: number): Promise { diff --git a/src/index.ts b/src/index.ts index f6ae706..780c035 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,4 +61,5 @@ export * from './format.js' export * from './client.js' export * from './auth.js' export * from './commands.js' +export * from './label.js' export * from './bridge.js' diff --git a/src/label.ts b/src/label.ts new file mode 100644 index 0000000..af6210a --- /dev/null +++ b/src/label.ts @@ -0,0 +1,123 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' + +/** Loose session shape so tests and live Session both work. */ +export interface SessionView { + header?: { cwd?: string } + meta?: { cwd?: string } + events?: ReadonlyArray<{ type?: string; data?: { title?: string } }> +} + +export interface AgentLabelParts { + index: number + title?: string + cwd?: string + workspace?: string + idTail: string + sessionId: string +} + +const BUTTON_MAX = 64 + +export function readSessionCwd(session: unknown): string | undefined { + const s = session as SessionView | undefined + const cwd = s?.header?.cwd ?? s?.meta?.cwd + if (typeof cwd !== 'string') return undefined + const trimmed = cwd.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export function workspaceName(cwd: string | undefined): string | undefined { + if (!cwd) return undefined + const parts = cwd.split(/[/\\]/).filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1] : undefined +} + +export function readSessionTitle(ctx: Context | undefined, session: unknown): string | undefined { + if (!session) return undefined + + try { + const svc = (ctx as { sessionTitle?: { get?: (s: unknown) => { title?: string } | undefined } } | undefined) + ?.sessionTitle + const snap = svc?.get?.(session) + if (snap?.title && String(snap.title).trim()) return String(snap.title).trim() + } catch { + // optional service + } + + try { + const projections = (ctx as { + sessionProjections?: { get?: (s: unknown, key: string) => unknown } + } | undefined)?.sessionProjections + const projected = projections?.get?.(session, 'title') + if (typeof projected === 'string' && projected.trim()) return projected.trim() + } catch { + // optional service + } + + const events = (session as SessionView).events + if (Array.isArray(events)) { + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i] + if (ev?.type === 'session/title' && typeof ev.data?.title === 'string') { + const t = ev.data.title.trim() + if (t) return t + } + } + } + return undefined +} + +export function describeAgent(agent: Agent, index: number, ctx?: Context): AgentLabelParts { + const sessionId = String(agent.id) + const idTail = sessionId.length > 12 ? sessionId.slice(-12) : sessionId + const cwd = readSessionCwd(agent.session) + const title = readSessionTitle(ctx, agent.session) + return { + index, + title, + cwd, + workspace: workspaceName(cwd), + idTail, + sessionId, + } +} + +/** Compact label for inline keyboard buttons (Telegram ≤64 chars). */ +export function buttonLabel(parts: AgentLabelParts): string { + const n = parts.index + 1 + const primary = parts.title || parts.workspace || `session ${n}` + let text = `${n}. ${primary}` + if (parts.title && parts.workspace) { + const withWs = `${n}. ${parts.title} · ${parts.workspace}` + if ([...withWs].length <= BUTTON_MAX) text = withWs + } + if ([...text].length <= BUTTON_MAX) return text + const chars = [...text] + return `${chars.slice(0, BUTTON_MAX - 1).join('')}…` +} + +/** Multi-line detail block for the picker message body. */ +export function detailLines(parts: AgentLabelParts): string { + const lines = [`${parts.index + 1}. ${parts.title ?? '(无标题)'}`] + if (parts.cwd) { + lines.push(` 工作区:${parts.cwd}`) + } else if (parts.workspace) { + lines.push(` 工作区:${parts.workspace}`) + } else { + lines.push(' 工作区:(未知)') + } + lines.push(` ID:…${parts.idTail}`) + return lines.join('\n') +} + +/** Human label stored on bind / status (not length-capped as hard as buttons). */ +export function displayLabel(parts: AgentLabelParts): string { + const bits: string[] = [] + if (parts.title) bits.push(parts.title) + if (parts.workspace) bits.push(parts.workspace) + if (bits.length === 0) bits.push(`session ${parts.index + 1}`) + bits.push(`…${parts.idTail}`) + const text = bits.join(' · ') + return text.length > 120 ? `${text.slice(0, 117)}...` : text +} diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 518efda..df82686 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -44,10 +44,21 @@ function messageUpdate(chatId: number, userId: number, text: string, updateId = } } -function makeAgent(id: string, followups: UserMessage[]) { +function makeAgent(id: string, followups: UserMessage[], opts?: { + cwd?: string + title?: string +}) { + const cwd = opts?.cwd ?? `/proj/${id}` + const events = opts?.title + ? [{ type: 'session/title', data: { title: opts.title } }] + : [] return { id: SessionId(id), - session: { meta: { cwd: `/proj/${id}` } }, + session: { + header: { cwd }, + meta: { cwd }, + events, + }, followup(message: UserMessage) { followups.push(message) }, @@ -118,7 +129,10 @@ test('/sessions with no live agents shows NO_LIVE', async () => { test('/sessions lists live agents with bind callbacks', async () => { const sent: SentMessage[] = [] const followups: UserMessage[] = [] - const agent = makeAgent('live-aaa', followups) + const agent = makeAgent('live-aaa', followups, { + cwd: 'D:/gitData/demo-app', + title: '演示会话', + }) const ctx = { logger: { info() {}, warn() {}, error() {} }, agents: { @@ -137,6 +151,9 @@ test('/sessions lists live agents with bind callbacks', async () => { }) await bridge.processUpdate(messageUpdate(10, 1, '/sessions')) assert.match(sent[0]!.text, /选择要遥控/) + assert.match(sent[0]!.text, /演示会话/) + assert.match(sent[0]!.text, /D:\/gitData\/demo-app/) + assert.match(sent[0]!.replyMarkup!.inline_keyboard![0]![0]!.text, /演示会话/) assert.ok(sent[0]!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data?.startsWith(BIND_CB_PREFIX)) }) diff --git a/tests/label.test.ts b/tests/label.test.ts new file mode 100644 index 0000000..3b6fee3 --- /dev/null +++ b/tests/label.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + buttonLabel, + describeAgent, + detailLines, + displayLabel, + readSessionCwd, + readSessionTitle, + workspaceName, +} from '../src/label.ts' +import { SessionId } from '@deepseek-ai/dsh-session/types' + +test('workspaceName takes last path segment', () => { + assert.equal(workspaceName('D:\\gitData\\dsh-telegram-channel'), 'dsh-telegram-channel') + assert.equal(workspaceName('/home/me/proj/app'), 'app') +}) + +test('readSessionCwd prefers header.cwd over meta.cwd', () => { + assert.equal(readSessionCwd({ header: { cwd: '/real' }, meta: { cwd: '/old' } }), '/real') + assert.equal(readSessionCwd({ meta: { cwd: '/legacy' } }), '/legacy') +}) + +test('readSessionTitle uses service, then events', () => { + const session = { + header: { cwd: '/w' }, + events: [ + { type: 'user/message', data: {} }, + { type: 'session/title', data: { title: ' 修复登录 ' } }, + ], + } + assert.equal(readSessionTitle(undefined, session), '修复登录') + const ctx = { + sessionTitle: { get: () => ({ title: '来自服务' }) }, + } + assert.equal(readSessionTitle(ctx as any, session), '来自服务') +}) + +test('describeAgent builds title + workspace + id tail', () => { + const agent = { + id: SessionId('0123456789abcdef-live'), + session: { + header: { cwd: 'D:/gitData/my-app' }, + events: [{ type: 'session/title', data: { title: '重构支付' } }], + }, + } + const parts = describeAgent(agent as any, 0) + assert.equal(parts.title, '重构支付') + assert.equal(parts.workspace, 'my-app') + assert.match(detailLines(parts), /重构支付/) + assert.match(detailLines(parts), /D:\/gitData\/my-app/) + assert.match(buttonLabel(parts), /^1\. 重构支付/) + assert.match(displayLabel(parts), /重构支付 · my-app/) +}) + +test('buttonLabel stays within 64 chars', () => { + const longTitle = '啊'.repeat(80) + const label = buttonLabel({ + index: 0, + title: longTitle, + workspace: 'ws', + idTail: 'abc', + sessionId: 'x', + }) + assert.ok([...label].length <= 64) +})