dsh-plugin-weapp-gateway 0.8.12 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { userInfo } from 'node:os';
5
+ import { randomBytes } from 'node:crypto';
6
+ /** Best-effort owner-only permissions for credentials and stable identities. */
7
+ export function tightenPrivateFile(file) {
8
+ try {
9
+ chmodSync(file, 0o600);
10
+ }
11
+ catch { /* best effort */ }
12
+ if (process.platform !== 'win32')
13
+ return;
14
+ try {
15
+ execFileSync('icacls', [file, '/inheritance:r', '/grant:r', `${userInfo().username}:F`], {
16
+ timeout: 5000,
17
+ windowsHide: true,
18
+ stdio: 'ignore',
19
+ });
20
+ }
21
+ catch { /* best effort */ }
22
+ }
23
+ /**
24
+ * Replace a private JSON file without ever truncating the previous valid file.
25
+ * The temporary file lives beside the destination, so rename is same-volume.
26
+ */
27
+ export function writePrivateJsonAtomic(file, value) {
28
+ publishPrivateJson(file, value, true);
29
+ }
30
+ /** Publish a fully written identity once. Concurrent first starts must read
31
+ * the winner, never overwrite it or observe an empty placeholder file. */
32
+ export function createPrivateJsonAtomic(file, value) {
33
+ return publishPrivateJson(file, value, false);
34
+ }
35
+ function publishPrivateJson(file, value, replace) {
36
+ const parent = dirname(file);
37
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
38
+ const temporary = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
39
+ try {
40
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
41
+ encoding: 'utf8',
42
+ mode: 0o600,
43
+ flag: 'wx',
44
+ });
45
+ const fd = openSync(temporary, 'r+');
46
+ try {
47
+ // Some Windows filesystems reject fsync on ordinary user files. Rename
48
+ // remains atomic; durability flush is an additional best-effort barrier.
49
+ try {
50
+ fsyncSync(fd);
51
+ }
52
+ catch { /* best effort */ }
53
+ }
54
+ finally {
55
+ closeSync(fd);
56
+ }
57
+ tightenPrivateFile(temporary);
58
+ if (replace)
59
+ renameSync(temporary, file);
60
+ else {
61
+ try {
62
+ linkSync(temporary, file);
63
+ }
64
+ catch (error) {
65
+ if (error.code === 'EEXIST')
66
+ return false;
67
+ throw error; // Do not replace an existing identity on unsupported storage.
68
+ }
69
+ }
70
+ tightenPrivateFile(file);
71
+ return true;
72
+ }
73
+ finally {
74
+ if (existsSync(temporary)) {
75
+ try {
76
+ rmSync(temporary, { force: true });
77
+ }
78
+ catch { /* best effort */ }
79
+ }
80
+ }
81
+ }
82
+ export function readPrivateJson(file) {
83
+ const value = JSON.parse(readFileSync(file, 'utf8'));
84
+ tightenPrivateFile(file);
85
+ return value;
86
+ }
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'node:crypto';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { ClawbotRuntime } from './runtime.mjs';
5
+ import { DshClawbotHost } from './dsh-host.mjs';
6
+ import { FileClawbotStorage } from './storage.mjs';
7
+ import { ClawbotError } from './contracts.mjs';
8
+ import { ILinkError } from './ilink-types.mjs';
9
+
10
+ function dshProfile(ctx) {
11
+ const profile = ctx?.get?.('profileContext');
12
+ if (profile !== undefined && profile !== null) {
13
+ if (!profile || typeof profile !== 'object'
14
+ || typeof profile.name !== 'string' || !profile.name || profile.name.length > 80
15
+ || /[\\/\u0000-\u001f]/.test(profile.name) || ['.', '..', 'node_modules'].includes(profile.name)
16
+ || typeof profile.home !== 'string' || !path.isAbsolute(profile.home) || profile.home.includes('\0')) {
17
+ throw new Error('DSH 运行实例信息不完整,未使用其他实例的配置');
18
+ }
19
+ return { name: profile.name, home: path.normalize(profile.home) };
20
+ }
21
+ if (process.versions.electron) throw new Error('DSH profile facts are unavailable');
22
+ const configured = String(process.env.DSH_HOME || '').trim();
23
+ const home = configured === '~' ? os.homedir()
24
+ : configured.startsWith('~/') || configured.startsWith('~\\')
25
+ ? path.join(os.homedir(), configured.slice(2))
26
+ : configured ? configured : path.join(os.homedir(), '.dsh');
27
+ const explicit = process.argv.find((value, index, argv) => argv[index - 1] === '--profile')
28
+ || process.argv.find(value => value.startsWith('--profile='))?.slice('--profile='.length);
29
+ const name = explicit || (process.argv[2] === 'web' ? 'web' : 'default');
30
+ return { name, home: path.resolve(home) };
31
+ }
32
+
33
+ export function clawbotStorageDirectory(ctx) {
34
+ const profile = dshProfile(ctx);
35
+ const scope = ['web', 'default'].includes(profile.name.toLowerCase()) ? 'web' : profile.name;
36
+ const key = createHash('sha256').update(`deepseek-harness\0${scope}`).digest('hex').slice(0, 24);
37
+ return path.join(profile.home, 'harness-remote', 'instances', key, 'clawbot');
38
+ }
39
+
40
+ export function createClawbotService(ctx) {
41
+ let storage;
42
+ try {
43
+ storage = new FileClawbotStorage(clawbotStorageDirectory(ctx));
44
+ const runtime = new ClawbotRuntime(storage, new DshClawbotHost(ctx));
45
+ return {
46
+ runtime,
47
+ error: null,
48
+ async control(request, signal = new AbortController().signal) {
49
+ try {
50
+ return { ok: true, value: await runtime.control(request, signal) };
51
+ } catch (cause) {
52
+ signal.throwIfAborted();
53
+ const known = cause instanceof ClawbotError || cause instanceof ILinkError;
54
+ return {
55
+ ok: false,
56
+ error: known
57
+ ? { code: cause.code, message: cause.message }
58
+ : { code: 'clawbot-operation-failed', message: '操作未完成,请刷新核对当前状态后再试。' },
59
+ };
60
+ }
61
+ },
62
+ dispose: () => runtime.dispose(),
63
+ };
64
+ } catch (error) {
65
+ try { storage?.close(); } catch { /* Preserve the original startup error. */ }
66
+ const locked = /another|另一个|管理/u.test(String(error?.message || ''));
67
+ return {
68
+ runtime: null,
69
+ async control() {
70
+ return { ok: false, error: this.error };
71
+ },
72
+ error: {
73
+ code: locked ? 'clawbot-owner-conflict' : 'clawbot-startup-failed',
74
+ message: locked
75
+ ? 'ClawBot 本地状态仍由旧服务占用。请先停止旧的私聊接线服务并重启 DSH,再由小程序网关接管。'
76
+ : 'ClawBot 本地状态无法安全读取,原记录未覆盖。请在电脑端核对后重启 DSH。',
77
+ },
78
+ dispose: async () => {},
79
+ };
80
+ }
81
+ }
@@ -0,0 +1,108 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { writePrivateJsonAtomic } from './secure-file.mjs';
5
+ import { validateMedia, validatePreparedMedia, MEDIA_MAX_ITEMS } from './ilink-media.mjs';
6
+ import { ClawbotError } from './contracts.mjs';
7
+ import { REPLY_TEXT_MAX_BYTES, REPLY_MAX_TEXT_PARTS, REPLY_TOTAL_TEXT_MAX_BYTES, TEXT_PART_MAX_BYTES } from './reply-text.mjs';
8
+ export const emptyClawbotData = () => ({ version: 1, revision: 0, enabled: false, credentials: null, target: null, cursor: '', receipts: [] });
9
+ export function validateClawbotData(data) {
10
+ const validTarget = (target) => target === null || target && typeof target.id === 'string'
11
+ && target.id.length > 0 && target.id.length <= 256 && typeof target.title === 'string' && target.title.length <= 500
12
+ && (target.generation === undefined || Number.isSafeInteger(target.generation))
13
+ && (target.workspaceId === undefined || typeof target.workspaceId === 'string' && target.workspaceId.length <= 256)
14
+ && (target.workspaceTitle === undefined || typeof target.workspaceTitle === 'string' && target.workspaceTitle.length <= 200);
15
+ if (data.version !== 1 || !Number.isSafeInteger(data.revision) || data.revision < 0 || typeof data.enabled !== 'boolean'
16
+ || data.accessBinding !== undefined && (!data.accessBinding || ![data.accessBinding.nodeId, data.accessBinding.accountInstance]
17
+ .every(value => typeof value === 'string' && value.length > 0 && value.length <= 256))
18
+ || data.authExpired !== undefined && typeof data.authExpired !== 'boolean'
19
+ || typeof data.cursor !== 'string' || data.cursor.length > 65536 || !validTarget(data.target)
20
+ || !Array.isArray(data.receipts) || data.receipts.length > 1000 || new Set(data.receipts.map(r => r?.id)).size !== data.receipts.length
21
+ || data.receipts.some(r => !r || typeof r.id !== 'string' || !/^[a-f0-9]{64}$/.test(r.id) || typeof r.userId !== 'string'
22
+ || typeof r.contextToken !== 'string' || typeof r.input !== 'string' || r.input.length > 8000 || !validTarget(r.target)
23
+ || r.media !== undefined && (!Array.isArray(r.media) || !r.media.length || r.media.length > MEDIA_MAX_ITEMS || !r.media.every(validateMedia))
24
+ || !Number.isSafeInteger(r.createdAt) || !Number.isSafeInteger(r.revision)
25
+ || r.parentId !== undefined && (typeof r.parentId !== 'string' || !/^[a-f0-9]{64}$/.test(r.parentId) || r.parentId === r.id)
26
+ || !['pending', 'running', 'ready', 'sending', 'done', 'uncertain'].includes(r.state)
27
+ || r.output !== undefined && (typeof r.output !== 'string' || Buffer.byteLength(r.output) > REPLY_TEXT_MAX_BYTES)
28
+ || r.parts !== undefined && (!Array.isArray(r.parts) || !r.parts.length || r.parts.length > REPLY_MAX_TEXT_PARTS + MEDIA_MAX_ITEMS
29
+ || r.parts.some(p => typeof p === 'string' ? !p || Buffer.byteLength(p) > TEXT_PART_MAX_BYTES
30
+ : !validatePreparedMedia(p) || p.userId !== r.userId || p.accountId !== data.credentials?.accountId)
31
+ || r.parts.reduce((bytes, p) => bytes + (typeof p === 'string' ? Buffer.byteLength(p) : 0), 0) > REPLY_TOTAL_TEXT_MAX_BYTES
32
+ || r.parts.filter(p => typeof p !== 'string').length > MEDIA_MAX_ITEMS
33
+ || !Number.isSafeInteger(r.sentParts) || r.sentParts < 0 || r.sentParts >= r.parts.length)
34
+ || !['done', 'uncertain'].includes(r.state) && r.parts === undefined && r.sentParts !== undefined && r.sentParts !== 0
35
+ || r.state === 'ready' && r.parts === undefined && (typeof r.output !== 'string' || !r.output)))
36
+ throw new ClawbotError('clawbot-storage-invalid', 'ClawBot 本地记录格式无效,未重置或覆盖。');
37
+ if (data.credentials && ![data.credentials.token, data.credentials.accountId, data.credentials.userId, data.credentials.baseUrl]
38
+ .every(value => typeof value === 'string' && value.length > 0 && value.length <= 8192))
39
+ throw new ClawbotError('clawbot-storage-invalid', 'ClawBot 登录记录不完整,请核对本地文件。');
40
+ if (Buffer.byteLength(JSON.stringify(data)) > 12 * 1024 * 1024)
41
+ throw new ClawbotError('clawbot-capacity', 'ClawBot 本地记录达到空间上限;未丢弃或覆盖待处理消息。');
42
+ }
43
+ /** One owner per native profile. Exact generation filenames make crashed-owner
44
+ * recovery safe against two contenders; no host process is stopped. */
45
+ export class FileClawbotStorage {
46
+ file;
47
+ lock;
48
+ owner;
49
+ closed = false;
50
+ constructor(directory) {
51
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
52
+ this.file = path.join(directory, 'state.json');
53
+ this.lock = path.join(directory, '.owner');
54
+ this.owner = `owner-${process.pid}-${randomUUID()}.json`;
55
+ const staging = path.join(directory, `.pending-${randomUUID()}`);
56
+ mkdirSync(staging, { mode: 0o700 });
57
+ writePrivateJsonAtomic(path.join(staging, this.owner), { pid: process.pid });
58
+ try {
59
+ if (existsSync(this.lock)) {
60
+ const entries = readdirSync(this.lock);
61
+ if (entries.length !== 1 || !/^owner-\d+-[a-f0-9-]+\.json$/.test(entries[0]))
62
+ throw new Error('ClawBot 状态正在被另一个实例使用');
63
+ const previous = path.join(this.lock, entries[0]);
64
+ const { pid } = JSON.parse(readFileSync(previous, 'utf8'));
65
+ if (!Number.isSafeInteger(pid) || pid < 1)
66
+ throw new Error('ClawBot 本地锁需要核对');
67
+ try {
68
+ process.kill(pid, 0);
69
+ throw new Error('ClawBot 已由此节点的另一个实例管理');
70
+ }
71
+ catch (error) {
72
+ if (error.code !== 'ESRCH')
73
+ throw error;
74
+ }
75
+ rmSync(previous);
76
+ rmdirSync(this.lock);
77
+ }
78
+ renameSync(staging, this.lock);
79
+ }
80
+ catch (error) {
81
+ rmSync(path.join(staging, this.owner), { force: true });
82
+ rmdirSync(staging);
83
+ throw error;
84
+ }
85
+ }
86
+ read() {
87
+ if (!existsSync(this.file))
88
+ return emptyClawbotData();
89
+ if (statSync(this.file).size > 16 * 1024 * 1024)
90
+ throw new Error('ClawBot 本地记录超过上限,未覆盖原记录');
91
+ const data = JSON.parse(readFileSync(this.file, 'utf8'));
92
+ validateClawbotData(data);
93
+ return data;
94
+ }
95
+ write(data) {
96
+ if (this.closed)
97
+ throw new Error('ClawBot 组件已停止');
98
+ validateClawbotData(data);
99
+ writePrivateJsonAtomic(this.file, data);
100
+ }
101
+ close() {
102
+ if (this.closed)
103
+ return;
104
+ this.closed = true;
105
+ rmSync(path.join(this.lock, this.owner));
106
+ rmdirSync(this.lock);
107
+ }
108
+ }
@@ -7,6 +7,46 @@ function eventTurnKey(event) {
7
7
  return null
8
8
  }
