agent-muster 0.1.0

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,384 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import QRCode from 'qrcode';
7
+ import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream';
8
+ import { EnvHttpProxyAgent, fetch } from 'undici';
9
+ const directory = join(homedir(), '.agent-muster');
10
+ const dataFile = join(directory, 'dingtalk-apps.json');
11
+ const iconDirectory = join(directory, 'dingtalk-icons');
12
+ const imageDirectory = join(directory, 'dingtalk-images');
13
+ const apiBase = 'https://oapi.dingtalk.com';
14
+ const openApiBase = 'https://api.dingtalk.com';
15
+ const dispatcher = new EnvHttpProxyAgent();
16
+ async function post(path, body) {
17
+ const response = await fetch(`${apiBase}${path}`, {
18
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000), dispatcher,
19
+ });
20
+ if (!response.ok)
21
+ throw new Error(`钉钉接口请求失败 (${response.status})`);
22
+ const result = await response.json();
23
+ if (result.errcode !== 0)
24
+ throw new Error(result.errmsg || `钉钉接口错误 (${result.errcode})`);
25
+ return result;
26
+ }
27
+ export class DingTalkRegistry extends EventEmitter {
28
+ apps = [];
29
+ registrations = new Map();
30
+ clients = new Map();
31
+ accessTokens = new Map();
32
+ writeQueue = Promise.resolve();
33
+ async load() {
34
+ try {
35
+ const stored = JSON.parse(await readFile(dataFile, 'utf8'));
36
+ this.apps = stored.map(({ id, clientId, clientSecret, name, iconMime, project, agent, conversations, conversationTargets }) => ({
37
+ id, clientId, clientSecret, name: name || null, iconMime: iconMime || null, project, agent: agent || null, conversations: conversations || {}, conversationTargets,
38
+ }));
39
+ }
40
+ catch (error) {
41
+ if (error.code !== 'ENOENT')
42
+ throw error;
43
+ }
44
+ }
45
+ list() {
46
+ return this.apps.map(({ id, clientId, name, iconMime, project, agent }) => ({ id, clientId, name, icon: iconMime ? `/api/dingtalk/apps/${id}/icon` : null, project, agent }));
47
+ }
48
+ async getIcon(id) {
49
+ const app = this.apps.find(item => item.id === id);
50
+ if (!app?.iconMime)
51
+ return null;
52
+ return { data: await readFile(join(iconDirectory, id)), mime: app.iconMime };
53
+ }
54
+ getBinding(id) {
55
+ const app = this.apps.find(item => item.id === id);
56
+ return app ? { project: app.project, agent: app.agent } : undefined;
57
+ }
58
+ getSession(id, conversation) {
59
+ return this.apps.find(item => item.id === id)?.conversations[conversation];
60
+ }
61
+ sessionBindings() {
62
+ return this.apps.flatMap(app => Object.values(app.conversations).map(sessionId => ({ sessionId, appId: app.id })));
63
+ }
64
+ async setSession(id, conversation, sessionId) {
65
+ const app = this.apps.find(item => item.id === id);
66
+ if (!app)
67
+ throw new Error('钉钉应用不存在');
68
+ app.conversations[conversation] = sessionId;
69
+ await this.persist();
70
+ }
71
+ async setConversationTarget(id, conversation, message) {
72
+ const app = this.apps.find(item => item.id === id);
73
+ if (!app)
74
+ throw new Error('钉钉应用不存在');
75
+ const target = message.conversationType === '1'
76
+ ? { type: '1', robotCode: message.robotCode, userId: message.senderStaffId }
77
+ : { type: '2', robotCode: message.robotCode, openConversationId: message.conversationId };
78
+ app.conversationTargets ||= {};
79
+ app.conversationTargets[conversation] = target;
80
+ await this.persist();
81
+ }
82
+ async downloadImage(id, robotCode, downloadCode) {
83
+ const app = this.apps.find(item => item.id === id);
84
+ if (!app)
85
+ throw new Error('钉钉应用不存在');
86
+ if (!downloadCode)
87
+ throw new Error('钉钉图片消息缺少下载码');
88
+ if (!robotCode)
89
+ throw new Error('钉钉图片消息缺少机器人编码');
90
+ const token = await this.getAccessToken(app);
91
+ const response = await fetch(`${openApiBase}/v1.0/robot/messageFiles/download`, {
92
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token },
93
+ body: JSON.stringify({ robotCode, downloadCode }),
94
+ redirect: 'error', signal: AbortSignal.timeout(10000), dispatcher,
95
+ });
96
+ const body = await response.text();
97
+ if (!response.ok)
98
+ throw new Error(`钉钉图片下载地址获取失败 (${response.status}): ${body.slice(0, 2000)}`);
99
+ let result;
100
+ try {
101
+ result = JSON.parse(body);
102
+ }
103
+ catch (error) {
104
+ throw new Error(`钉钉图片下载地址响应不是有效 JSON: ${body.slice(0, 2000)}`, { cause: error });
105
+ }
106
+ if (typeof result?.downloadUrl !== 'string' || !result.downloadUrl)
107
+ throw new Error(`钉钉未返回图片下载地址: ${body.slice(0, 2000)}`);
108
+ const url = new URL(result.downloadUrl);
109
+ if (url.protocol !== 'https:')
110
+ throw new Error('钉钉返回了无效的图片下载地址');
111
+ const image = await fetch(url, { signal: AbortSignal.timeout(30000), dispatcher });
112
+ if (!image.ok)
113
+ throw new Error(`钉钉图片下载失败 (${image.status}): ${(await image.text()).slice(0, 2000)}`);
114
+ const mimeType = image.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
115
+ const extensions = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
116
+ if (!mimeType || !extensions[mimeType])
117
+ throw new Error(`钉钉返回了不支持的图片类型: ${mimeType}`);
118
+ const data = Buffer.from(await image.arrayBuffer());
119
+ if (!data.length)
120
+ throw new Error('钉钉返回了空图片');
121
+ await mkdir(imageDirectory, { recursive: true, mode: 0o700 });
122
+ const path = join(imageDirectory, `${randomUUID()}.${extensions[mimeType]}`);
123
+ await writeFile(path, data, { mode: 0o600 });
124
+ return { type: 'image', mimeType, data: data.toString('base64'), path };
125
+ }
126
+ async sendToSession(appId, sessionId, content) {
127
+ const app = this.apps.find(item => item.id === appId);
128
+ if (!app)
129
+ throw new Error('钉钉应用不存在');
130
+ const conversation = Object.keys(app.conversations).find(key => app.conversations[key] === sessionId);
131
+ const target = conversation ? app.conversationTargets?.[conversation] : undefined;
132
+ if (!target)
133
+ throw new Error('此对话尚无发送目标,请先在钉钉中向应用发送一条消息');
134
+ if (!target.robotCode || (target.type === '1' ? !target.userId : !target.openConversationId))
135
+ throw new Error('钉钉对话的发送目标不完整');
136
+ const token = await this.getAccessToken(app);
137
+ const path = target.type === '1' ? '/v1.0/robot/oToMessages/batchSend' : '/v1.0/robot/groupMessages/send';
138
+ const response = await fetch(`${openApiBase}${path}`, {
139
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token },
140
+ body: JSON.stringify({
141
+ robotCode: target.robotCode, msgKey: 'sampleText', msgParam: JSON.stringify({ content }),
142
+ ...(target.type === '1' ? { userIds: [target.userId] } : { openConversationId: target.openConversationId }),
143
+ }),
144
+ redirect: 'error', signal: AbortSignal.timeout(10000), dispatcher,
145
+ });
146
+ const result = await response.json();
147
+ if (!response.ok)
148
+ throw new Error(result.message || `钉钉消息发送失败 (${response.status})`);
149
+ if (result.invalidStaffIdList?.length)
150
+ throw new Error('钉钉消息发送失败:接收人无效');
151
+ if (result.flowControlledStaffIdList?.length)
152
+ throw new Error('钉钉消息发送失败:发送频率受限,请稍后重试');
153
+ if (!result.processQueryKey)
154
+ throw new Error(result.message || '钉钉未返回消息发送凭证');
155
+ }
156
+ async unbindSession(sessionId) {
157
+ let changed = false;
158
+ for (const app of this.apps) {
159
+ for (const [conversation, id] of Object.entries(app.conversations)) {
160
+ if (id !== sessionId)
161
+ continue;
162
+ delete app.conversations[conversation];
163
+ delete app.conversationTargets?.[conversation];
164
+ changed = true;
165
+ }
166
+ }
167
+ if (changed)
168
+ await this.persist();
169
+ }
170
+ async reply(id, webhook, content) {
171
+ const url = new URL(webhook);
172
+ if (url.protocol !== 'https:' || url.hostname !== 'oapi.dingtalk.com' || url.pathname !== '/robot/sendBySession') {
173
+ throw new Error('钉钉返回了无效的回复地址');
174
+ }
175
+ const client = this.clients.get(id);
176
+ if (!client)
177
+ throw new Error('钉钉订阅未启动');
178
+ const accessToken = await client.getAccessToken();
179
+ const response = await fetch(url, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': accessToken },
182
+ body: JSON.stringify({ msgtype: 'text', text: { content } }),
183
+ redirect: 'error', signal: AbortSignal.timeout(10000), dispatcher,
184
+ });
185
+ if (!response.ok)
186
+ throw new Error(`钉钉回复失败 (${response.status})`);
187
+ const result = await response.json();
188
+ if (result.errcode !== 0)
189
+ throw new Error(result.errmsg || `钉钉回复失败 (${result.errcode})`);
190
+ }
191
+ startSubscriptions() {
192
+ for (const app of this.apps)
193
+ this.subscribe(app);
194
+ }
195
+ shutdown() {
196
+ for (const client of this.clients.values())
197
+ client.disconnect();
198
+ this.clients.clear();
199
+ this.accessTokens.clear();
200
+ }
201
+ subscribe(app) {
202
+ const current = this.clients.get(app.id);
203
+ if (current?.config.clientId === app.clientId && current.config.clientSecret === app.clientSecret)
204
+ return;
205
+ current?.disconnect();
206
+ const client = new DWClient({ clientId: app.clientId, clientSecret: app.clientSecret });
207
+ client.registerCallbackListener(TOPIC_ROBOT, event => {
208
+ try {
209
+ const message = JSON.parse(event.data);
210
+ this.emit('message', { appId: app.id, message });
211
+ client.socketCallBackResponse(event.headers.messageId, null);
212
+ }
213
+ catch (error) {
214
+ console.error(`DingTalk message handling failed for ${app.clientId}:`, error);
215
+ }
216
+ });
217
+ this.clients.set(app.id, client);
218
+ void client.connect().catch(error => console.error(`DingTalk subscription failed for ${app.clientId}:`, error));
219
+ }
220
+ async start() {
221
+ const { nonce } = await post('/app/registration/init', {});
222
+ const result = await post('/app/registration/begin', { nonce });
223
+ const url = new URL(result.verification_uri_complete);
224
+ if (url.protocol !== 'https:' || !url.hostname.endsWith('.dingtalk.com'))
225
+ throw new Error('钉钉返回了无效的授权地址');
226
+ const id = randomUUID();
227
+ const expiresAt = Date.now() + result.expires_in * 1000;
228
+ this.registrations.set(id, { deviceCode: result.device_code, expiresAt, interval: result.interval, lastPolledAt: 0 });
229
+ const svg = await QRCode.toString(url.href, { type: 'svg', margin: 1, width: 220 });
230
+ return { id, userCode: result.user_code, verificationUrl: url.href, qrCode: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, expiresAt, interval: result.interval };
231
+ }
232
+ async poll(id) {
233
+ const registration = this.registrations.get(id);
234
+ if (!registration)
235
+ throw new Error('扫码会话不存在');
236
+ if (registration.result)
237
+ return registration.result;
238
+ if (Date.now() >= registration.expiresAt) {
239
+ registration.result = { status: 'EXPIRED' };
240
+ return registration.result;
241
+ }
242
+ if (registration.polling)
243
+ return registration.polling;
244
+ if (Date.now() - registration.lastPolledAt < registration.interval * 1000)
245
+ return { status: 'WAITING' };
246
+ registration.lastPolledAt = Date.now();
247
+ registration.polling = (async () => {
248
+ const result = await post('/app/registration/poll', { device_code: registration.deviceCode });
249
+ if (result.status === 'SUCCESS') {
250
+ if (!result.client_id || !result.client_secret)
251
+ throw new Error('钉钉未返回应用凭证');
252
+ const app = await this.saveApp(result.client_id, result.client_secret);
253
+ this.subscribe(app);
254
+ registration.result = { status: 'SUCCESS', appId: app.id };
255
+ }
256
+ else if (result.status === 'FAIL' || result.status === 'EXPIRED') {
257
+ registration.result = { status: result.status, reason: result.fail_reason };
258
+ }
259
+ return registration.result || { status: 'WAITING' };
260
+ })();
261
+ try {
262
+ return await registration.polling;
263
+ }
264
+ finally {
265
+ registration.polling = undefined;
266
+ }
267
+ }
268
+ async bind(id, binding, projects, agents) {
269
+ if (binding.project !== undefined && binding.project !== null && !projects.includes(binding.project))
270
+ throw new Error('项目不存在');
271
+ if (binding.agent !== undefined && binding.agent !== null && !agents.includes(binding.agent))
272
+ throw new Error('Agent 不可用');
273
+ const app = this.apps.find(item => item.id === id);
274
+ if (!app)
275
+ throw new Error('钉钉应用不存在');
276
+ if ((binding.project !== undefined && binding.project !== app.project) || (binding.agent !== undefined && binding.agent !== app.agent)) {
277
+ app.conversations = {};
278
+ app.conversationTargets = {};
279
+ }
280
+ if (binding.project !== undefined)
281
+ app.project = binding.project;
282
+ if (binding.agent !== undefined)
283
+ app.agent = binding.agent;
284
+ await this.persist();
285
+ return { id: app.id, clientId: app.clientId, name: app.name, icon: app.iconMime ? `/api/dingtalk/apps/${app.id}/icon` : null, project: app.project, agent: app.agent };
286
+ }
287
+ async remove(id) {
288
+ const index = this.apps.findIndex(app => app.id === id);
289
+ if (index < 0)
290
+ throw new Error('钉钉应用不存在');
291
+ const [app] = this.apps.splice(index, 1);
292
+ this.clients.get(id)?.disconnect();
293
+ this.clients.delete(id);
294
+ this.accessTokens.delete(id);
295
+ await this.persist();
296
+ if (app.iconMime)
297
+ await rm(join(iconDirectory, id), { force: true });
298
+ }
299
+ async unbindProject(project) {
300
+ for (const app of this.apps) {
301
+ if (app.project !== project)
302
+ continue;
303
+ app.project = null;
304
+ app.conversations = {};
305
+ app.conversationTargets = {};
306
+ }
307
+ await this.persist();
308
+ }
309
+ async saveApp(clientId, clientSecret) {
310
+ let app = this.apps.find(item => item.clientId === clientId);
311
+ if (app) {
312
+ if (app.clientSecret !== clientSecret)
313
+ this.accessTokens.delete(app.id);
314
+ app.clientSecret = clientSecret;
315
+ }
316
+ else {
317
+ app = { id: randomUUID(), clientId, clientSecret, name: null, iconMime: null, project: null, agent: null, conversations: {} };
318
+ this.apps.push(app);
319
+ }
320
+ await this.persist();
321
+ return app;
322
+ }
323
+ async syncMetadata(id) {
324
+ const app = this.apps.find(item => item.id === id);
325
+ if (!app)
326
+ throw new Error('钉钉应用不存在');
327
+ const token = await this.getAccessToken(app);
328
+ const response = await fetch(`${openApiBase}/v1.0/microApp/allInnerApps`, {
329
+ headers: { 'x-acs-dingtalk-access-token': token }, signal: AbortSignal.timeout(10000), dispatcher,
330
+ });
331
+ const result = await response.json();
332
+ if (response.status === 403 && result.code === 'Forbidden.AccessDenied.AccessTokenPermissionDenied') {
333
+ throw new Error('缺少钉钉权限 qyapi_get_microapp_list,请在钉钉开发者后台为此应用开通后重试');
334
+ }
335
+ if (!response.ok)
336
+ throw new Error(`钉钉应用信息获取失败 (${response.status})`);
337
+ const match = result.appList?.find(item => item.robotInfo?.robotCode === app.clientId);
338
+ if (!match?.name || !match.icon)
339
+ throw new Error('钉钉应用列表中未找到与当前 Client ID 匹配的名称和图标');
340
+ const iconUrl = new URL(match.icon);
341
+ if (iconUrl.protocol !== 'https:')
342
+ throw new Error('钉钉返回了无效的图标地址');
343
+ const iconResponse = await fetch(iconUrl, { signal: AbortSignal.timeout(10000), dispatcher });
344
+ if (!iconResponse.ok)
345
+ throw new Error(`钉钉图标下载失败 (${iconResponse.status})`);
346
+ const mime = iconResponse.headers.get('content-type')?.split(';')[0];
347
+ if (mime !== 'image/png' && mime !== 'image/jpeg' && mime !== 'image/webp' && mime !== 'image/gif') {
348
+ throw new Error('钉钉返回了无效的图标');
349
+ }
350
+ await mkdir(iconDirectory, { recursive: true, mode: 0o700 });
351
+ await writeFile(join(iconDirectory, app.id), Buffer.from(await iconResponse.arrayBuffer()), { mode: 0o600 });
352
+ app.name = match.name;
353
+ app.iconMime = mime;
354
+ await this.persist();
355
+ return { id: app.id, clientId: app.clientId, name: app.name, icon: `/api/dingtalk/apps/${app.id}/icon?v=${Date.now()}`, project: app.project, agent: app.agent };
356
+ }
357
+ async getAccessToken(app) {
358
+ const cached = this.accessTokens.get(app.id);
359
+ if (cached && cached.expiresAt > Date.now())
360
+ return cached.value;
361
+ const response = await fetch(`${openApiBase}/v1.0/oauth2/accessToken`, {
362
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
363
+ body: JSON.stringify({ appKey: app.clientId, appSecret: app.clientSecret }),
364
+ signal: AbortSignal.timeout(10000), dispatcher,
365
+ });
366
+ if (!response.ok)
367
+ throw new Error(`钉钉 accessToken 获取失败 (${response.status})`);
368
+ const result = await response.json();
369
+ if (!result.accessToken || !result.expireIn)
370
+ throw new Error('钉钉未返回有效的 accessToken');
371
+ this.accessTokens.set(app.id, { value: result.accessToken, expiresAt: Date.now() + Math.max(0, result.expireIn - 60) * 1000 });
372
+ return result.accessToken;
373
+ }
374
+ async persist() {
375
+ const snapshot = JSON.stringify(this.apps);
376
+ this.writeQueue = this.writeQueue.then(async () => {
377
+ await mkdir(directory, { recursive: true, mode: 0o700 });
378
+ const temporary = `${dataFile}.${process.pid}.tmp`;
379
+ await writeFile(temporary, snapshot, { mode: 0o600 });
380
+ await rename(temporary, dataFile);
381
+ });
382
+ await this.writeQueue;
383
+ }
384
+ }
@@ -0,0 +1,53 @@
1
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ const directory = join(homedir(), '.agent-muster');
5
+ const dataFile = join(directory, 'projects.json');
6
+ export class ProjectRegistry {
7
+ paths = [];
8
+ writeQueue = Promise.resolve();
9
+ async load() {
10
+ try {
11
+ this.paths = JSON.parse(await readFile(dataFile, 'utf8'));
12
+ }
13
+ catch (error) {
14
+ if (error.code !== 'ENOENT')
15
+ throw error;
16
+ }
17
+ }
18
+ list() {
19
+ return [...this.paths];
20
+ }
21
+ async add(path) {
22
+ if (!isAbsolute(path) || !(await stat(path).then(value => value.isDirectory()).catch(() => false))) {
23
+ throw new Error('Project directory must be an existing absolute path');
24
+ }
25
+ const project = resolve(path);
26
+ if (this.paths.includes(project))
27
+ return project;
28
+ this.paths.push(project);
29
+ const snapshot = JSON.stringify(this.paths);
30
+ this.writeQueue = this.writeQueue.then(async () => {
31
+ await mkdir(directory, { recursive: true });
32
+ const temporary = `${dataFile}.${process.pid}.tmp`;
33
+ await writeFile(temporary, snapshot);
34
+ await rename(temporary, dataFile);
35
+ });
36
+ await this.writeQueue;
37
+ return project;
38
+ }
39
+ async remove(path) {
40
+ const index = this.paths.indexOf(path);
41
+ if (index < 0)
42
+ throw new Error('Project not found');
43
+ this.paths.splice(index, 1);
44
+ const snapshot = JSON.stringify(this.paths);
45
+ this.writeQueue = this.writeQueue.then(async () => {
46
+ await mkdir(directory, { recursive: true });
47
+ const temporary = `${dataFile}.${process.pid}.tmp`;
48
+ await writeFile(temporary, snapshot);
49
+ await rename(temporary, dataFile);
50
+ });
51
+ await this.writeQueue;
52
+ }
53
+ }