diff --git a/README.md b/README.md index 86b114f..1c13e1b 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ dsh plugin --profile web add D:\path\to\dsh-telegram-channel | 「无权限」 | User ID 必须是 `@userinfobot` 的数字 | | `/sessions` 无会话 | 确认 Web 有未归档会话;空白会话会被隐藏 | | `/sessions` 比电脑少很多 | 升级到 ≥0.3.0:应按工作区列出;仍少则检查是否归档 | -| `/model` 不可用 | 需 `dsh web`(apiProxy);先 `/sessions` 绑定 | +| `/model` 不可用 | 需 `dsh web`(apiProxy);先 `/sessions` 绑定。≥0.3.2 已修复「未 inject 读不到 apiProxy」 | | Telegram 401 | Token 错了或被 revoke | --- diff --git a/lib/apiproxy.d.ts b/lib/apiproxy.d.ts new file mode 100644 index 0000000..2416a25 --- /dev/null +++ b/lib/apiproxy.d.ts @@ -0,0 +1,30 @@ +import type { Context } from '@deepseek-ai/cordis'; +/** Loose ApiProxy surface used by catalog + model helpers. */ +export type ApiProxySessions = { + list?: (req: { + rpcId: string; + payload: unknown; + }) => Promise; + models?: (req: { + rpcId: string; + payload: unknown; + }) => Promise; + selectModel?: (req: { + rpcId: string; + payload: unknown; + }) => Promise; +}; +export type ApiProxyLike = { + workspace?: { + list?: (req: { + rpcId: string; + payload: unknown; + }) => Promise; + }; + sessions?: ApiProxySessions; +}; +/** + * Resolve host apiProxy without requiring Cordis inject. + * Direct `ctx.apiProxy` throws "cannot get property without inject" on real fibers. + */ +export declare function resolveApiProxy(ctx: Context): ApiProxyLike | undefined; diff --git a/lib/apiproxy.js b/lib/apiproxy.js new file mode 100644 index 0000000..2a06c58 --- /dev/null +++ b/lib/apiproxy.js @@ -0,0 +1,27 @@ +/** + * Resolve host apiProxy without requiring Cordis inject. + * Direct `ctx.apiProxy` throws "cannot get property without inject" on real fibers. + */ +export function resolveApiProxy(ctx) { + const c = ctx; + if (typeof c.get === 'function') { + try { + const viaGet = c.get('apiProxy'); + if (viaGet) + return viaGet; + } + catch { + // ignore and try own-property fallback (tests / plain mocks) + } + } + try { + // Own property on plain mocks only — Cordis proxy throws without inject. + if (Object.prototype.hasOwnProperty.call(c, 'apiProxy') && c.apiProxy) { + return c.apiProxy; + } + } + catch { + return undefined; + } + return undefined; +} diff --git a/lib/bridge.js b/lib/bridge.js index b37faa6..3a7f932 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -363,7 +363,8 @@ export class TelegramBridge { } catch (err) { this.ctx.logger.warn(`dsh-telegram-channel: /model failed: ${this.redact(err)}`); - await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE); + const detail = err instanceof Error ? err.message : String(err); + await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE(detail)); } } async sendEffortPicker(chatId, option) { diff --git a/lib/catalog.js b/lib/catalog.js index 730adc0..14254f5 100644 --- a/lib/catalog.js +++ b/lib/catalog.js @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { resolveApiProxy } from './apiproxy.js'; import { describeAgent, workspaceName } from './label.js'; function rpcCall(fn, payload) { if (typeof fn !== 'function') @@ -27,7 +28,7 @@ function titleOf(summary) { } /** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */ export async function loadCatalog(ctx) { - const api = ctx.apiProxy; + const api = resolveApiProxy(ctx); const wsPromise = rpcCall(api?.workspace?.list, {}); const sessPromise = rpcCall(api?.sessions?.list, {}); if (!wsPromise || !sessPromise) diff --git a/lib/commands.d.ts b/lib/commands.d.ts index ea81248..875a1c1 100644 --- a/lib/commands.d.ts +++ b/lib/commands.d.ts @@ -15,7 +15,7 @@ export declare const MSG: { readonly STATUS_BOUND: (label: string) => string; readonly STATUS_BOUND_COLD: (label: string) => string; readonly GONE: "绑定的会话已不可用。请重新 /sessions。"; - readonly MODEL_UNAVAILABLE: "无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。"; + readonly MODEL_UNAVAILABLE: (detail?: string) => string; readonly MODEL_UNROUTABLE: (current: string) => string; readonly MODEL_EMPTY: (current: string) => string; readonly MODEL_SET: (selected: string) => string; diff --git a/lib/commands.js b/lib/commands.js index eb4d3af..3825976 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -36,7 +36,12 @@ export const MSG = { return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`; }, GONE: '绑定的会话已不可用。请重新 /sessions。', - MODEL_UNAVAILABLE: '无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。', + MODEL_UNAVAILABLE(detail) { + const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。'; + if (!detail) + return tip; + return `${tip}\n详情:${detail}`; + }, MODEL_UNROUTABLE(current) { return `当前模型不可路由:${current}\n请在 Web 或本机配置可用 provider 后再试 /model。`; }, diff --git a/lib/index.d.ts b/lib/index.d.ts index 0aabba3..28c74cd 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1,6 +1,7 @@ import type { Context } from '@deepseek-ai/cordis'; import Schema from '@deepseek-ai/schemastery'; export declare const name = "dsh-telegram-channel"; +/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */ export declare const inject: string[]; export interface TelegramChannelConfig { token?: string; diff --git a/lib/index.js b/lib/index.js index 6c8af91..b85e792 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,7 +1,8 @@ import Schema from '@deepseek-ai/schemastery'; import { TelegramBridge } from './bridge.js'; export const name = 'dsh-telegram-channel'; -export const inject = ['agents']; +/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */ +export const inject = ['agents', 'apiProxy']; export const Config = Schema.object({ token: Schema.string().default(''), allowedUserIds: Schema.array(Schema.number()).default([]), diff --git a/lib/models.js b/lib/models.js index d324670..c239d96 100644 --- a/lib/models.js +++ b/lib/models.js @@ -1,21 +1,36 @@ import { randomUUID } from 'node:crypto'; +import { resolveApiProxy } from './apiproxy.js'; function unwrap(res) { const r = res; if (r?.result?.ok === true) return r.result.value; return undefined; } +function unwrapError(res) { + const r = res; + if (r?.result && r.result.ok === false) { + const err = r.result.error; + if (!err) + return 'rpc failed'; + return err.code ? `${err.code}: ${err.message ?? ''}`.trim() : (err.message ?? 'rpc failed'); + } + return undefined; +} async function call(fn, payload) { if (typeof fn !== 'function') throw new Error('apiProxy sessions API unavailable'); return fn({ rpcId: randomUUID(), payload }); } export async function loadSessionModels(ctx, sessionId) { - const api = ctx.apiProxy; - const raw = await call(api?.sessions?.models, { sessionId }); + const api = resolveApiProxy(ctx); + if (!api?.sessions?.models) { + throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)'); + } + const raw = await call(api.sessions.models, { sessionId }); const value = unwrap(raw); - if (!value) - throw new Error('failed to load session models'); + if (!value) { + throw new Error(unwrapError(raw) ?? 'failed to load session models'); + } const options = []; for (const group of value.groups ?? []) { for (const m of group.models ?? []) { @@ -34,16 +49,20 @@ export async function loadSessionModels(ctx, sessionId) { }; } export async function selectSessionModel(ctx, sessionId, selection) { - const api = ctx.apiProxy; - const raw = await call(api?.sessions?.selectModel, { + const api = resolveApiProxy(ctx); + if (!api?.sessions?.selectModel) { + throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)'); + } + const raw = await call(api.sessions.selectModel, { sessionId, provider: selection.provider, model: selection.model, reasoningEffort: selection.reasoningEffort, }); const value = unwrap(raw); - if (!value?.selected) - throw new Error('failed to select model'); + if (!value?.selected) { + throw new Error(unwrapError(raw) ?? 'failed to select model'); + } return value.selected; } export function formatModel(sel) { diff --git a/package.json b/package.json index b75ca22..ed6faf6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-telegram-channel", - "version": "0.3.1", + "version": "0.3.2", "description": "DeepSeek Harness Telegram mobile remote: workspace→session picker (Web-aligned), /model switch, same trajectory — dsh-plugin", "keywords": [ "telegram", diff --git a/src/apiproxy.ts b/src/apiproxy.ts new file mode 100644 index 0000000..4d92740 --- /dev/null +++ b/src/apiproxy.ts @@ -0,0 +1,45 @@ +import type { Context } from '@deepseek-ai/cordis' + +/** Loose ApiProxy surface used by catalog + model helpers. */ +export type ApiProxySessions = { + list?: (req: { rpcId: string; payload: unknown }) => Promise + models?: (req: { rpcId: string; payload: unknown }) => Promise + selectModel?: (req: { rpcId: string; payload: unknown }) => Promise +} + +export type ApiProxyLike = { + workspace?: { + list?: (req: { rpcId: string; payload: unknown }) => Promise + } + sessions?: ApiProxySessions +} + +/** + * Resolve host apiProxy without requiring Cordis inject. + * Direct `ctx.apiProxy` throws "cannot get property without inject" on real fibers. + */ +export function resolveApiProxy(ctx: Context): ApiProxyLike | undefined { + const c = ctx as Context & { + get?: (name: string, strict?: boolean) => unknown + apiProxy?: ApiProxyLike + } + + if (typeof c.get === 'function') { + try { + const viaGet = c.get('apiProxy') as ApiProxyLike | undefined + if (viaGet) return viaGet + } catch { + // ignore and try own-property fallback (tests / plain mocks) + } + } + + try { + // Own property on plain mocks only — Cordis proxy throws without inject. + if (Object.prototype.hasOwnProperty.call(c, 'apiProxy') && c.apiProxy) { + return c.apiProxy + } + } catch { + return undefined + } + return undefined +} diff --git a/src/bridge.ts b/src/bridge.ts index aaec237..2ebe1d8 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -432,7 +432,8 @@ export class TelegramBridge { await this.client.sendMessage(chatId, body, undefined, keyboard) } catch (err) { this.ctx.logger.warn(`dsh-telegram-channel: /model failed: ${this.redact(err)}`) - await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE) + const detail = err instanceof Error ? err.message : String(err) + await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE(detail)) } } diff --git a/src/catalog.ts b/src/catalog.ts index eab45b6..97fec1e 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { resolveApiProxy } from './apiproxy.js' import { describeAgent, workspaceName } from './label.js' export interface WorkspaceRow { @@ -59,12 +60,7 @@ function titleOf(summary: { /** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */ export async function loadCatalog(ctx: Context): Promise { - const api = (ctx as { - apiProxy?: { - workspace?: { list?: ApiFn } - sessions?: { list?: ApiFn } - } - }).apiProxy + const api = resolveApiProxy(ctx) const wsPromise = rpcCall(api?.workspace?.list, {}) const sessPromise = rpcCall(api?.sessions?.list, {}) diff --git a/src/commands.ts b/src/commands.ts index 5d23c5b..5adadcc 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -36,7 +36,11 @@ export const MSG = { return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)` }, GONE: '绑定的会话已不可用。请重新 /sessions。', - MODEL_UNAVAILABLE: '无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。', + MODEL_UNAVAILABLE(detail?: string): string { + const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。' + if (!detail) return tip + return `${tip}\n详情:${detail}` + }, MODEL_UNROUTABLE(current: string): string { return `当前模型不可路由:${current}\n请在 Web 或本机配置可用 provider 后再试 /model。` }, diff --git a/src/index.ts b/src/index.ts index 780c035..51e05d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,8 @@ import Schema from '@deepseek-ai/schemastery' import { TelegramBridge } from './bridge.js' export const name = 'dsh-telegram-channel' -export const inject = ['agents'] +/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */ +export const inject = ['agents', 'apiProxy'] export interface TelegramChannelConfig { token?: string diff --git a/src/models.ts b/src/models.ts index 300922f..564cf93 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' +import { resolveApiProxy } from './apiproxy.js' export interface ModelOption { provider: string @@ -17,21 +18,32 @@ export interface ModelSnapshot { type ApiFn = (req: { rpcId: string; payload: unknown }) => Promise function unwrap(res: unknown): T | undefined { - const r = res as { result?: { ok?: boolean; value?: T } } | undefined + const r = res as { result?: { ok?: boolean; value?: T; error?: { message?: string } } } | undefined if (r?.result?.ok === true) return r.result.value return undefined } +function unwrapError(res: unknown): string | undefined { + const r = res as { result?: { ok?: boolean; error?: { message?: string; code?: string } } } | undefined + if (r?.result && r.result.ok === false) { + const err = r.result.error + if (!err) return 'rpc failed' + return err.code ? `${err.code}: ${err.message ?? ''}`.trim() : (err.message ?? 'rpc failed') + } + return undefined +} + async function call(fn: ApiFn | undefined, payload: unknown): Promise { if (typeof fn !== 'function') throw new Error('apiProxy sessions API unavailable') return fn({ rpcId: randomUUID(), payload }) } export async function loadSessionModels(ctx: Context, sessionId: string): Promise { - const api = (ctx as { - apiProxy?: { sessions?: { models?: ApiFn } } - }).apiProxy - const raw = await call(api?.sessions?.models, { sessionId }) + const api = resolveApiProxy(ctx) + if (!api?.sessions?.models) { + throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)') + } + const raw = await call(api.sessions.models, { sessionId }) const value = unwrap<{ current: { provider: string; model: string; reasoningEffort?: string } routable: boolean @@ -45,7 +57,9 @@ export async function loadSessionModels(ctx: Context, sessionId: string): Promis }> }> }>(raw) - if (!value) throw new Error('failed to load session models') + if (!value) { + throw new Error(unwrapError(raw) ?? 'failed to load session models') + } const options: ModelOption[] = [] for (const group of value.groups ?? []) { @@ -71,17 +85,20 @@ export async function selectSessionModel( sessionId: string, selection: { provider: string; model: string; reasoningEffort?: string }, ): Promise<{ provider: string; model: string; reasoningEffort?: string }> { - const api = (ctx as { - apiProxy?: { sessions?: { selectModel?: ApiFn } } - }).apiProxy - const raw = await call(api?.sessions?.selectModel, { + const api = resolveApiProxy(ctx) + if (!api?.sessions?.selectModel) { + throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)') + } + const raw = await call(api.sessions.selectModel, { sessionId, provider: selection.provider, model: selection.model, reasoningEffort: selection.reasoningEffort, }) const value = unwrap<{ selected: { provider: string; model: string; reasoningEffort?: string } }>(raw) - if (!value?.selected) throw new Error('failed to select model') + if (!value?.selected) { + throw new Error(unwrapError(raw) ?? 'failed to select model') + } return value.selected } diff --git a/tests/apiproxy.test.ts b/tests/apiproxy.test.ts new file mode 100644 index 0000000..3d58e93 --- /dev/null +++ b/tests/apiproxy.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { resolveApiProxy } from '../src/apiproxy.ts' + +test('resolveApiProxy uses ctx.get and avoids bare ctx.apiProxy', () => { + const api = { sessions: {} } + let touchedBare = false + const ctx = { + get(name: string) { + return name === 'apiProxy' ? api : undefined + }, + get apiProxy() { + touchedBare = true + throw new Error('cannot get property "apiProxy" without inject') + }, + } + assert.equal(resolveApiProxy(ctx as any), api) + assert.equal(touchedBare, false) +}) + +test('resolveApiProxy falls back to own-property mock', () => { + const api = { sessions: {} } + const ctx = { apiProxy: api } + assert.equal(resolveApiProxy(ctx as any), api) +})