@xmanrui/dsh-im 1.2.0 → 1.3.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.
- package/lib/index.js +166 -163
- package/package.json +1 -1
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
- package/plugin-src/host/channels/feishu/production.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +3 -1
- package/plugin-src/host/channels/shared/production.mjs +3 -1
- package/plugin-src/host/channels/slack/production.mjs +3 -1
- package/plugin-src/host/channels/wecom/production.mjs +3 -1
- package/plugin-src/host/channels/weixin/production.mjs +3 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
- package/plugin-src/host/harness-session-coordinator.mjs +32 -5
- package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
- package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
- package/src/channels/discord/discord-runtime.mjs +23 -0
- package/src/channels/feishu/bridge.mjs +18 -10
- package/src/channels/feishu/message-utils.mjs +47 -0
- package/src/channels/qq/qq-bridge.mjs +80 -28
- package/src/channels/shared/file-download.mjs +64 -0
- package/src/channels/shared/harness-client.mjs +45 -0
- package/src/channels/shared/inbound-file.mjs +206 -0
- package/src/channels/shared/text-harness-bridge.mjs +31 -11
- package/src/channels/slack/slack-api.mjs +27 -4
- package/src/channels/slack/slack-runtime.mjs +55 -5
- package/src/channels/telegram/telegram-api.mjs +21 -6
- package/src/channels/telegram/telegram-runtime.mjs +24 -3
- package/src/channels/wecom/wecom-bridge.mjs +73 -10
- package/src/channels/weixin/weixin-api.mjs +45 -0
- package/src/channels/weixin/weixin-bridge.mjs +52 -18
- package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
- package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
package/package.json
CHANGED
|
@@ -86,9 +86,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
86
86
|
return state;
|
|
87
87
|
};
|
|
88
88
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
89
|
-
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
|
89
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
|
90
90
|
controlExecutor: internals.controlExecutor,
|
|
91
91
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
|
92
|
+
fileIngressExecutor: internals.fileIngressExecutor,
|
|
92
93
|
});
|
|
93
94
|
const harness = new Harness({
|
|
94
95
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
@@ -98,6 +99,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
98
99
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
99
100
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
100
101
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
102
|
+
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
101
103
|
});
|
|
102
104
|
const coreController = new Controller({
|
|
103
105
|
deviceAuth,
|
|
@@ -133,9 +133,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
133
133
|
return stateFor(botConfig);
|
|
134
134
|
};
|
|
135
135
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
136
|
-
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
|
136
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
|
137
137
|
controlExecutor: internals.controlExecutor,
|
|
138
138
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
|
139
|
+
fileIngressExecutor: internals.fileIngressExecutor,
|
|
139
140
|
});
|
|
140
141
|
const harness = new Harness({
|
|
141
142
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
@@ -147,6 +148,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
147
148
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
148
149
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
149
150
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
151
|
+
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
150
152
|
});
|
|
151
153
|
const proxyEnv = internals.proxyEnv ?? process.env;
|
|
152
154
|
const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);
|
|
@@ -77,9 +77,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
77
77
|
return state;
|
|
78
78
|
};
|
|
79
79
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
80
|
-
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
|
80
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
|
81
81
|
controlExecutor: internals.controlExecutor,
|
|
82
82
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
|
83
|
+
fileIngressExecutor: internals.fileIngressExecutor,
|
|
83
84
|
});
|
|
84
85
|
const harness = new Harness({
|
|
85
86
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
@@ -89,6 +90,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
89
90
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
90
91
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
91
92
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
93
|
+
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
92
94
|
});
|
|
93
95
|
const coreController = new Controller({
|
|
94
96
|
qrAuth,
|
|
@@ -78,9 +78,10 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|
|
78
78
|
return state;
|
|
79
79
|
};
|
|
80
80
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
81
|
-
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
|
81
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
|
82
82
|
controlExecutor: internals.controlExecutor,
|
|
83
83
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
|
84
|
+
fileIngressExecutor: internals.fileIngressExecutor,
|
|
84
85
|
});
|
|
85
86
|
const harness = new ResolvedHarness({
|
|
86
87
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
@@ -90,6 +91,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|
|
90
91
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
91
92
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
92
93
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
94
|
+
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
93
95
|
});
|
|
94
96
|
const coreController = new ResolvedController({
|
|
95
97
|
credentials: ctx.credentials,
|
|
@@ -56,9 +56,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
56
56
|
return state;
|
|
57
57
|
};
|
|
58
58
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
59
|
-
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
|
59
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
|
60
60
|
controlExecutor: internals.controlExecutor,
|
|
61
61
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
|
62
|
+
fileIngressExecutor: internals.fileIngressExecutor,
|
|
62
63
|
});
|
|
63
64
|
const harness = new ResolvedHarness({
|
|
64
65
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
@@ -68,6 +69,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
68
69
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
69
70
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
70
71
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
72
|
+
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
71
73
|
});
|
|
72
74
|
const coreController = new ResolvedController({
|
|
73
75
|
credentials: ctx.credentials,
|
|
@@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
80
80
|
return state;
|
|
81
81
|
};
|
|
82
82
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
83
|
-
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
|
83
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
|
84
84
|
controlExecutor: internals.controlExecutor,
|
|
85
85
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
|
86
|
+
fileIngressExecutor: internals.fileIngressExecutor,
|
|
86
87
|
});
|
|
87
88
|
const harness = new Harness({
|
|
88
89
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
@@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
92
93
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
93
94
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
94
95
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
96
|
+
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
95
97
|
});
|
|
96
98
|
const coreController = new Controller({
|
|
97
99
|
qrAuth,
|
|
@@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
80
80
|
return state;
|
|
81
81
|
};
|
|
82
82
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
83
|
-
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
|
83
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
|
84
84
|
controlExecutor: internals.controlExecutor,
|
|
85
85
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
|
86
|
+
fileIngressExecutor: internals.fileIngressExecutor,
|
|
86
87
|
});
|
|
87
88
|
const harness = new Harness({
|
|
88
89
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
@@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
92
93
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
93
94
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
94
95
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
96
|
+
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
95
97
|
});
|
|
96
98
|
const coreController = new Controller({
|
|
97
99
|
api,
|
|
@@ -83,9 +83,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
83
83
|
return state;
|
|
84
84
|
};
|
|
85
85
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
86
|
-
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
|
86
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
|
87
87
|
controlExecutor: internals.controlExecutor,
|
|
88
88
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
|
89
|
+
fileIngressExecutor: internals.fileIngressExecutor,
|
|
89
90
|
});
|
|
90
91
|
const harness = new Harness({
|
|
91
92
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
|
@@ -95,6 +96,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
95
96
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
96
97
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
97
98
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
99
|
+
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
98
100
|
});
|
|
99
101
|
const coreController = new Controller({
|
|
100
102
|
configStore: observedConfigStore,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
InboundFileError,
|
|
5
|
+
stageInboundFiles,
|
|
6
|
+
} from '../../src/channels/shared/inbound-file.mjs';
|
|
7
|
+
|
|
3
8
|
function agentsFromContext(ctx) {
|
|
4
9
|
if (typeof ctx?.get !== 'function') return undefined;
|
|
5
10
|
let agents;
|
|
@@ -108,13 +113,30 @@ function createSessionMaintenanceExecutor(agents) {
|
|
|
108
113
|
};
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
function createFileIngressExecutor(agents) {
|
|
117
|
+
return ({ sessionId, workspace, files, signal }) => {
|
|
118
|
+
const agent = agents.get(sessionId);
|
|
119
|
+
const attachedWorkspace = agent?.session?.header?.cwd;
|
|
120
|
+
const exactWorkspace = typeof attachedWorkspace === 'string' && attachedWorkspace
|
|
121
|
+
? attachedWorkspace
|
|
122
|
+
: workspace;
|
|
123
|
+
if (typeof exactWorkspace !== 'string' || !exactWorkspace) {
|
|
124
|
+
throw new InboundFileError(
|
|
125
|
+
'inbound-file-workspace-unavailable',
|
|
126
|
+
'The Harness Session workspace is unavailable for inbound files.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return stageInboundFiles({ files }, { workspace: exactWorkspace, signal });
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
111
133
|
/**
|
|
112
|
-
* Build optional same-process Session executors
|
|
113
|
-
*
|
|
114
|
-
*
|
|
134
|
+
* Build optional same-process Session executors. File ingress also works for
|
|
135
|
+
* cold Sessions by using the authoritative cwd returned by session.list;
|
|
136
|
+
* when an Agent is attached, its live Session header remains authoritative.
|
|
115
137
|
*/
|
|
116
138
|
export function createHarnessSessionExecutors(ctx, provided = {}) {
|
|
117
|
-
const { controlExecutor, sessionMaintenanceExecutor } = provided;
|
|
139
|
+
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = provided;
|
|
118
140
|
if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
|
|
119
141
|
throw new TypeError('controlExecutor must be a function');
|
|
120
142
|
}
|
|
@@ -122,13 +144,18 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
|
|
|
122
144
|
&& typeof sessionMaintenanceExecutor !== 'function') {
|
|
123
145
|
throw new TypeError('sessionMaintenanceExecutor must be a function');
|
|
124
146
|
}
|
|
147
|
+
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
|
|
148
|
+
throw new TypeError('fileIngressExecutor must be a function');
|
|
149
|
+
}
|
|
125
150
|
|
|
126
|
-
const agents = controlExecutor && sessionMaintenanceExecutor
|
|
151
|
+
const agents = controlExecutor && sessionMaintenanceExecutor && fileIngressExecutor
|
|
127
152
|
? undefined
|
|
128
153
|
: agentsFromContext(ctx);
|
|
129
154
|
return {
|
|
130
155
|
controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined),
|
|
131
156
|
sessionMaintenanceExecutor: sessionMaintenanceExecutor
|
|
132
157
|
?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
|
|
158
|
+
fileIngressExecutor: fileIngressExecutor
|
|
159
|
+
?? createFileIngressExecutor(agents ?? { get: () => undefined }),
|
|
133
160
|
};
|
|
134
161
|
}
|
|
@@ -435,6 +435,48 @@ export function createDingtalkApi({
|
|
|
435
435
|
return request;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
async function messageFileDownloadUrl({
|
|
439
|
+
clientId,
|
|
440
|
+
clientSecret,
|
|
441
|
+
robotCode,
|
|
442
|
+
downloadCode,
|
|
443
|
+
signal,
|
|
444
|
+
kind,
|
|
445
|
+
}) {
|
|
446
|
+
const botCode = nonEmptyString(robotCode);
|
|
447
|
+
const fileCode = nonEmptyString(downloadCode);
|
|
448
|
+
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
|
|
449
|
+
const token = await accessToken({ clientId, clientSecret, signal });
|
|
450
|
+
let response;
|
|
451
|
+
try {
|
|
452
|
+
response = await requestJson(
|
|
453
|
+
fetchImpl,
|
|
454
|
+
endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
|
|
455
|
+
{
|
|
456
|
+
body: { downloadCode: fileCode, robotCode: botCode },
|
|
457
|
+
headers: { 'x-acs-dingtalk-access-token': token },
|
|
458
|
+
signal,
|
|
459
|
+
action: `${kind === 'image' ? '图片' : '文件'}下载地址`,
|
|
460
|
+
},
|
|
461
|
+
);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (signal?.aborted) throw error;
|
|
464
|
+
throw new DingtalkApiError(
|
|
465
|
+
`${kind}-download-address-failed`,
|
|
466
|
+
`钉钉${kind === 'image' ? '图片' : '文件'}下载地址获取失败。`,
|
|
467
|
+
{ cause: error, status: error?.status, providerCode: error?.providerCode },
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
|
|
471
|
+
if (!downloadUrl) {
|
|
472
|
+
throw new DingtalkApiError(
|
|
473
|
+
`invalid-${kind}-download`,
|
|
474
|
+
`钉钉服务没有返回${kind === 'image' ? '图片' : '文件'}下载地址。`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
return secureDingtalkDownloadUrl(downloadUrl);
|
|
478
|
+
}
|
|
479
|
+
|
|
438
480
|
function acquireCardRequestSlot(signal) {
|
|
439
481
|
const acquire = async () => {
|
|
440
482
|
const waitMs = Math.max(0, nextCardRequestAt - now());
|
|
@@ -568,45 +610,69 @@ export function createDingtalkApi({
|
|
|
568
610
|
signal,
|
|
569
611
|
maxBytes,
|
|
570
612
|
}) {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
613
|
+
const downloadUrl = await messageFileDownloadUrl({
|
|
614
|
+
clientId,
|
|
615
|
+
clientSecret,
|
|
616
|
+
robotCode,
|
|
617
|
+
downloadCode,
|
|
618
|
+
signal,
|
|
619
|
+
kind: 'image',
|
|
620
|
+
});
|
|
576
621
|
try {
|
|
577
|
-
|
|
622
|
+
return await fetchImageBuffer(downloadUrl, {
|
|
578
623
|
fetchImpl,
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
headers: { 'x-acs-dingtalk-access-token': token },
|
|
583
|
-
signal,
|
|
584
|
-
action: '图片下载地址',
|
|
585
|
-
},
|
|
586
|
-
);
|
|
624
|
+
signal,
|
|
625
|
+
maxBytes,
|
|
626
|
+
});
|
|
587
627
|
} catch (error) {
|
|
588
|
-
if (signal?.aborted) throw error;
|
|
628
|
+
if (signal?.aborted || error instanceof ImagePromptError) throw error;
|
|
589
629
|
throw new DingtalkApiError(
|
|
590
|
-
'image-download-
|
|
591
|
-
'
|
|
592
|
-
{ cause: error
|
|
630
|
+
'image-content-download-failed',
|
|
631
|
+
'钉钉图片内容下载失败。',
|
|
632
|
+
{ cause: error },
|
|
593
633
|
);
|
|
594
634
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
async downloadFile({
|
|
638
|
+
clientId,
|
|
639
|
+
clientSecret,
|
|
640
|
+
robotCode,
|
|
641
|
+
downloadCode,
|
|
642
|
+
signal,
|
|
643
|
+
}) {
|
|
644
|
+
const downloadUrl = await messageFileDownloadUrl({
|
|
645
|
+
clientId,
|
|
646
|
+
clientSecret,
|
|
647
|
+
robotCode,
|
|
648
|
+
downloadCode,
|
|
649
|
+
signal,
|
|
650
|
+
kind: 'file',
|
|
651
|
+
});
|
|
652
|
+
if (downloadUrl.protocol !== 'https:') {
|
|
653
|
+
throw new DingtalkApiError('invalid-file-download', '钉钉服务返回了无效的文件下载地址。');
|
|
598
654
|
}
|
|
599
655
|
try {
|
|
600
|
-
|
|
601
|
-
|
|
656
|
+
const response = await fetchImpl(downloadUrl, {
|
|
657
|
+
method: 'GET',
|
|
658
|
+
redirect: 'follow',
|
|
602
659
|
signal,
|
|
603
|
-
maxBytes,
|
|
604
660
|
});
|
|
661
|
+
if (!response?.ok) {
|
|
662
|
+
await response?.body?.cancel?.().catch?.(() => undefined);
|
|
663
|
+
throw new DingtalkApiError(
|
|
664
|
+
'file-content-download-failed',
|
|
665
|
+
`钉钉文件内容下载失败(HTTP ${response?.status ?? 'unknown'})。`,
|
|
666
|
+
{ status: response?.status },
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
return Buffer.from(await response.arrayBuffer());
|
|
605
670
|
} catch (error) {
|
|
606
|
-
if (signal?.aborted
|
|
671
|
+
if (signal?.aborted) throw abortError(signal);
|
|
672
|
+
if (error instanceof DingtalkApiError) throw error;
|
|
607
673
|
throw new DingtalkApiError(
|
|
608
|
-
'
|
|
609
|
-
'
|
|
674
|
+
'file-content-download-failed',
|
|
675
|
+
'钉钉文件内容下载失败。',
|
|
610
676
|
{ cause: error },
|
|
611
677
|
);
|
|
612
678
|
}
|
|
@@ -29,6 +29,11 @@ import {
|
|
|
29
29
|
imagePromptUserMessage,
|
|
30
30
|
promptContentForMessage,
|
|
31
31
|
} from '../shared/image-prompt.mjs';
|
|
32
|
+
import {
|
|
33
|
+
hasInboundFiles,
|
|
34
|
+
inboundFileUserMessage,
|
|
35
|
+
prefetchInboundFiles,
|
|
36
|
+
} from '../shared/inbound-file.mjs';
|
|
32
37
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
33
38
|
import {
|
|
34
39
|
materializeOutboundArtifact,
|
|
@@ -48,7 +53,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
|
|
|
48
53
|
const HELP_TEXT = [
|
|
49
54
|
'钉钉机器人已连接 DeepSeek Harness。',
|
|
50
55
|
'',
|
|
51
|
-
'
|
|
56
|
+
'直接发送文字、图片或文件即可继续当前会话。',
|
|
52
57
|
'/new 开启一个全新会话',
|
|
53
58
|
'/compact 压缩当前会话的较早上下文',
|
|
54
59
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -175,6 +180,7 @@ export function dingtalkInboundMessage(message, {
|
|
|
175
180
|
if (code) imageCodes.push(code);
|
|
176
181
|
}
|
|
177
182
|
}
|
|
183
|
+
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
|
|
178
184
|
return {
|
|
179
185
|
content: text,
|
|
180
186
|
images: imageCodes.map((downloadCode, index) => ({
|
|
@@ -193,6 +199,21 @@ export function dingtalkInboundMessage(message, {
|
|
|
193
199
|
});
|
|
194
200
|
},
|
|
195
201
|
})),
|
|
202
|
+
files: fileCode ? [{
|
|
203
|
+
name: nonEmptyString(content?.fileName ?? content?.file_name) ?? 'file',
|
|
204
|
+
load: ({ signal } = {}) => {
|
|
205
|
+
if (typeof api?.downloadFile !== 'function') {
|
|
206
|
+
throw new Error('DingTalk API does not support file downloads');
|
|
207
|
+
}
|
|
208
|
+
return api.downloadFile({
|
|
209
|
+
clientId,
|
|
210
|
+
clientSecret,
|
|
211
|
+
robotCode: message?.robotCode,
|
|
212
|
+
downloadCode: fileCode,
|
|
213
|
+
signal,
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
}] : [],
|
|
196
217
|
};
|
|
197
218
|
}
|
|
198
219
|
|
|
@@ -393,7 +414,7 @@ export class DingtalkHarnessBridge {
|
|
|
393
414
|
clientSecret: this.#clientSecret,
|
|
394
415
|
});
|
|
395
416
|
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
|
396
|
-
const commandRunner = isControlCommand(commandText)
|
|
417
|
+
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
|
|
397
418
|
? runControlCommand
|
|
398
419
|
: (isModelCommand(commandText)
|
|
399
420
|
? runModelCommand
|
|
@@ -499,10 +520,28 @@ export class DingtalkHarnessBridge {
|
|
|
499
520
|
releaseMessageId = true,
|
|
500
521
|
alreadyRecorded = false,
|
|
501
522
|
} = {}) {
|
|
523
|
+
let hasSafeReplyRoute = false;
|
|
524
|
+
try {
|
|
525
|
+
normalizeDingtalkSessionWebhook(message.sessionWebhook);
|
|
526
|
+
hasSafeReplyRoute = true;
|
|
527
|
+
} catch {
|
|
528
|
+
// Keep the existing rejection path without downloading an unusable file.
|
|
529
|
+
}
|
|
530
|
+
const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
|
|
531
|
+
const preparedMessage = hasSafeReplyRoute && addressed
|
|
532
|
+
? prefetchInboundFiles(dingtalkInboundMessage(message, {
|
|
533
|
+
api: this.#api,
|
|
534
|
+
clientId: this.#clientId,
|
|
535
|
+
clientSecret: this.#clientSecret,
|
|
536
|
+
}), { signal: this.#signal })
|
|
537
|
+
: undefined;
|
|
502
538
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
503
539
|
const current = previous
|
|
504
540
|
.catch(() => undefined)
|
|
505
|
-
.then(() => this.#process(message, messageId, sender, key, {
|
|
541
|
+
.then(() => this.#process(message, messageId, sender, key, {
|
|
542
|
+
alreadyRecorded,
|
|
543
|
+
preparedMessage,
|
|
544
|
+
}))
|
|
506
545
|
.finally(() => {
|
|
507
546
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
508
547
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
@@ -536,6 +575,7 @@ export class DingtalkHarnessBridge {
|
|
|
536
575
|
{
|
|
537
576
|
signal: this.#signal,
|
|
538
577
|
hasImages: hasInboundImages(prompt),
|
|
578
|
+
hasFiles: hasInboundFiles(prompt),
|
|
539
579
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
540
580
|
|| this.#approvals.hasPending(key),
|
|
541
581
|
control: { owner: this, key },
|
|
@@ -553,7 +593,10 @@ export class DingtalkHarnessBridge {
|
|
|
553
593
|
this.#status.lastError = null;
|
|
554
594
|
}
|
|
555
595
|
|
|
556
|
-
async #process(message, messageId, sender, key, {
|
|
596
|
+
async #process(message, messageId, sender, key, {
|
|
597
|
+
alreadyRecorded = false,
|
|
598
|
+
preparedMessage,
|
|
599
|
+
} = {}) {
|
|
557
600
|
this.#signal?.throwIfAborted();
|
|
558
601
|
if (!alreadyRecorded) {
|
|
559
602
|
if (this.#state.hasSeen(messageId)) return;
|
|
@@ -577,38 +620,39 @@ export class DingtalkHarnessBridge {
|
|
|
577
620
|
return;
|
|
578
621
|
}
|
|
579
622
|
|
|
580
|
-
const promptMessage = dingtalkInboundMessage(message, {
|
|
623
|
+
const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
|
|
581
624
|
api: this.#api,
|
|
582
625
|
clientId: this.#clientId,
|
|
583
626
|
clientSecret: this.#clientSecret,
|
|
584
627
|
});
|
|
585
628
|
const text = promptMessage.content;
|
|
586
629
|
const hasImages = hasInboundImages(promptMessage);
|
|
630
|
+
const hasFiles = hasInboundFiles(promptMessage);
|
|
587
631
|
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
|
588
632
|
let cardStream = null;
|
|
589
633
|
let cardStarted = false;
|
|
590
634
|
try {
|
|
591
|
-
if (!text && !hasImages) {
|
|
592
|
-
await this.#send(sessionWebhook, '
|
|
635
|
+
if (!text && !hasImages && !hasFiles) {
|
|
636
|
+
await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
|
|
593
637
|
return;
|
|
594
638
|
}
|
|
595
639
|
|
|
596
640
|
const command = text.toLowerCase();
|
|
597
|
-
if (isPlainText && !hasImages && command === '/help') {
|
|
641
|
+
if (isPlainText && !hasImages && !hasFiles && command === '/help') {
|
|
598
642
|
await this.#send(sessionWebhook, HELP_TEXT);
|
|
599
643
|
return;
|
|
600
644
|
}
|
|
601
|
-
if (isPlainText && !hasImages && command === '/status') {
|
|
645
|
+
if (isPlainText && !hasImages && !hasFiles && command === '/status') {
|
|
602
646
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
603
647
|
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
|
604
648
|
return;
|
|
605
649
|
}
|
|
606
|
-
if (isPlainText && !hasImages && command === '/new') {
|
|
650
|
+
if (isPlainText && !hasImages && !hasFiles && command === '/new') {
|
|
607
651
|
await this.#state.clearSession(key);
|
|
608
652
|
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
|
609
653
|
return;
|
|
610
654
|
}
|
|
611
|
-
const workspaceCommand = isPlainText && !hasImages
|
|
655
|
+
const workspaceCommand = isPlainText && !hasImages && !hasFiles
|
|
612
656
|
? await runWorkspaceCommand(text, this.#harness, key)
|
|
613
657
|
: null;
|
|
614
658
|
if (workspaceCommand) {
|
|
@@ -617,7 +661,7 @@ export class DingtalkHarnessBridge {
|
|
|
617
661
|
}
|
|
618
662
|
return;
|
|
619
663
|
}
|
|
620
|
-
const compactCommand = isPlainText && !hasImages
|
|
664
|
+
const compactCommand = isPlainText && !hasImages && !hasFiles
|
|
621
665
|
? await runCompactCommand(
|
|
622
666
|
text,
|
|
623
667
|
this.#harness,
|
|
@@ -668,6 +712,7 @@ export class DingtalkHarnessBridge {
|
|
|
668
712
|
requiresMention: String(message.conversationType) === '2',
|
|
669
713
|
}),
|
|
670
714
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
715
|
+
files: promptMessage.files,
|
|
671
716
|
},
|
|
672
717
|
});
|
|
673
718
|
const answerText = typeof answer === 'string' && answer.trim()
|
|
@@ -717,7 +762,9 @@ export class DingtalkHarnessBridge {
|
|
|
717
762
|
safeErrorDiagnostic(error),
|
|
718
763
|
);
|
|
719
764
|
try {
|
|
720
|
-
const errorText =
|
|
765
|
+
const errorText = inboundFileUserMessage(error)
|
|
766
|
+
?? dingtalkImageErrorUserMessage(error)
|
|
767
|
+
?? CARD_ERROR_TEXT;
|
|
721
768
|
const streamed = cardStarted && await cardStream.finish(errorText);
|
|
722
769
|
if (!streamed) await this.#send(sessionWebhook, errorText);
|
|
723
770
|
} catch {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
2
|
+
import { fetchFileStream } from '../shared/file-download.mjs';
|
|
2
3
|
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
3
4
|
import { DiscordApi } from './discord-api.mjs';
|
|
4
5
|
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
|
@@ -88,6 +89,25 @@ function discordImageSource(attachment, fetchImpl) {
|
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
function discordFileSource(attachment, fetchImpl) {
|
|
93
|
+
if (attachmentMediaType(attachment) || typeof attachment?.url !== 'string' || !attachment.url) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const mediaType = typeof attachment.content_type === 'string' && attachment.content_type
|
|
97
|
+
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : undefined;
|
|
98
|
+
return {
|
|
99
|
+
name: typeof attachment.filename === 'string' && attachment.filename
|
|
100
|
+
? attachment.filename : String(attachment.id ?? 'discord-file'),
|
|
101
|
+
...(mediaType ? { mediaType } : {}),
|
|
102
|
+
size: attachmentSize(attachment.size),
|
|
103
|
+
load: ({ signal } = {}) => fetchFileStream(attachment.url, {
|
|
104
|
+
fetchImpl,
|
|
105
|
+
signal,
|
|
106
|
+
allowedHosts: DISCORD_IMAGE_HOSTS,
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
|
|
92
112
|
if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
|
|
93
113
|
const direct = !message.guild_id;
|
|
@@ -103,6 +123,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|
|
103
123
|
images: Array.isArray(message.attachments)
|
|
104
124
|
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
|
|
105
125
|
: [],
|
|
126
|
+
files: Array.isArray(message.attachments)
|
|
127
|
+
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
|
|
128
|
+
: [],
|
|
106
129
|
addressed,
|
|
107
130
|
replyTarget: {
|
|
108
131
|
channelId: String(message.channel_id),
|