9
9
 
10
+ function finiteCount(...values) {
11
+ for (const value of values) {
12
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
13
+ }
14
+ return null
15
+ }
16
+
17
+ function addUsage(target, usage) {
18
+ if (!target || !usage || typeof usage !== 'object') return
19
+ const input = finiteCount(usage.inputTokens, usage.promptTokens)
20
+ const uncachedInput = finiteCount(usage.uncachedInputTokens, usage.uncached_input_tokens)
21
+ const output = finiteCount(usage.outputTokens, usage.completionTokens)
22
+ const total = finiteCount(usage.totalTokens)
23
+ const cacheRead = finiteCount(usage.cacheReadTokens, usage.cachedInputTokens, usage.cacheReadInputTokens)
24
+ if (input !== null) { target.inputTokens += input; target.hasInputTokens = true }
25
+ if (uncachedInput !== null) { target.uncachedInputTokens += uncachedInput; target.hasUncachedInputTokens = true }
26
+ if (output !== null) { target.outputTokens += output; target.hasOutputTokens = true }
27
+ if (total !== null) { target.totalTokens += total; target.hasTotalTokens = true }
28
+ if (cacheRead !== null) { target.cacheReadTokens += cacheRead; target.hasCacheReadTokens = true }
29
+ }
30
+
31
+ function turnMetrics(state, key, endedAt) {
32
+ const usage = state.turnUsage.get(key)
33
+ const start = state.turnStartedAt.get(key)
34
+ const result = {}
35
+ if (state.taskName) result.taskName = state.taskName
36
+ if (start !== undefined && Number.isFinite(endedAt) && endedAt >= start) result.durationMs = endedAt - start
37
+ if (usage && (usage.hasTotalTokens || usage.hasInputTokens || usage.hasOutputTokens)) {
38
+ result.tokens = usage.hasTotalTokens ? usage.totalTokens : usage.inputTokens + usage.outputTokens
39
+ } else if (usage?.hasUncachedInputTokens || usage?.hasCacheReadTokens || usage?.hasOutputTokens) {
40
+ result.tokens = usage.uncachedInputTokens + usage.cacheReadTokens + usage.outputTokens
41
+ }
42
+ if (usage?.hasCacheReadTokens && (usage.hasUncachedInputTokens || usage.hasInputTokens)) {
43
+ const inputBase = usage.hasUncachedInputTokens ? usage.uncachedInputTokens : usage.inputTokens
44
+ const denominator = inputBase + usage.cacheReadTokens
45
+ if (denominator > 0) result.cacheHitRate = Math.min(1, usage.cacheReadTokens / denominator)
46
+ }
47
+ return result
48
+ }
49
+
10
50
  function completionUrl(baseUrl) {
11
51
  if (!baseUrl) return null
12
52
  let url
@@ -26,7 +66,10 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
26
66
  function sessionState(sessionId, create = false) {
27
67
  let value = sessions.get(sessionId)
28
68
  if (!value && create) {
29
- value = { pending: [], active: new Map(), currentTurn: null, currentTurnAt: null }
69
+ value = {
70
+ pending: [], active: new Map(), currentTurn: null, currentTurnAt: null,
71
+ taskName: null, taskNameAt: null, turnStartedAt: new Map(), turnUsage: new Map(),
72
+ }
30
73
  sessions.set(sessionId, value)
31
74
  }
32
75
  return value
@@ -45,7 +88,17 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
45
88
  if (live.length) state.active.set(key, live)
46
89
  else state.active.delete(key)
47
90
  }
48
- if (!state.pending.length && !state.active.size && state.currentTurn === null) sessions.delete(sessionId)
91
+ if (state.taskNameAt !== null && state.taskNameAt <= cutoff) {
92
+ state.taskName = null
93
+ state.taskNameAt = null
94
+ }
95
+ for (const [key, startedAt] of state.turnStartedAt) {
96
+ if (startedAt <= cutoff) {
97
+ state.turnStartedAt.delete(key)
98
+ state.turnUsage.delete(key)
99
+ }
100
+ }
101
+ if (!state.pending.length && !state.active.size && state.currentTurn === null && state.taskNameAt === null) sessions.delete(sessionId)
49
102
  }
