@mhfire/dsh-im-bridge 0.2.0 → 0.4.2
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.
- package/README.en.md +109 -6
- package/README.md +109 -6
- package/cordis.patch.yml +6 -0
- package/lib/client.js +126 -10
- package/lib/client.js.map +1 -1
- package/lib/index.js +8579 -70
- package/package.json +26 -10
- package/persona.default.en.md +1 -0
- package/persona.default.md +2 -1
- package/persona.example.md +2 -1
- package/src/client/WecomCard.tsx +75 -0
- package/src/client/card-controller.ts +74 -1
- package/src/client/card-form.ts +5 -0
- package/src/client/fields.module.css +32 -0
- package/src/client/fields.tsx +3 -1
- package/src/client/index.ts +3 -1
- package/src/client/locales.ts +23 -2
- package/src/index.ts +578 -62
- package/src/reply-images.ts +228 -0
- package/src/session-key.ts +198 -0
- package/src/wecom-cli.ts +1015 -0
- package/tsconfig.json +1 -1
- package/tsdown.host.ts +1 -0
package/src/wecom-cli.ts
ADDED
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate the bundled wecom-cli binary, keep it on PATH, probe auth, and load
|
|
3
|
+
* official wecomcli-* SKILL.md files from a managed directory.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
chmodSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
16
|
+
import { createRequire } from 'node:module'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
19
|
+
import { promisify } from 'node:util'
|
|
20
|
+
import { unzipSync } from 'fflate'
|
|
21
|
+
import { parse as parseYaml } from 'yaml'
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile)
|
|
24
|
+
|
|
25
|
+
/** Kebab-case skill names accepted by DSH (`wecomcli-shared`). */
|
|
26
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
27
|
+
|
|
28
|
+
/** Workspace-relative DSH native skills directory (`.dsh/skills`). Used only to detect leftover wecomcli-*. */
|
|
29
|
+
export const DEFAULT_WORKSPACE_SKILLS_DIR = join('.dsh', 'skills')
|
|
30
|
+
|
|
31
|
+
/** Workspace-relative Cursor/agents skills directory (`.agents/skills`). Used only to detect leftover wecomcli-*. */
|
|
32
|
+
export const DEFAULT_WORKSPACE_AGENTS_SKILLS_DIR = join('.agents', 'skills')
|
|
33
|
+
|
|
34
|
+
/** Managed wecomcli-* root under `$DSH_HOME` (not `$DSH_HOME/skills`, which skill-filesystem still scans). */
|
|
35
|
+
export const DEFAULT_MANAGED_SKILLS_DIR = 'wecom-cli-skills'
|
|
36
|
+
|
|
37
|
+
/** Workspace-relative wecom-cli credential directory (writable under sandbox). */
|
|
38
|
+
export const DEFAULT_WORKSPACE_CONFIG_DIR = join('.dsh', 'wecom-cli')
|
|
39
|
+
|
|
40
|
+
/** Official CLI env that overrides `~/.config/wecom`. */
|
|
41
|
+
export const WECOM_CLI_CONFIG_DIR_ENV = 'WECOM_CLI_CONFIG_DIR'
|
|
42
|
+
|
|
43
|
+
/** Directory that holds the PATH deny shim. */
|
|
44
|
+
export const SHIM_DIR_NAME = 'wecom-cli-bin'
|
|
45
|
+
|
|
46
|
+
/** Model-facing name of the gated office tool. */
|
|
47
|
+
export const WECOM_CLI_TOOL_NAME = 'wecom_cli'
|
|
48
|
+
|
|
49
|
+
/** Wall-clock limit for one gated office command. */
|
|
50
|
+
export const WECOM_CLI_TOOL_TIMEOUT_MS = 120_000
|
|
51
|
+
|
|
52
|
+
/** Byte ceiling applied to each of stdout and stderr before the model sees them. */
|
|
53
|
+
export const WECOM_CLI_TOOL_MAX_OUTPUT_BYTES = 60_000
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What the PATH shim prints before exiting 1. ASCII only: a `.cmd` echoes the
|
|
57
|
+
* file's bytes, and a console under a non-UTF-8 code page would garble Chinese.
|
|
58
|
+
*/
|
|
59
|
+
export const WECOM_CLI_SHIM_DENY_MESSAGE =
|
|
60
|
+
'wecom-cli is disabled in this window. Office 1:1 agents must call the wecom_cli tool with an argv array; group chats have no office access.'
|
|
61
|
+
|
|
62
|
+
/** Timeout for `wecom-cli auth show --status`. */
|
|
63
|
+
export const AUTH_PROBE_TIMEOUT_MS = 10_000
|
|
64
|
+
|
|
65
|
+
/** Timeout for `wecom-cli auth init --bot-id/--secret` (CLI contacts WeCom). */
|
|
66
|
+
export const AUTH_INIT_TIMEOUT_MS = 30_000
|
|
67
|
+
|
|
68
|
+
/** Host TTY fallback command; the credential directory is deployment-specific, so use {@link authInitHint}. */
|
|
69
|
+
export const AUTH_INIT_COMMAND = 'npx --yes @wecom/cli auth init --manual'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Host-terminal fallback that writes into the credential directory this plugin reads.
|
|
73
|
+
* The plugin injects `WECOM_CLI_CONFIG_DIR` per spawn, so a bare terminal would
|
|
74
|
+
* otherwise authorize `~/.config/wecom`, which nothing here reads.
|
|
75
|
+
* @param configDir - absolute credential directory.
|
|
76
|
+
* @param platform - target platform; selects pwsh or POSIX env syntax.
|
|
77
|
+
* @returns a one-line command to run on the host.
|
|
78
|
+
*/
|
|
79
|
+
export function authInitHint(
|
|
80
|
+
configDir: string,
|
|
81
|
+
platform: NodeJS.Platform = process.platform,
|
|
82
|
+
): string {
|
|
83
|
+
return platform === 'win32'
|
|
84
|
+
? `$env:${WECOM_CLI_CONFIG_DIR_ENV}='${configDir}'; ${AUTH_INIT_COMMAND}`
|
|
85
|
+
: `${WECOM_CLI_CONFIG_DIR_ENV}='${configDir}' ${AUTH_INIT_COMMAND}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Logged when plugin config has no Bot ID or Secret. Must never include the secret value. */
|
|
89
|
+
export const AUTH_INIT_MISSING_MESSAGE = '缺少 botId 或密钥,无法写入 wecom-cli 凭据。'
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Logged when automatic seeding exits non-zero. Must never include botId or the secret value.
|
|
93
|
+
* @param configDir - absolute credential directory, for the manual fallback.
|
|
94
|
+
* @returns the operator-facing failure line.
|
|
95
|
+
*/
|
|
96
|
+
export function authInitFailedMessage(configDir: string): string {
|
|
97
|
+
return `wecom-cli 未能用已有 Bot ID 完成授权。请重启 dsh,或在 host 终端执行 ${authInitHint(configDir)}(输入同一套密钥,不要全局安装)。`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Logged when wecomCli is on but wecomCli.allowFrom is empty. */
|
|
101
|
+
export const ALLOW_FROM_REQUIRED_MESSAGE =
|
|
102
|
+
'wecomCli 已开启但 wecomCli.allowFrom 为空;已跳过 wecom-cli 的 PATH 与授权检查。聊天仍由根级 allowFrom 控制(空 = 所有人)。请把办公 userid 配进 wecomCli.allowFrom。工作区 .dsh/skills 或 .agents/skills 里残留的 wecomcli-* 仍会被该 cwd 下所有 Agent 发现。'
|
|
103
|
+
|
|
104
|
+
/** Logged when the Agent context has no skills.register. */
|
|
105
|
+
export const SKILLS_SERVICE_MISSING_MESSAGE =
|
|
106
|
+
'当前 Agent 没有 skills 服务,无法注册 wecomcli-*。'
|
|
107
|
+
|
|
108
|
+
/** Logged when the Agent context has no tools.register. */
|
|
109
|
+
export const TOOLS_SERVICE_MISSING_MESSAGE =
|
|
110
|
+
`当前 Agent 没有 tools 服务,无法注册 ${WECOM_CLI_TOOL_NAME},办公命令不可用。`
|
|
111
|
+
|
|
112
|
+
/** Logged when workspace scan roots still contain wecomcli-*. */
|
|
113
|
+
export const WORKSPACE_WECOMCLI_LEAK_MESSAGE =
|
|
114
|
+
'工作区 .dsh/skills 或 .agents/skills 仍有 wecomcli-*,同 cwd 的 GUI/群聊仍会发现。请挪到 $DSH_HOME/wecom-cli-skills 后删除工作区副本。'
|
|
115
|
+
|
|
116
|
+
/** Official GitHub zip of wecom-cli (skills live under `skills/wecomcli-*`). */
|
|
117
|
+
export const WECOM_CLI_SKILLS_ARCHIVE_URL =
|
|
118
|
+
'https://github.com/WeComTeam/wecom-cli/archive/refs/heads/main.zip'
|
|
119
|
+
|
|
120
|
+
/** Connection channel for the Settings install button. */
|
|
121
|
+
export const IM_BRIDGE_RPC_CHANNEL = '/im-bridge'
|
|
122
|
+
|
|
123
|
+
/** Unary endpoint that downloads and extracts official wecomcli-* skills. */
|
|
124
|
+
export const INSTALL_SKILLS_ENDPOINT = 'wecomcli.installSkills'
|
|
125
|
+
|
|
126
|
+
/** Logged when the Host has no Connection (no Settings install button). */
|
|
127
|
+
export const SKILLS_RPC_UNAVAILABLE_MESSAGE =
|
|
128
|
+
'当前 Host 没有 Connection,Settings 安装按钮不可用。'
|
|
129
|
+
|
|
130
|
+
/** Zip entry under the GitHub archive: `wecom-cli-<ref>/skills/wecomcli-<name>/...`. */
|
|
131
|
+
const ARCHIVE_SKILL_FILE =
|
|
132
|
+
/^wecom-cli-[^/]+\/skills\/(wecomcli-[a-z0-9]+(?:-[a-z0-9]+)*)\/(.+)$/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* GitHub zips list directories as empty entries. Writing those as files makes
|
|
136
|
+
* a later mkdir of the same path fail with EEXIST.
|
|
137
|
+
* @param name - zip path with `/` separators.
|
|
138
|
+
* @param data - entry bytes.
|
|
139
|
+
* @returns true when this entry must not be written as a file.
|
|
140
|
+
*/
|
|
141
|
+
function isZipDirectoryEntry(name: string, data: Uint8Array): boolean {
|
|
142
|
+
if (name.endsWith('/')) return true
|
|
143
|
+
if (data.byteLength > 0) return false
|
|
144
|
+
const last = name.split('/').filter(Boolean).at(-1) ?? ''
|
|
145
|
+
return !last.includes('.')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Channel rules for every WeCom Agent: no GUI confirm, so `ask_user_question` hangs. */
|
|
149
|
+
export const WECOM_CHANNEL_PROMPT =
|
|
150
|
+
'本通道是企业微信,没有确认框。禁止调用 ask_user_question,它会挂到任务超时;有问题写在回复里问。'
|
|
151
|
+
|
|
152
|
+
/** Extra system-prompt rules for office 1:1 (gated `wecom_cli` tool). */
|
|
153
|
+
export const WECOM_CLI_PROMPT = [
|
|
154
|
+
WECOM_CHANNEL_PROMPT,
|
|
155
|
+
`办公只用 ${WECOM_CLI_TOOL_NAME}:argv 为 wecom-cli 之后的参数,例如 ["message","aibot","sessions","list"]。禁止用 pwsh/bash/npx/npm 再跑 wecom-cli。`,
|
|
156
|
+
'发信、取消会议、删待办、覆盖文档:先 --dry-run,回复里说明,等用户下一条确认再执行。',
|
|
157
|
+
`禁止任何 auth init 与扫码(会新建机器人)。凭证由插件维护。报 853004 时用 ${WECOM_CLI_TOOL_NAME} 重试该命令;仍失败则让用户重启 dsh。`,
|
|
158
|
+
].join('\n')
|
|
159
|
+
|
|
160
|
+
/** Prompt for group chats, non-office 1:1, and WeCom agents with wecomCli off. */
|
|
161
|
+
export const WECOM_CLI_NO_OFFICE_PROMPT = [
|
|
162
|
+
WECOM_CHANNEL_PROMPT,
|
|
163
|
+
`本通道没有企微办公权限:没有 ${WECOM_CLI_TOOL_NAME},禁止 wecom-cli / npx @wecom/cli / wecomcli-*。只回答诊断与当前工作区任务。`,
|
|
164
|
+
].join('\n')
|
|
165
|
+
|
|
166
|
+
/** Prepended to every official skill body: its `wecom-cli ...` lines are not runnable here. */
|
|
167
|
+
export const WECOM_CLI_SKILL_PREFIX = [
|
|
168
|
+
`执行方式:本机没有可直接运行的 wecom-cli 命令。下文每条 \`wecom-cli ...\` 都改为调用 ${WECOM_CLI_TOOL_NAME} 工具,argv 传命令名之后的参数。`,
|
|
169
|
+
`例如 \`wecom-cli message aibot send --chat-id X\` → ${WECOM_CLI_TOOL_NAME}({"argv":["message","aibot","send","--chat-id","X"]})。`,
|
|
170
|
+
'禁止用 pwsh/bash/npx 运行 wecom-cli。',
|
|
171
|
+
].join('\n')
|
|
172
|
+
|
|
173
|
+
/** One parsed official wecom-cli skill. */
|
|
174
|
+
export interface WecomSkill {
|
|
175
|
+
/** Frontmatter `name` (kebab-case). */
|
|
176
|
+
name: string
|
|
177
|
+
/** Frontmatter `description`. */
|
|
178
|
+
description: string
|
|
179
|
+
/** Markdown body after the frontmatter. */
|
|
180
|
+
content: string
|
|
181
|
+
/** Skill directory; used as DSH `resourceBase.path`. */
|
|
182
|
+
directory: string
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Duck-typed payload for `ctx.skills.register()` (no `@deepseek-ai/dsh-skill` import). */
|
|
186
|
+
export interface RuntimeSkillRegistration {
|
|
187
|
+
/** Kebab-case skill name. */
|
|
188
|
+
name: string
|
|
189
|
+
/** Catalog description. */
|
|
190
|
+
description: string
|
|
191
|
+
/** Runtime provider bucket; must not land in skill-filesystem roots. */
|
|
192
|
+
source: 'runtime'
|
|
193
|
+
/** Instruction body. */
|
|
194
|
+
content: string
|
|
195
|
+
/** Directory that holds SKILL.md and relative resources. */
|
|
196
|
+
resourceBase: { kind: 'directory'; path: string }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Minimal Cordis face used to look up an optional Agent-scoped service. */
|
|
200
|
+
export interface AgentServiceHost {
|
|
201
|
+
/** Service lookup; a missing service is a no-op with a warning. */
|
|
202
|
+
get(name: string): unknown
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One completed gated CLI run; matches the tool's `output.schema` exactly. */
|
|
206
|
+
export interface WecomCliRun {
|
|
207
|
+
/** Captured standard output, clipped to {@link WECOM_CLI_TOOL_MAX_OUTPUT_BYTES}. */
|
|
208
|
+
stdout: string
|
|
209
|
+
/** Captured standard error, clipped the same way; also carries spawn failures. */
|
|
210
|
+
stderr: string
|
|
211
|
+
/** Process exit code; a spawn failure or timeout reports 1. */
|
|
212
|
+
exitCode: number
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The part of a DSH tool execution context this tool uses. */
|
|
216
|
+
export interface RuntimeToolExec {
|
|
217
|
+
/** Caller-owned cancellation for this call. */
|
|
218
|
+
signal: AbortSignal
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Duck-typed `ctx.tools.register()` payload (no `@deepseek-ai/dsh-tools` import). */
|
|
222
|
+
export interface RuntimeToolRegistration {
|
|
223
|
+
/** Model-visible tool name. */
|
|
224
|
+
name: string
|
|
225
|
+
/** Model-visible description. */
|
|
226
|
+
description: string
|
|
227
|
+
/** JSON Schema for the model's arguments. */
|
|
228
|
+
parameters: Record<string, unknown>
|
|
229
|
+
/** Mandatory canonical output declaration; the registry validates every returned value against `schema`. */
|
|
230
|
+
output: {
|
|
231
|
+
/** JSON Schema of the value `execute` returns. */
|
|
232
|
+
schema: Record<string, unknown>
|
|
233
|
+
/** Pure projection from the canonical value to model-facing content. */
|
|
234
|
+
render(args: unknown, value: unknown): Array<{ type: 'text'; text: string }>
|
|
235
|
+
}
|
|
236
|
+
/** Cooperative wall-clock budget enforced by the host's timeout policy. */
|
|
237
|
+
timeoutMs?: number
|
|
238
|
+
/** Run one accepted call. */
|
|
239
|
+
execute(args: unknown, exec: RuntimeToolExec): Promise<WecomCliRun>
|
|
240
|
+
/** Pure pending-state card, derived from `args` alone. */
|
|
241
|
+
presentCall(args: unknown): { card: 'terminal'; title: string }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Result of installing the PATH deny shim. */
|
|
245
|
+
export interface EnsureOnPathResult {
|
|
246
|
+
/** Directory prepended to PATH; holds the deny shim. */
|
|
247
|
+
shimDir: string
|
|
248
|
+
/** True when another wecom-cli executable was already on PATH and is now shadowed. */
|
|
249
|
+
shadowed: boolean
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `wecom-cli auth show --status` outcome. */
|
|
253
|
+
export type AuthStatus = 'authorized' | 'unauthorized' | 'error'
|
|
254
|
+
|
|
255
|
+
/** Result of writing official wecomcli-* into the managed skills directory. */
|
|
256
|
+
export interface InstallWecomSkillsResult {
|
|
257
|
+
/** Absolute destination directory. */
|
|
258
|
+
dest: string
|
|
259
|
+
/** How many wecomcli-* SKILL.md trees loaded after extract. */
|
|
260
|
+
count: number
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Failed download or extract; message is safe to show in Settings. */
|
|
264
|
+
export class WecomSkillsInstallError extends Error {
|
|
265
|
+
/**
|
|
266
|
+
* @param message - short reason with no secrets.
|
|
267
|
+
*/
|
|
268
|
+
constructor(message: string) {
|
|
269
|
+
super(message)
|
|
270
|
+
this.name = 'WecomSkillsInstallError'
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* DSH home: `$DSH_HOME`, else `~/.dsh`.
|
|
276
|
+
* @param env - environment to read; defaults to `process.env`.
|
|
277
|
+
* @returns the absolute home directory.
|
|
278
|
+
*/
|
|
279
|
+
export function resolveDshHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
280
|
+
const fromEnv = env.DSH_HOME?.trim()
|
|
281
|
+
return fromEnv !== undefined && fromEnv !== '' ? fromEnv : join(homedir(), '.dsh')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Skills directory: configured path, else `$DSH_HOME/wecom-cli-skills`.
|
|
286
|
+
* Relative `configured` values resolve against `workspace`.
|
|
287
|
+
* @param configured - `wecomCli.skillsDir`; empty means the managed home default.
|
|
288
|
+
* @param workspace - Agent cwd / plugin `workspace`.
|
|
289
|
+
* @param env - environment for `$DSH_HOME`; defaults to `process.env`.
|
|
290
|
+
* @returns an absolute directory path.
|
|
291
|
+
*/
|
|
292
|
+
export function resolveSkillsDir(
|
|
293
|
+
configured: string,
|
|
294
|
+
workspace: string,
|
|
295
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
296
|
+
): string {
|
|
297
|
+
const trimmed = configured.trim()
|
|
298
|
+
if (trimmed === '') return join(resolveDshHome(env), DEFAULT_MANAGED_SKILLS_DIR)
|
|
299
|
+
return isAbsolute(trimmed) ? trimmed : resolve(workspace, trimmed)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* wecom-cli credential directory: configured path, else `<workspace>/.dsh/wecom-cli`.
|
|
304
|
+
* Relative `configured` values resolve against `workspace`.
|
|
305
|
+
* @param configured - `wecomCli.configDir`; empty means the workspace default.
|
|
306
|
+
* @param workspace - Agent cwd / plugin `workspace`.
|
|
307
|
+
* @returns an absolute directory path.
|
|
308
|
+
*/
|
|
309
|
+
export function resolveConfigDir(configured: string, workspace: string): string {
|
|
310
|
+
const trimmed = configured.trim()
|
|
311
|
+
if (trimmed === '') return join(workspace, DEFAULT_WORKSPACE_CONFIG_DIR)
|
|
312
|
+
return isAbsolute(trimmed) ? trimmed : resolve(workspace, trimmed)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create the credential directory. Deliberately does not touch `process.env`:
|
|
317
|
+
* an exported `WECOM_CLI_CONFIG_DIR` would hand the authorized identity to every
|
|
318
|
+
* child process, including a group chat's shell reaching a CLI copy some other way.
|
|
319
|
+
* @param dir - absolute credential directory.
|
|
320
|
+
* @returns `dir`.
|
|
321
|
+
*/
|
|
322
|
+
export function ensureConfigDir(dir: string): string {
|
|
323
|
+
mkdirSync(dir, { recursive: true })
|
|
324
|
+
return dir
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Environment for one CLI spawn: `WECOM_CLI_CONFIG_DIR` reaches the CLI only here,
|
|
329
|
+
* never `~/.config/wecom` and never the ambient process environment.
|
|
330
|
+
* @param configDir - absolute credential directory.
|
|
331
|
+
* @param base - environment to extend; defaults to `process.env`.
|
|
332
|
+
* @returns a new environment object; `base` is not mutated.
|
|
333
|
+
*/
|
|
334
|
+
export function wecomCliEnv(
|
|
335
|
+
configDir: string,
|
|
336
|
+
base: NodeJS.ProcessEnv = process.env,
|
|
337
|
+
): NodeJS.ProcessEnv {
|
|
338
|
+
return { ...base, [WECOM_CLI_CONFIG_DIR_ENV]: configDir }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* How to land official skills in `dir`. The skills CLI has no `--dir` and `-g`
|
|
343
|
+
* leaks into `~/.agents/skills`.
|
|
344
|
+
* @param dir - destination skills directory.
|
|
345
|
+
* @returns a one-line install hint.
|
|
346
|
+
*/
|
|
347
|
+
export function skillsInstallHint(dir: string): string {
|
|
348
|
+
return `在 Settings → 插件配置 → 企业微信桥接 点「安装官方 skills」,或把官方仓库 skills/wecomcli-* 拷到 "${dir}"(不要 npx skills add -g;CLI 没有 --dir)`
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Download the official wecom-cli GitHub zip.
|
|
353
|
+
* @param fetchImpl - HTTP client; defaults to global `fetch`.
|
|
354
|
+
* @param signal - optional cancellation.
|
|
355
|
+
* @returns zip bytes.
|
|
356
|
+
*/
|
|
357
|
+
export async function fetchWecomSkillsZip(
|
|
358
|
+
fetchImpl: typeof fetch = fetch,
|
|
359
|
+
signal?: AbortSignal,
|
|
360
|
+
): Promise<Uint8Array> {
|
|
361
|
+
let response: Response
|
|
362
|
+
try {
|
|
363
|
+
response = await fetchImpl(WECOM_CLI_SKILLS_ARCHIVE_URL, { signal, redirect: 'follow' })
|
|
364
|
+
} catch {
|
|
365
|
+
throw new WecomSkillsInstallError('下载官方 skills 失败(网络)')
|
|
366
|
+
}
|
|
367
|
+
if (!response.ok) {
|
|
368
|
+
throw new WecomSkillsInstallError(`下载官方 skills 失败(HTTP ${String(response.status)})`)
|
|
369
|
+
}
|
|
370
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Extract `skills/wecomcli-*` from a GitHub archive zip into `dest`.
|
|
375
|
+
* Rejects entries whose path contains `..`. Overwrites matching skill folders
|
|
376
|
+
* and leaves other children of `dest` in place.
|
|
377
|
+
* @param zipBytes - GitHub `archive/refs/heads/main.zip` body.
|
|
378
|
+
* @param dest - managed skills root (`resolveSkillsDir`).
|
|
379
|
+
* @returns destination and loaded wecomcli-* count.
|
|
380
|
+
*/
|
|
381
|
+
export function extractWecomSkillsFromZip(zipBytes: Uint8Array, dest: string): InstallWecomSkillsResult {
|
|
382
|
+
if (zipBytes.byteLength === 0) {
|
|
383
|
+
throw new WecomSkillsInstallError('下载的 zip 为空')
|
|
384
|
+
}
|
|
385
|
+
let files: Record<string, Uint8Array>
|
|
386
|
+
try {
|
|
387
|
+
files = unzipSync(zipBytes)
|
|
388
|
+
} catch {
|
|
389
|
+
throw new WecomSkillsInstallError('无法解压官方 skills zip')
|
|
390
|
+
}
|
|
391
|
+
const writes: Array<{ skill: string; rel: string; data: Uint8Array }> = []
|
|
392
|
+
for (const [rawName, data] of Object.entries(files)) {
|
|
393
|
+
const name = rawName.replaceAll('\\', '/')
|
|
394
|
+
if (name.split('/').includes('..')) {
|
|
395
|
+
throw new WecomSkillsInstallError('zip 含非法路径')
|
|
396
|
+
}
|
|
397
|
+
if (isZipDirectoryEntry(name, data)) continue
|
|
398
|
+
const match = ARCHIVE_SKILL_FILE.exec(name)
|
|
399
|
+
if (match === null || match[1] === undefined || match[2] === undefined) continue
|
|
400
|
+
writes.push({ skill: match[1], rel: match[2], data })
|
|
401
|
+
}
|
|
402
|
+
if (writes.length === 0) {
|
|
403
|
+
throw new WecomSkillsInstallError('zip 中没有 wecomcli-* skills')
|
|
404
|
+
}
|
|
405
|
+
writes.sort((left, right) => left.rel.split('/').length - right.rel.split('/').length)
|
|
406
|
+
mkdirSync(dest, { recursive: true })
|
|
407
|
+
const destRoot = resolve(dest)
|
|
408
|
+
for (const skill of new Set(writes.map(entry => entry.skill))) {
|
|
409
|
+
const skillDir = join(destRoot, skill)
|
|
410
|
+
if (existsSync(skillDir)) rmSync(skillDir, { recursive: true, force: true })
|
|
411
|
+
}
|
|
412
|
+
for (const entry of writes) {
|
|
413
|
+
const target = resolve(destRoot, entry.skill, entry.rel)
|
|
414
|
+
const relToDest = relative(destRoot, target)
|
|
415
|
+
if (relToDest.startsWith('..') || isAbsolute(relToDest)) {
|
|
416
|
+
throw new WecomSkillsInstallError('zip 含非法路径')
|
|
417
|
+
}
|
|
418
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
419
|
+
writeFileSync(target, entry.data)
|
|
420
|
+
}
|
|
421
|
+
const count = countWecomcliSkills(loadWecomSkills(destRoot))
|
|
422
|
+
if (count === 0) {
|
|
423
|
+
throw new WecomSkillsInstallError('解压后未读到 wecomcli-* SKILL.md')
|
|
424
|
+
}
|
|
425
|
+
return { dest: destRoot, count }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Install official wecomcli-* into `dest`. Tests pass `zip`; production fetches.
|
|
430
|
+
* @param dest - managed skills root.
|
|
431
|
+
* @param options - fixture zip, fetch override, or cancellation.
|
|
432
|
+
* @returns destination and loaded count.
|
|
433
|
+
*/
|
|
434
|
+
export async function installOfficialWecomSkills(
|
|
435
|
+
dest: string,
|
|
436
|
+
options?: {
|
|
437
|
+
zip?: Uint8Array
|
|
438
|
+
fetch?: typeof fetch
|
|
439
|
+
signal?: AbortSignal
|
|
440
|
+
},
|
|
441
|
+
): Promise<InstallWecomSkillsResult> {
|
|
442
|
+
const zip = options?.zip ?? await fetchWecomSkillsZip(options?.fetch, options?.signal)
|
|
443
|
+
return extractWecomSkillsFromZip(zip, dest)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Whether wecomCli may set up PATH, auth, and office prompts.
|
|
448
|
+
* Uses `wecomCli.allowFrom`, not the chat `allowFrom`.
|
|
449
|
+
* Workspace wecomcli-* leftovers are still discovered by skill-filesystem.
|
|
450
|
+
* @param enabled - `wecomCli.enabled`.
|
|
451
|
+
* @param officeFrom - office-sender userid list.
|
|
452
|
+
* @returns true only when both conditions hold.
|
|
453
|
+
*/
|
|
454
|
+
export function shouldEnableWecomCli(enabled: boolean, officeFrom: readonly string[]): boolean {
|
|
455
|
+
return enabled && officeFrom.some(id => id.trim() !== '')
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Whether this chat may receive runtime wecomcli-* registration.
|
|
460
|
+
* Groups never inject; 1:1 injects only for an office sender.
|
|
461
|
+
* @param kind - WeCom window kind; undefined is treated as no inject.
|
|
462
|
+
* @param office - whether the current sender is on `wecomCli.allowFrom`.
|
|
463
|
+
* @returns true only for office 1:1 chats.
|
|
464
|
+
*/
|
|
465
|
+
export function shouldInjectWecomOfficeSkills(
|
|
466
|
+
kind: 'single' | 'group' | undefined,
|
|
467
|
+
office: boolean,
|
|
468
|
+
): boolean {
|
|
469
|
+
return kind === 'single' && office
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Whether `sender` is on the office list (trimmed exact match).
|
|
474
|
+
* @param officeFrom - `wecomCli.allowFrom`.
|
|
475
|
+
* @param sender - inbound userid.
|
|
476
|
+
* @returns true when the sender may receive the office prompt.
|
|
477
|
+
*/
|
|
478
|
+
export function senderHasOfficeAccess(officeFrom: readonly string[], sender: string): boolean {
|
|
479
|
+
const id = sender.trim()
|
|
480
|
+
if (id === '') return false
|
|
481
|
+
return officeFrom.some(entry => entry.trim() === id)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Workspace skill-filesystem roots that would leak wecomcli-* to every cwd Agent.
|
|
486
|
+
* @param workspace - Agent cwd / plugin `workspace`.
|
|
487
|
+
* @returns `.dsh/skills` then `.agents/skills` under `workspace`.
|
|
488
|
+
*/
|
|
489
|
+
export function workspaceWecomcliLeakRoots(workspace: string): readonly string[] {
|
|
490
|
+
return [
|
|
491
|
+
join(workspace, DEFAULT_WORKSPACE_SKILLS_DIR),
|
|
492
|
+
join(workspace, DEFAULT_WORKSPACE_AGENTS_SKILLS_DIR),
|
|
493
|
+
]
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Count `wecomcli-*` still sitting in workspace scan roots.
|
|
498
|
+
* @param workspace - Agent cwd / plugin `workspace`.
|
|
499
|
+
* @returns total parsed wecomcli-* across leak roots.
|
|
500
|
+
*/
|
|
501
|
+
export function countWorkspaceWecomcliLeaks(workspace: string): number {
|
|
502
|
+
let total = 0
|
|
503
|
+
for (const root of workspaceWecomcliLeakRoots(workspace)) {
|
|
504
|
+
total += countWecomcliSkills(loadWecomSkills(root))
|
|
505
|
+
}
|
|
506
|
+
return total
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Count official wecom-cli skills (`wecomcli-*`) in a loaded list.
|
|
511
|
+
* @param skills - parsed skills from a directory scan.
|
|
512
|
+
* @returns how many names start with `wecomcli-`.
|
|
513
|
+
*/
|
|
514
|
+
export function countWecomcliSkills(skills: readonly WecomSkill[]): number {
|
|
515
|
+
return skills.filter(skill => skill.name.startsWith('wecomcli-')).length
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Map a parsed wecomcli-* skill to a runtime registration. Other names are ignored.
|
|
520
|
+
* The body keeps the official `wecom-cli ...` command lines, so
|
|
521
|
+
* {@link WECOM_CLI_SKILL_PREFIX} leads and redirects them to the gated tool.
|
|
522
|
+
* @param skill - parsed SKILL.md.
|
|
523
|
+
* @returns the registration, or undefined when the name is not `wecomcli-*`.
|
|
524
|
+
*/
|
|
525
|
+
export function toRuntimeSkillRegistration(skill: WecomSkill): RuntimeSkillRegistration | undefined {
|
|
526
|
+
if (!skill.name.startsWith('wecomcli-')) return undefined
|
|
527
|
+
return {
|
|
528
|
+
name: skill.name,
|
|
529
|
+
description: skill.description,
|
|
530
|
+
source: 'runtime',
|
|
531
|
+
content: `${WECOM_CLI_SKILL_PREFIX}\n\n${skill.content}`,
|
|
532
|
+
resourceBase: { kind: 'directory', path: skill.directory },
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Register wecomcli-* on this Agent's skills layer. Must be called on `agentCtx`, not the host.
|
|
538
|
+
* @param agentCtx - the Agent-scoped Cordis context.
|
|
539
|
+
* @param skills - parsed skills; non-wecomcli names are skipped.
|
|
540
|
+
* @returns how many skills were registered.
|
|
541
|
+
*/
|
|
542
|
+
export function registerWecomOfficeSkills(
|
|
543
|
+
agentCtx: AgentServiceHost,
|
|
544
|
+
skills: readonly WecomSkill[],
|
|
545
|
+
): number {
|
|
546
|
+
const registry = skillsRegisterOf(agentCtx)
|
|
547
|
+
if (registry === undefined) {
|
|
548
|
+
console.warn(`[im-bridge] ${SKILLS_SERVICE_MISSING_MESSAGE}`)
|
|
549
|
+
return 0
|
|
550
|
+
}
|
|
551
|
+
let count = 0
|
|
552
|
+
for (const skill of skills) {
|
|
553
|
+
const registration = toRuntimeSkillRegistration(skill)
|
|
554
|
+
if (registration === undefined) continue
|
|
555
|
+
registry.register(registration)
|
|
556
|
+
count += 1
|
|
557
|
+
}
|
|
558
|
+
return count
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function skillsRegisterOf(agentCtx: AgentServiceHost): { register(skill: RuntimeSkillRegistration): () => void } | undefined {
|
|
562
|
+
const skills = agentCtx.get('skills')
|
|
563
|
+
if (skills === undefined || skills === null || typeof skills !== 'object') return undefined
|
|
564
|
+
const register = (skills as { register?: unknown }).register
|
|
565
|
+
if (typeof register !== 'function') return undefined
|
|
566
|
+
return skills as { register(skill: RuntimeSkillRegistration): () => void }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Resolve `@wecom/cli`'s `bin/wecom.js` from this package's node_modules.
|
|
571
|
+
* @returns the absolute launcher path, or undefined when the package is missing.
|
|
572
|
+
*/
|
|
573
|
+
export function resolveWecomBin(): string | undefined {
|
|
574
|
+
try {
|
|
575
|
+
const pkgJsonPath = createRequire(import.meta.url).resolve('@wecom/cli/package.json')
|
|
576
|
+
return resolveWecomBinFromPackage(pkgJsonPath)
|
|
577
|
+
} catch {
|
|
578
|
+
return undefined
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Read a `@wecom/cli` package.json and return its wecom-cli bin path.
|
|
584
|
+
* @param pkgJsonPath - absolute path to that package.json.
|
|
585
|
+
* @returns the absolute launcher path, or undefined when `bin` is missing.
|
|
586
|
+
*/
|
|
587
|
+
export function resolveWecomBinFromPackage(pkgJsonPath: string): string | undefined {
|
|
588
|
+
const raw = readFileSync(pkgJsonPath, 'utf8')
|
|
589
|
+
const pkg = JSON.parse(raw) as { bin?: string | Record<string, string> }
|
|
590
|
+
const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['wecom-cli']
|
|
591
|
+
if (rel === undefined || rel === '') return undefined
|
|
592
|
+
const binJs = join(dirname(pkgJsonPath), rel)
|
|
593
|
+
return existsSync(binJs) ? binJs : undefined
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* True when PATH already contains a wecom-cli executable.
|
|
598
|
+
* @param env - environment whose PATH to scan.
|
|
599
|
+
* @param platform - target platform name matching Node's `process.platform`.
|
|
600
|
+
* @returns true if an executable is found.
|
|
601
|
+
*/
|
|
602
|
+
export function wecomCliOnPath(
|
|
603
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
604
|
+
platform: NodeJS.Platform = process.platform,
|
|
605
|
+
): boolean {
|
|
606
|
+
const pathValue = env.PATH ?? env.Path ?? ''
|
|
607
|
+
const names = platform === 'win32'
|
|
608
|
+
? ['wecom-cli.exe', 'wecom-cli.cmd', 'wecom-cli.bat', 'wecom-cli']
|
|
609
|
+
: ['wecom-cli']
|
|
610
|
+
for (const dir of pathValue.split(delimiterFor(platform))) {
|
|
611
|
+
if (dir === '') continue
|
|
612
|
+
for (const name of names) {
|
|
613
|
+
if (existsSync(join(dir, name))) return true
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return false
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Write the deny shim and put it first on PATH, shadowing any other wecom-cli.
|
|
621
|
+
* Every child process of this host — group chats, the GUI, the user's own
|
|
622
|
+
* terminal tools — resolves `wecom-cli` to a command that refuses; the plugin
|
|
623
|
+
* itself never goes through PATH.
|
|
624
|
+
* @param options - home, env, and platform overrides for tests.
|
|
625
|
+
* @returns the shim directory and whether another wecom-cli was shadowed.
|
|
626
|
+
*/
|
|
627
|
+
export function ensureOnPath(options?: {
|
|
628
|
+
dshHome?: string
|
|
629
|
+
env?: NodeJS.ProcessEnv
|
|
630
|
+
platform?: NodeJS.Platform
|
|
631
|
+
}): EnsureOnPathResult {
|
|
632
|
+
const env = options?.env ?? process.env
|
|
633
|
+
const platform = options?.platform ?? process.platform
|
|
634
|
+
const shadowed = wecomCliOnPath(env, platform)
|
|
635
|
+
const dshHome = options?.dshHome ?? resolveDshHome(env)
|
|
636
|
+
const shimDir = join(dshHome, SHIM_DIR_NAME)
|
|
637
|
+
writeWecomShim(shimDir, platform)
|
|
638
|
+
prependPath(shimDir, env, platform)
|
|
639
|
+
return { shimDir, shadowed }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Write `wecom-cli` / `wecom-cli.cmd` that print {@link WECOM_CLI_SHIM_DENY_MESSAGE}
|
|
644
|
+
* and exit 1. The message names the tool that does work, so a model following a
|
|
645
|
+
* skill body's `wecom-cli ...` line learns the supported route from the failure.
|
|
646
|
+
* @param shimDir - directory to create.
|
|
647
|
+
* @param platform - target platform.
|
|
648
|
+
* @returns `shimDir`.
|
|
649
|
+
*/
|
|
650
|
+
export function writeWecomShim(
|
|
651
|
+
shimDir: string,
|
|
652
|
+
platform: NodeJS.Platform = process.platform,
|
|
653
|
+
): string {
|
|
654
|
+
mkdirSync(shimDir, { recursive: true })
|
|
655
|
+
if (platform === 'win32') {
|
|
656
|
+
writeFileSync(
|
|
657
|
+
join(shimDir, 'wecom-cli.cmd'),
|
|
658
|
+
`@echo off\r\necho ${WECOM_CLI_SHIM_DENY_MESSAGE}\r\nexit /b 1\r\n`,
|
|
659
|
+
'utf8',
|
|
660
|
+
)
|
|
661
|
+
return shimDir
|
|
662
|
+
}
|
|
663
|
+
const posix = join(shimDir, 'wecom-cli')
|
|
664
|
+
writeFileSync(
|
|
665
|
+
posix,
|
|
666
|
+
`#!/bin/sh\necho '${WECOM_CLI_SHIM_DENY_MESSAGE}'\nexit 1\n`,
|
|
667
|
+
{ encoding: 'utf8', mode: 0o755 },
|
|
668
|
+
)
|
|
669
|
+
chmodSync(posix, 0o755)
|
|
670
|
+
return shimDir
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Prepend `dir` to PATH on `env`.
|
|
675
|
+
* @param dir - directory to put first.
|
|
676
|
+
* @param env - environment object to mutate.
|
|
677
|
+
* @param platform - target platform (selects the PATH delimiter).
|
|
678
|
+
*/
|
|
679
|
+
export function prependPath(
|
|
680
|
+
dir: string,
|
|
681
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
682
|
+
platform: NodeJS.Platform = process.platform,
|
|
683
|
+
): void {
|
|
684
|
+
const sep = delimiterFor(platform)
|
|
685
|
+
const current = env.PATH ?? env.Path ?? ''
|
|
686
|
+
env.PATH = current === '' ? dir : `${dir}${sep}${current}`
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Map `auth show --status` stdout to a status tag.
|
|
691
|
+
* @param stdout - captured standard output.
|
|
692
|
+
* @returns `authorized` / `unauthorized` / `error`.
|
|
693
|
+
*/
|
|
694
|
+
export function parseAuthStatus(stdout: string): AuthStatus {
|
|
695
|
+
const line = stdout.trim().split(/\r?\n/).at(-1)?.trim() ?? ''
|
|
696
|
+
if (line === 'authorized' || line === 'unauthorized') return line
|
|
697
|
+
return 'error'
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Run `wecom-cli auth show --status` against `binJs`.
|
|
702
|
+
* @param binJs - absolute launcher path.
|
|
703
|
+
* @param configDir - credential directory injected for this spawn.
|
|
704
|
+
* @returns the parsed status; spawn failures become `error`.
|
|
705
|
+
*/
|
|
706
|
+
export async function probeAuth(binJs: string, configDir: string): Promise<AuthStatus> {
|
|
707
|
+
try {
|
|
708
|
+
const { stdout } = await execFileAsync(process.execPath, [binJs, 'auth', 'show', '--status'], {
|
|
709
|
+
timeout: AUTH_PROBE_TIMEOUT_MS,
|
|
710
|
+
windowsHide: true,
|
|
711
|
+
env: wecomCliEnv(configDir),
|
|
712
|
+
})
|
|
713
|
+
return parseAuthStatus(String(stdout))
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (error !== null && typeof error === 'object' && 'stdout' in error) {
|
|
716
|
+
const stdout = (error as { stdout: unknown }).stdout
|
|
717
|
+
const parsed = parseAuthStatus(String(stdout ?? ''))
|
|
718
|
+
if (parsed !== 'error') return parsed
|
|
719
|
+
}
|
|
720
|
+
return 'error'
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Hidden `@wecom/cli` argv: `auth init --bot-id/--secret`.
|
|
726
|
+
* Official `--manual` needs a TTY; these flags skip prompts when stderr is not a terminal.
|
|
727
|
+
* @param botId - plugin `botId`.
|
|
728
|
+
* @param secret - plugin `secret`.
|
|
729
|
+
* @returns argv after the launcher path. Do not log this array.
|
|
730
|
+
*/
|
|
731
|
+
export function authInitArgv(botId: string, secret: string): string[] {
|
|
732
|
+
return ['auth', 'init', '--bot-id', botId.trim(), '--secret', secret.trim()]
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Seed wecom-cli credentials from the plugin Bot ID / Secret via hidden `--bot-id/--secret`.
|
|
737
|
+
* Does not log the secret. Stdio stays piped so the CLI sees a non-TTY stderr.
|
|
738
|
+
* @param binJs - absolute launcher path.
|
|
739
|
+
* @param botId - plugin `botId`.
|
|
740
|
+
* @param secret - plugin `secret`.
|
|
741
|
+
* @param configDir - credential directory the seeded credentials are written to.
|
|
742
|
+
* @returns `undefined` when the process exits 0; otherwise an error string with no secret value.
|
|
743
|
+
*/
|
|
744
|
+
export async function trySeedAuth(
|
|
745
|
+
binJs: string,
|
|
746
|
+
botId: string,
|
|
747
|
+
secret: string,
|
|
748
|
+
configDir: string,
|
|
749
|
+
): Promise<string | undefined> {
|
|
750
|
+
const id = botId.trim()
|
|
751
|
+
const sec = secret.trim()
|
|
752
|
+
if (id === '' || sec === '') {
|
|
753
|
+
return AUTH_INIT_MISSING_MESSAGE
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
await execFileAsync(process.execPath, [binJs, ...authInitArgv(id, sec)], {
|
|
757
|
+
timeout: AUTH_INIT_TIMEOUT_MS,
|
|
758
|
+
windowsHide: true,
|
|
759
|
+
encoding: 'utf8',
|
|
760
|
+
env: wecomCliEnv(configDir),
|
|
761
|
+
})
|
|
762
|
+
return undefined
|
|
763
|
+
} catch {
|
|
764
|
+
// Credential check failed, timeout, or CLI rejected the hidden flags.
|
|
765
|
+
return authInitFailedMessage(configDir)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Reject argv that would re-authorize the CLI. QR / `auth init` creates a NEW
|
|
771
|
+
* bot; credentials are the plugin's job, and `--bot-id/--secret` never belong
|
|
772
|
+
* in a model-supplied command.
|
|
773
|
+
* @param argv - arguments after the `wecom-cli` name.
|
|
774
|
+
* @returns a model-facing reason, or undefined when the command may run.
|
|
775
|
+
*/
|
|
776
|
+
export function argvForbiddenAuth(argv: readonly string[]): string | undefined {
|
|
777
|
+
const tokens = new Set(argv.map(token => token.trim().toLowerCase()))
|
|
778
|
+
if (tokens.has('auth') && tokens.has('init')) {
|
|
779
|
+
return '禁止 auth init:重新授权会新建智能机器人。凭证由 im-bridge 用已有 botId/密钥维护;报 853004 时直接重试业务命令。'
|
|
780
|
+
}
|
|
781
|
+
if (tokens.has('--bot-id') || tokens.has('--secret')) {
|
|
782
|
+
return '禁止在 argv 里传 --bot-id/--secret。'
|
|
783
|
+
}
|
|
784
|
+
return undefined
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Validate the model's tool arguments.
|
|
789
|
+
* @param args - raw tool arguments.
|
|
790
|
+
* @returns the argv array.
|
|
791
|
+
* @throws when `argv` is missing, empty, or not all strings.
|
|
792
|
+
*/
|
|
793
|
+
export function parseWecomCliArgs(args: unknown): string[] {
|
|
794
|
+
const argv = typeof args === 'object' && args !== null
|
|
795
|
+
? (args as { argv?: unknown }).argv
|
|
796
|
+
: undefined
|
|
797
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.some(item => typeof item !== 'string')) {
|
|
798
|
+
throw new Error(`${WECOM_CLI_TOOL_NAME} 需要 argv:非空字符串数组,例如 ["message","aibot","sessions","list"]`)
|
|
799
|
+
}
|
|
800
|
+
return argv as string[]
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Clip `text` to a byte ceiling; a split multibyte character becomes U+FFFD.
|
|
805
|
+
* @param text - captured stream contents.
|
|
806
|
+
* @param limit - byte ceiling.
|
|
807
|
+
* @returns `text`, or a truncated copy that states the original byte length.
|
|
808
|
+
*/
|
|
809
|
+
export function clipOutput(text: string, limit: number = WECOM_CLI_TOOL_MAX_OUTPUT_BYTES): string {
|
|
810
|
+
const bytes = Buffer.from(text, 'utf8')
|
|
811
|
+
if (bytes.byteLength <= limit) return text
|
|
812
|
+
const head = new TextDecoder().decode(bytes.subarray(0, limit))
|
|
813
|
+
return `${head}\n…(输出已截断,原始 ${String(bytes.byteLength)} 字节)`
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Run one office command directly against `binJs`, bypassing PATH and the deny shim.
|
|
818
|
+
* A non-zero exit is a domain result, not a throw; cancellation propagates.
|
|
819
|
+
* @param binJs - absolute launcher path.
|
|
820
|
+
* @param argv - arguments after the `wecom-cli` name.
|
|
821
|
+
* @param configDir - credential directory injected for this spawn.
|
|
822
|
+
* @param signal - caller cancellation from the tool execution.
|
|
823
|
+
* @returns clipped stdout/stderr and the exit code.
|
|
824
|
+
*/
|
|
825
|
+
export async function runWecomCli(
|
|
826
|
+
binJs: string,
|
|
827
|
+
argv: readonly string[],
|
|
828
|
+
configDir: string,
|
|
829
|
+
signal?: AbortSignal,
|
|
830
|
+
): Promise<WecomCliRun> {
|
|
831
|
+
try {
|
|
832
|
+
const { stdout, stderr } = await execFileAsync(process.execPath, [binJs, ...argv], {
|
|
833
|
+
timeout: WECOM_CLI_TOOL_TIMEOUT_MS,
|
|
834
|
+
windowsHide: true,
|
|
835
|
+
encoding: 'utf8',
|
|
836
|
+
maxBuffer: WECOM_CLI_TOOL_MAX_OUTPUT_BYTES * 4,
|
|
837
|
+
env: wecomCliEnv(configDir),
|
|
838
|
+
signal,
|
|
839
|
+
})
|
|
840
|
+
return { stdout: clipOutput(String(stdout)), stderr: clipOutput(String(stderr)), exitCode: 0 }
|
|
841
|
+
} catch (error) {
|
|
842
|
+
if (signal?.aborted === true) throw error
|
|
843
|
+
const failure = error as { stdout?: unknown; stderr?: unknown; code?: unknown; message?: unknown }
|
|
844
|
+
const stderr = String(failure.stderr ?? '') || String(failure.message ?? '')
|
|
845
|
+
return {
|
|
846
|
+
stdout: clipOutput(String(failure.stdout ?? '')),
|
|
847
|
+
stderr: clipOutput(stderr),
|
|
848
|
+
exitCode: typeof failure.code === 'number' ? failure.code : 1,
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Model-facing text for one run: stdout, then stderr, then the exit code.
|
|
855
|
+
* @param value - completed run.
|
|
856
|
+
* @returns the rendered block.
|
|
857
|
+
*/
|
|
858
|
+
export function renderWecomCliRun(value: WecomCliRun): string {
|
|
859
|
+
const parts: string[] = []
|
|
860
|
+
if (value.stdout.trim() !== '') parts.push(value.stdout.trimEnd())
|
|
861
|
+
if (value.stderr.trim() !== '') parts.push(`[stderr]\n${value.stderr.trimEnd()}`)
|
|
862
|
+
parts.push(`[exit code: ${String(value.exitCode)}]`)
|
|
863
|
+
return parts.join('\n')
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Build the gated office tool. Registering it on an Agent context is the only
|
|
868
|
+
* way a model reaches wecom-cli; PATH resolves to the deny shim everywhere.
|
|
869
|
+
* @param binJs - absolute launcher path.
|
|
870
|
+
* @param configDir - credential directory injected into every run.
|
|
871
|
+
* @returns the duck-typed `ctx.tools.register()` payload.
|
|
872
|
+
*/
|
|
873
|
+
export function wecomCliToolDefinition(binJs: string, configDir: string): RuntimeToolRegistration {
|
|
874
|
+
return {
|
|
875
|
+
name: WECOM_CLI_TOOL_NAME,
|
|
876
|
+
description: '执行企业微信办公命令(wecom-cli)。argv 是 wecom-cli 之后的参数,例如 ["message","aibot","sessions","list"]。禁止 auth init 与扫码授权;不要用 pwsh/bash/npx 运行 wecom-cli。',
|
|
877
|
+
parameters: {
|
|
878
|
+
type: 'object',
|
|
879
|
+
properties: {
|
|
880
|
+
argv: {
|
|
881
|
+
type: 'array',
|
|
882
|
+
items: { type: 'string' },
|
|
883
|
+
description: 'wecom-cli 之后的参数,逐个元素,不要拼成一整条命令行。',
|
|
884
|
+
},
|
|
885
|
+
},
|
|
886
|
+
required: ['argv'],
|
|
887
|
+
},
|
|
888
|
+
output: {
|
|
889
|
+
schema: {
|
|
890
|
+
type: 'object',
|
|
891
|
+
properties: {
|
|
892
|
+
stdout: { type: 'string' },
|
|
893
|
+
stderr: { type: 'string' },
|
|
894
|
+
exitCode: { type: 'number' },
|
|
895
|
+
},
|
|
896
|
+
required: ['stdout', 'stderr', 'exitCode'],
|
|
897
|
+
},
|
|
898
|
+
render: (_args, value) => [{ type: 'text', text: renderWecomCliRun(value as WecomCliRun) }],
|
|
899
|
+
},
|
|
900
|
+
timeoutMs: WECOM_CLI_TOOL_TIMEOUT_MS,
|
|
901
|
+
async execute(args, exec) {
|
|
902
|
+
const argv = parseWecomCliArgs(args)
|
|
903
|
+
const forbidden = argvForbiddenAuth(argv)
|
|
904
|
+
if (forbidden !== undefined) throw new Error(forbidden)
|
|
905
|
+
exec.signal.throwIfAborted()
|
|
906
|
+
return runWecomCli(binJs, argv, configDir, exec.signal)
|
|
907
|
+
},
|
|
908
|
+
presentCall: (args) => {
|
|
909
|
+
const argv = typeof args === 'object' && args !== null ? (args as { argv?: unknown }).argv : undefined
|
|
910
|
+
const shown = Array.isArray(argv) ? argv.filter(item => typeof item === 'string').join(' ') : ''
|
|
911
|
+
return { card: 'terminal', title: `wecom-cli ${shown}`.trimEnd() }
|
|
912
|
+
},
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Register the gated office tool on this Agent's tools layer. Must be called on
|
|
918
|
+
* `agentCtx`: a host context would register it globally, exposing it to the GUI
|
|
919
|
+
* and to group chats.
|
|
920
|
+
* @param agentCtx - the Agent-scoped Cordis context.
|
|
921
|
+
* @param binJs - absolute launcher path.
|
|
922
|
+
* @param configDir - credential directory injected into every run.
|
|
923
|
+
* @returns true when the tool was registered.
|
|
924
|
+
*/
|
|
925
|
+
export function registerWecomCliTool(
|
|
926
|
+
agentCtx: AgentServiceHost,
|
|
927
|
+
binJs: string,
|
|
928
|
+
configDir: string,
|
|
929
|
+
): boolean {
|
|
930
|
+
const registry = toolsRegisterOf(agentCtx)
|
|
931
|
+
if (registry === undefined) {
|
|
932
|
+
console.warn(`[im-bridge] ${TOOLS_SERVICE_MISSING_MESSAGE}`)
|
|
933
|
+
return false
|
|
934
|
+
}
|
|
935
|
+
registry.register(wecomCliToolDefinition(binJs, configDir))
|
|
936
|
+
return true
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function toolsRegisterOf(agentCtx: AgentServiceHost): { register(tool: RuntimeToolRegistration): () => void } | undefined {
|
|
940
|
+
const tools = agentCtx.get('tools')
|
|
941
|
+
if (tools === undefined || tools === null || typeof tools !== 'object') return undefined
|
|
942
|
+
const register = (tools as { register?: unknown }).register
|
|
943
|
+
if (typeof register !== 'function') return undefined
|
|
944
|
+
return tools as { register(tool: RuntimeToolRegistration): () => void }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Parse one SKILL.md body. Missing/invalid frontmatter returns undefined.
|
|
949
|
+
* @param text - file contents.
|
|
950
|
+
* @param directory - skill directory (resource base).
|
|
951
|
+
* @returns the skill, or undefined when name/description are unusable.
|
|
952
|
+
*/
|
|
953
|
+
export function parseSkillMarkdown(text: string, directory: string): WecomSkill | undefined {
|
|
954
|
+
const split = splitFrontmatter(text)
|
|
955
|
+
if (split === undefined) return undefined
|
|
956
|
+
let data: unknown
|
|
957
|
+
try {
|
|
958
|
+
data = parseYaml(split.yaml)
|
|
959
|
+
} catch {
|
|
960
|
+
return undefined
|
|
961
|
+
}
|
|
962
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) return undefined
|
|
963
|
+
const record = data as Record<string, unknown>
|
|
964
|
+
const name = typeof record.name === 'string' ? record.name.trim() : ''
|
|
965
|
+
const description = typeof record.description === 'string' ? record.description.trim() : ''
|
|
966
|
+
if (name === '' || description === '' || !SKILL_NAME.test(name)) return undefined
|
|
967
|
+
return { name, description, content: split.body.trim(), directory }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Load SKILL.md from one-level subdirectories of `dir`.
|
|
972
|
+
* Missing or empty directories return []. Empty `allow` keeps every valid skill.
|
|
973
|
+
* @param dir - managed skills root.
|
|
974
|
+
* @param allow - optional name allow-list.
|
|
975
|
+
* @returns parsed skills in directory order.
|
|
976
|
+
*/
|
|
977
|
+
export function loadWecomSkills(dir: string, allow: readonly string[] = []): WecomSkill[] {
|
|
978
|
+
if (!existsSync(dir)) return []
|
|
979
|
+
let entries: string[]
|
|
980
|
+
try {
|
|
981
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
982
|
+
.filter(entry => entry.isDirectory())
|
|
983
|
+
.map(entry => entry.name)
|
|
984
|
+
} catch {
|
|
985
|
+
return []
|
|
986
|
+
}
|
|
987
|
+
const allowSet = new Set(allow.map(name => name.trim()).filter(Boolean))
|
|
988
|
+
const skills: WecomSkill[] = []
|
|
989
|
+
for (const name of entries) {
|
|
990
|
+
const directory = join(dir, name)
|
|
991
|
+
const file = join(directory, 'SKILL.md')
|
|
992
|
+
if (!existsSync(file)) continue
|
|
993
|
+
let text: string
|
|
994
|
+
try {
|
|
995
|
+
text = readFileSync(file, 'utf8')
|
|
996
|
+
} catch {
|
|
997
|
+
continue
|
|
998
|
+
}
|
|
999
|
+
const skill = parseSkillMarkdown(text, directory)
|
|
1000
|
+
if (skill === undefined) continue
|
|
1001
|
+
if (allowSet.size > 0 && !allowSet.has(skill.name)) continue
|
|
1002
|
+
skills.push(skill)
|
|
1003
|
+
}
|
|
1004
|
+
return skills
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function delimiterFor(platform: NodeJS.Platform): string {
|
|
1008
|
+
return platform === 'win32' ? ';' : ':'
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function splitFrontmatter(text: string): { yaml: string; body: string } | undefined {
|
|
1012
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
|
|
1013
|
+
if (match === null || match[1] === undefined) return undefined
|
|
1014
|
+
return { yaml: match[1], body: text.slice(match[0].length) }
|
|
1015
|
+
}
|