50
103
  }
51
104
 
@@ -75,10 +128,10 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
75
128
  if (remaining.length) state.active.set(key, remaining)
76
129
  else state.active.delete(key)
77
130
  }
78
- if (!state.pending.length && !state.active.size && state.currentTurn === null) sessions.delete(sessionId)
131
+ if (!state.pending.length && !state.active.size && state.currentTurn === null && state.taskNameAt === null) sessions.delete(sessionId)
79
132
  }
80
133
 
81
- async function deliver(sessionId, records) {
134
+ async function deliver(sessionId, records, metrics) {
82
135
  if (!endpoint || !records.length) return
83
136
  for (const record of records) {
84
137
  let response
@@ -87,7 +140,7 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
87
140
  response = await fetchImpl(endpoint, {
88
141
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
89
142
  headers: { 'Content-Type': 'application/json' },
90
- body: JSON.stringify({ ticket: record.ticket, sessionId, status: 'completed' }),
143
+ body: JSON.stringify({ ticket: record.ticket, sessionId, status: 'completed', ...metrics }),
91
144
  })
92
145
  if (response.ok) break
93
146
  } catch (_) { response = null }
@@ -100,12 +153,27 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
100
153
  function onSessionEvent(sessionId, event) {
101
154
  if (!endpoint || typeof sessionId !== 'string' || !event) return
102
155
  prune()
156
+ if (event.type === 'session/title') {
157
+ const title = typeof event.data?.title === 'string' ? event.data.title.trim().slice(0, 200) : ''
158
+ if (title) {
159
+ const state = sessionState(sessionId, true)
160
+ state.taskName = title
161
+ state.taskNameAt = now()
162
+ }
163
+ return
164
+ }
103
165
  if (event.type === 'turn/start') {
104
166
  const state = sessionState(sessionId, true)
105
167
  const key = eventTurnKey(event) || (Number.isSafeInteger(event.seq) ? `seq:${event.seq}` : null)
106
168
  if (key === null) return
107
169
  state.currentTurn = key
108
170
  state.currentTurnAt = now()
171
+ state.turnStartedAt.set(key, state.currentTurnAt)
172
+ state.turnUsage.set(key, {
173
+ inputTokens: 0, uncachedInputTokens: 0, outputTokens: 0, totalTokens: 0, cacheReadTokens: 0,
174
+ hasInputTokens: false, hasUncachedInputTokens: false, hasOutputTokens: false,
175
+ hasTotalTokens: false, hasCacheReadTokens: false,
176
+ })
109
177
  if (!state.active.has(key)) state.active.set(key, [])
110
178
  const next = state.pending.shift()
111
179
  if (next) {
@@ -114,6 +182,12 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
114
182
  }
115
183
  return
116
184
  }
185
+ if (event.type === 'assistant/message') {
186
+ const state = sessionState(sessionId)
187
+ const key = eventTurnKey(event) || state?.currentTurn
188
+ if (state && key !== null && key !== undefined) addUsage(state.turnUsage.get(key), event.data?.usage)
189
+ return
190
+ }
117
191
  if (event.type !== 'turn/end') return
118
192
  const state = sessionState(sessionId)
119
193
  if (!state) return
@@ -121,10 +195,15 @@ export function createCompletionNotifications({ serviceUrl, fetchImpl = globalTh
121
195
  // absent on the end event, close the turn already tracked as current.
122
196
  const key = eventTurnKey(event) || state.currentTurn
123
197
  const records = key === null ? [] : state.active.get(key) || []
198
+ const metrics = key === null ? {} : turnMetrics(state, key, now())
124
199
  if (key !== null) state.active.delete(key)
200
+ if (key !== null) {
201
+ state.turnStartedAt.delete(key)
202
+ state.turnUsage.delete(key)
203
+ }
125
204
  if (state.currentTurn === key) { state.currentTurn = null; state.currentTurnAt = null }
126
- void deliver(sessionId, records).catch(() => log(`completion notification delivery failed for session ${sessionId}`))
127
- if (!state.pending.length && !state.active.size && state.currentTurn === null) sessions.delete(sessionId)
205
+ void deliver(sessionId, records, metrics).catch(() => log(`completion notification delivery failed for session ${sessionId}`))
206
+ if (!state.pending.length && !state.active.size && state.currentTurn === null && state.taskNameAt === null) sessions.delete(sessionId)
128
207
  }
129
208
 
130
209
  return { register, discard, onSessionEvent, clear() { sessions.clear() } }
@@ -325,12 +325,6 @@ export function createDshHostAdapter(typertGateway) {
325
325
  permissionPresets: {
326
326
  catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
327
327
  },
328
- // This endpoint intentionally forwards only the ClawBot control DTO. It
329
- // stays behind the paired gateway and the ClawBot service's own access
330
- // checks; it never exposes the channel credentials to the client.
331
- clawbot: {
332
- control: (request, signal) => invoke('wechatClawbot', 'control', request, signal),
333
- },
334
328
  schedule: {
335
329
  async catalog(_payload = {}, signal) {
336
330
  return { items: requireArray(await invoke('schedule', 'catalog', {}, signal), 'schedule/catalog') }
package/lib/index.mjs CHANGED
@@ -144,6 +144,7 @@ import { createCompletionNotifications } from './completion-notifications.mjs'
144
144
  import { gatewayProbeHeader } from './gateway-probe.mjs'
145
145
  import { checkPluginUpdate, installLatestPlugin } from './plugin-updates.mjs'
146
146
  import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
147
+ import { createClawbotService } from './clawbot/service.mjs'
147
148
  import QRCode from 'qrcode'
148
149
 
149
150
  const { createRegistry } = devicesModule
@@ -1378,15 +1379,15 @@ function validClawbotId(value) {
1378
1379
  && !/[\u0000-\u001f]/.test(value)
1379
1380
  }
1380
1381
 
1381
- async function handleClawbotControl(api, msg) {
1382
+ async function handleClawbotControl(clawbotService, msg) {
1382
1383
  const type = 'clawbot-control'
1383
1384
  const requestId = typeof msg.requestId === 'string' && msg.requestId.trim().length > 0
1384
1385
  && msg.requestId.length <= 128 ? msg.requestId.trim() : null
1385
1386
  const error = (code, message) => ({ kind: 'error', code, message, requestType: type, ...(requestId ? { requestId } : {}) })
1386
1387
  if (!requestId) return error('bad-request', 'ClawBot control requires a requestId')
1387
1388
  if (!CLAWBOT_ACTIONS.has(msg.action)) return error('bad-request', 'Unsupported ClawBot action')
1388
- if (typeof api.clawbot?.control !== 'function') {
1389
- return error('clawbot-unavailable', 'The gateway host adapter does not provide ClawBot control')
1389
+ if (typeof clawbotService?.control !== 'function') {
1390
+ return error('clawbot-unavailable', '网关内置的 ClawBot 服务尚未就绪,请检查网关状态后重试。')
1390
1391
  }
1391
1392
 
1392
1393
  const request = { action: msg.action }
@@ -1430,7 +1431,7 @@ async function handleClawbotControl(api, msg) {
1430
1431
  }
1431
1432
 
1432
1433
  try {
1433
- const result = await api.clawbot.control(request)
1434
+ const result = await clawbotService.control(request)
1434
1435
  if (!result || typeof result !== 'object' || Array.isArray(result)) {
1435
1436
  return error('clawbot-unavailable', 'ClawBot returned an invalid response')
1436
1437
  }
@@ -1449,13 +1450,13 @@ async function handleClawbotControl(api, msg) {
1449
1450
  } catch (cause) {
1450
1451
  // Do not expose arbitrary host/plugin exception details over the mobile API.
1451
1452
  log(`ClawBot control unavailable: ${cause?.code || 'unknown error'}`)
1452
- return error('clawbot-unavailable', 'ClawBot is unavailable. Update dsh-wechat-remote and check its component status.')
1453
+ return error('clawbot-unavailable', 'ClawBot 接入暂不可用,请检查网关本地服务状态并刷新重试。')
1453
1454
  }
1454
1455
  }
1455
1456
 
1456
1457
  // Dispatch one mobile query frame; returns the wire frame to send back.
1457
1458
  async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1458
- if (msg.type === 'clawbot-control') return handleClawbotControl(api, msg)
1459
+ if (msg.type === 'clawbot-control') return handleClawbotControl(pluginContext?.clawbotService, msg)
1459
1460
  if (WORKSPACE_CHANGES_TYPES.has(msg.type)) return handleWorkspaceChanges(api, msg)
1460
1461
  if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
1461
1462
  if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
@@ -2256,6 +2257,8 @@ const plugin = {
2256
2257
  apply(ctx, config) {
2257
2258
  const webServer = ctx.webServer
2258
2259
  const api = createDshHostAdapter(ctx.typertGateway)
2260
+ const clawbotService = createClawbotService(ctx)
2261
+ ctx.effect(() => () => clawbotService.dispose())
2259
2262
  // Optional lifecycle-bound service: old hosts retain all existing gateway features.
2260
2263
  ctx.inject(['workspaceChanges'], changesCtx => {
2261
2264
  api.workspaceChanges = changesCtx.workspaceChanges
@@ -3238,6 +3241,7 @@ const plugin = {
3238
3241
  handleQuery(api, host, agentDefaultModel, msg, {
3239
3242
  onInstallStart: (requestId) => pluginInstallOwners.set(requestId, ws),
3240
3243
  onInstallEnd: (requestId) => pluginInstallOwners.delete(requestId),
3244
+ clawbotService,
3241
3245
  }).then((frame) => {
3242
3246
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
3243
3247
  })
@@ -3281,7 +3285,7 @@ const plugin = {
3281
3285
  'images',
3282
3286
  'session-create',
3283
3287
  'session-agent-preset',
3284
- 'wechat-clawbot-control-v1',
3288
+ 'wechat-clawbot-control-v2',
3285
3289
  'commands',
3286
3290
  'tasks',
3287
3291
  'goals',
package/package.json CHANGED
@@ -1,51 +1,54 @@
1
- {
2
- "name": "dsh-plugin-weapp-gateway",
3
- "version": "0.8.12",
4
- "description": "Authenticated mobile gateway for dsh 0.1.7-rc.2 with Session V4 history, live permissions, inbox-based queue sync, plugin management (DSH 0.2.0 hosts), LAN and Cloudflare Tunnel access with public endpoint health checks, device pairing, per-session Agent preset selection, relay access for the WeChat mini-program, and the official speech-input bridge (speech-input-v1)",
5
- "main": "lib/index.mjs",
6
- "files": [
7
- "bin",
8
- "helper",
9
- "lib",
10
- "relay",
11
- "docs",
12
- "cordis.patch.yml",
13
- "PROTOCOL.md"
14
- ],
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/huiikeung/dsh-plugin-weapp-gateway.git"
18
- },
19
- "homepage": "https://github.com/huiikeung/dsh-plugin-weapp-gateway#readme",
20
- "bugs": {
21
- "url": "https://github.com/huiikeung/dsh-plugin-weapp-gateway/issues"
22
- },
23
- "dependencies": {
24
- "@deepseek-ai/schemastery": "^3.18.1",
25
- "ws": "^8.18.0",
26
- "qrcode": "^1.5.4"
27
- },
28
- "bin": "bin/setup-ip.mjs",
29
- "scripts": {
30
- "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/speech-input.test.mjs && node test/speech-official.test.mjs && node test/plugin-management.test.mjs && node test/plugin-updates.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/completion-notifications.test.mjs && node --test test/workspace-changes.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/relay-connector.test.mjs && node test/cloudflare-linux.test.mjs && node test/multi-gateway.test.mjs"
31
- },
32
- "exports": {
33
- ".": "./lib/index.mjs",
34
- "./client": "./lib/client.js",
35
- "./package.json": "./package.json"
36
- },
37
- "dsh": {
38
- "bundle": {
39
- "patch": "./cordis.patch.yml"
40
- },
41
- "client": {
42
- "platform": "web",
43
- "inject": [
44
- "@deepseek-ai/dsh-client-runtime",
45
- "@deepseek-ai/dsh-client-ui-layout",
46
- "@deepseek-ai/dsh-client-ui-sidebar"
47
- ]
48
- }
49
- },
50
- "license": "MIT"
51
- }
1
+ {
2
+ "name": "dsh-plugin-weapp-gateway",
3
+ "version": "0.8.13",
4
+ "description": "Authenticated mobile gateway for DSH with Session V4 history, live permissions, inbox-based queue sync, built-in WeChat ClawBot, plugin management, LAN and Cloudflare Tunnel access, device pairing, per-session Agent presets, relay access, and speech input",
5
+ "main": "lib/index.mjs",
6
+ "files": [
7
+ "bin",
8
+ "helper",
9
+ "lib",
10
+ "relay",
11
+ "docs",
12
+ "THIRD-PARTY-NOTICES",
13
+ "cordis.patch.yml",
14
+ "PROTOCOL.md"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/huiikeung/dsh-plugin-weapp-gateway.git"
19
+ },
20
+ "homepage": "https://github.com/huiikeung/dsh-plugin-weapp-gateway#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/huiikeung/dsh-plugin-weapp-gateway/issues"
23
+ },
24
+ "dependencies": {
25
+ "@deepseek-ai/schemastery": "^3.18.1",
26
+ "ws": "^8.18.0",
27
+ "qrcode": "^1.5.4"
28
+ },
29
+ "exports": {
30
+ ".": "./lib/index.mjs",
31
+ "./client": "./lib/client.js",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "platform": "web",
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-runtime",
42
+ "@deepseek-ai/dsh-client-ui-layout",
43
+ "@deepseek-ai/dsh-client-ui-sidebar"
44
+ ]
45
+ }
46
+ },
47
+ "license": "MIT",
48
+ "scripts": {
49
+ "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/clawbot-service.test.mjs && node test/speech-input.test.mjs && node test/speech-official.test.mjs && node test/plugin-management.test.mjs && node test/plugin-updates.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/completion-notifications.test.mjs && node --test test/workspace-changes.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/relay-connector.test.mjs && node test/cloudflare-linux.test.mjs && node test/multi-gateway.test.mjs"
50
+ },
51
+ "bin": {
52
+ "dsh-plugin-weapp-gateway": "bin/setup-ip.mjs"
53
+ }
54
+ }