@insta-dev01/intclaw 1.0.11 → 1.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.
- package/LICENSE +1 -1
- package/README.en.md +424 -0
- package/README.md +365 -164
- package/index.ts +28 -0
- package/openclaw.plugin.json +12 -41
- package/package.json +69 -40
- package/src/channel.ts +557 -0
- package/src/config/accounts.ts +230 -0
- package/src/config/schema.ts +144 -0
- package/src/core/connection.ts +733 -0
- package/src/core/message-handler.ts +1268 -0
- package/src/core/provider.ts +106 -0
- package/src/core/state.ts +54 -0
- package/src/directory.ts +95 -0
- package/src/gateway-methods.ts +237 -0
- package/src/onboarding.ts +387 -0
- package/src/policy.ts +19 -0
- package/src/probe.ts +213 -0
- package/src/reply-dispatcher.ts +674 -0
- package/src/runtime.ts +7 -0
- package/src/sdk/helpers.ts +317 -0
- package/src/sdk/types.ts +515 -0
- package/src/secret-input.ts +19 -0
- package/src/services/media/audio.ts +54 -0
- package/src/services/media/chunk-upload.ts +293 -0
- package/src/services/media/common.ts +154 -0
- package/src/services/media/file.ts +70 -0
- package/src/services/media/image.ts +67 -0
- package/src/services/media/index.ts +10 -0
- package/src/services/media/video.ts +162 -0
- package/src/services/media.ts +1134 -0
- package/src/services/messaging/index.ts +16 -0
- package/src/services/messaging/send.ts +137 -0
- package/src/services/messaging.ts +800 -0
- package/src/targets.ts +45 -0
- package/src/types/index.ts +52 -0
- package/src/utils/agent.ts +63 -0
- package/src/utils/async.ts +51 -0
- package/src/utils/constants.ts +9 -0
- package/src/utils/http-client.ts +84 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.ts +78 -0
- package/src/utils/session.ts +118 -0
- package/src/utils/token.ts +94 -0
- package/src/utils/utils-legacy.ts +506 -0
- package/.env.example +0 -11
- package/skills/intclaw_matrix/SKILL.md +0 -20
- package/src/channel/intclaw_channel.js +0 -155
- package/src/index.js +0 -23
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IntClaw消息发送模块
|
|
3
|
+
* 支持 AI Card 流式响应、普通消息、主动消息
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { IntclawConfig } from "../types/index.ts";
|
|
7
|
+
import { INTCLAW_API, getAccessToken, getOapiAccessToken } from "../utils/index.ts";
|
|
8
|
+
import { intclawHttp, intclawOapiHttp } from "../utils/http-client.ts";
|
|
9
|
+
import { createLoggerFromConfig } from "../utils/logger.ts";
|
|
10
|
+
import {
|
|
11
|
+
processLocalImages,
|
|
12
|
+
processVideoMarkers,
|
|
13
|
+
processAudioMarkers,
|
|
14
|
+
processFileMarkers,
|
|
15
|
+
uploadMediaToIntClaw,
|
|
16
|
+
} from "./media.ts";
|
|
17
|
+
|
|
18
|
+
// ============ 常量 ============
|
|
19
|
+
// 注意:AI Card 相关的类型和函数已移至 ./messaging/card.ts,通过上方 import 引入
|
|
20
|
+
|
|
21
|
+
/** 消息类型枚举 */
|
|
22
|
+
export type IntClawMsgType =
|
|
23
|
+
| "text"
|
|
24
|
+
| "markdown"
|
|
25
|
+
| "link"
|
|
26
|
+
| "actionCard"
|
|
27
|
+
| "image";
|
|
28
|
+
|
|
29
|
+
/** 主动发送消息的结果 */
|
|
30
|
+
export interface SendResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
processQueryKey?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 主动发送选项 */
|
|
37
|
+
export interface ProactiveSendOptions {
|
|
38
|
+
msgType?: IntClawMsgType;
|
|
39
|
+
replyToId?: string;
|
|
40
|
+
title?: string;
|
|
41
|
+
log?: any;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============ 普通消息发送 ============
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 发送 Markdown 消息
|
|
48
|
+
*/
|
|
49
|
+
export async function sendMarkdownMessage(
|
|
50
|
+
config: IntclawConfig,
|
|
51
|
+
sessionWebhook: string,
|
|
52
|
+
title: string,
|
|
53
|
+
markdown: string,
|
|
54
|
+
options: any = {},
|
|
55
|
+
): Promise<any> {
|
|
56
|
+
const token = await getAccessToken(config);
|
|
57
|
+
let text = markdown;
|
|
58
|
+
if (options.atUserId) text = `${text} @${options.atUserId}`;
|
|
59
|
+
|
|
60
|
+
const body: any = {
|
|
61
|
+
msgtype: "markdown",
|
|
62
|
+
markdown: { title: title || "Message", text },
|
|
63
|
+
};
|
|
64
|
+
if (options.atUserId)
|
|
65
|
+
body.at = { atUserIds: [options.atUserId], isAtAll: false };
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
await intclawHttp.post(sessionWebhook, body, {
|
|
69
|
+
headers: {
|
|
70
|
+
"x-acs-intclaw-access-token": token,
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
).data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 发送文本消息
|
|
79
|
+
*/
|
|
80
|
+
export async function sendTextMessage(
|
|
81
|
+
config: IntclawConfig,
|
|
82
|
+
sessionWebhook: string,
|
|
83
|
+
text: string,
|
|
84
|
+
options: any = {},
|
|
85
|
+
): Promise<any> {
|
|
86
|
+
const token = await getAccessToken(config);
|
|
87
|
+
const body: any = { msgtype: "text", text: { content: text } };
|
|
88
|
+
if (options.atUserId)
|
|
89
|
+
body.at = { atUserIds: [options.atUserId], isAtAll: false };
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
await intclawHttp.post(sessionWebhook, body, {
|
|
93
|
+
headers: {
|
|
94
|
+
"x-acs-intclaw-access-token": token,
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
).data;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 智能选择 text / markdown
|
|
103
|
+
*/
|
|
104
|
+
export async function sendMessage(
|
|
105
|
+
config: IntclawConfig,
|
|
106
|
+
sessionWebhook: string,
|
|
107
|
+
text: string,
|
|
108
|
+
options: any = {},
|
|
109
|
+
): Promise<any> {
|
|
110
|
+
const hasMarkdown =
|
|
111
|
+
/^[#*>-]|[*_`#\[\]]/.test(text) ||
|
|
112
|
+
(text && typeof text === "string" && text.includes("\n"));
|
|
113
|
+
const useMarkdown =
|
|
114
|
+
options.useMarkdown !== false && (options.useMarkdown || hasMarkdown);
|
|
115
|
+
|
|
116
|
+
if (useMarkdown) {
|
|
117
|
+
const title =
|
|
118
|
+
options.title ||
|
|
119
|
+
text
|
|
120
|
+
.split("\n")[0]
|
|
121
|
+
.replace(/^[#*\s\->]+/, "")
|
|
122
|
+
.slice(0, 20) ||
|
|
123
|
+
"Message";
|
|
124
|
+
return sendMarkdownMessage(config, sessionWebhook, title, text, options);
|
|
125
|
+
}
|
|
126
|
+
return sendTextMessage(config, sessionWebhook, text, options);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============ 主动发送消息 ============
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 构建普通消息的 msgKey 和 msgParam
|
|
133
|
+
*/
|
|
134
|
+
export function buildMsgPayload(
|
|
135
|
+
msgType: IntClawMsgType,
|
|
136
|
+
content: string,
|
|
137
|
+
title?: string,
|
|
138
|
+
): { msgKey: string; msgParam: Record<string, any> } | { error: string } {
|
|
139
|
+
switch (msgType) {
|
|
140
|
+
case "markdown":
|
|
141
|
+
return {
|
|
142
|
+
msgKey: "sampleMarkdown",
|
|
143
|
+
msgParam: {
|
|
144
|
+
title:
|
|
145
|
+
title ||
|
|
146
|
+
content
|
|
147
|
+
.split("\n")[0]
|
|
148
|
+
.replace(/^[#*\s\->]+/, "")
|
|
149
|
+
.slice(0, 20) ||
|
|
150
|
+
"Message",
|
|
151
|
+
text: content,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
case "link":
|
|
155
|
+
try {
|
|
156
|
+
return {
|
|
157
|
+
msgKey: "sampleLink",
|
|
158
|
+
msgParam: typeof content === "string" ? JSON.parse(content) : content,
|
|
159
|
+
};
|
|
160
|
+
} catch {
|
|
161
|
+
return { error: "Invalid link message format, expected JSON" };
|
|
162
|
+
}
|
|
163
|
+
case "actionCard":
|
|
164
|
+
try {
|
|
165
|
+
return {
|
|
166
|
+
msgKey: "sampleActionCard",
|
|
167
|
+
msgParam: typeof content === "string" ? JSON.parse(content) : content,
|
|
168
|
+
};
|
|
169
|
+
} catch {
|
|
170
|
+
return { error: "Invalid actionCard message format, expected JSON" };
|
|
171
|
+
}
|
|
172
|
+
case "image":
|
|
173
|
+
return {
|
|
174
|
+
msgKey: "sampleImageMsg",
|
|
175
|
+
msgParam: { photoURL: content },
|
|
176
|
+
};
|
|
177
|
+
case "text":
|
|
178
|
+
default:
|
|
179
|
+
return {
|
|
180
|
+
msgKey: "sampleText",
|
|
181
|
+
msgParam: { content },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 使用普通消息 API 发送单聊消息(降级方案)
|
|
188
|
+
*/
|
|
189
|
+
export async function sendNormalToUser(
|
|
190
|
+
config: IntclawConfig,
|
|
191
|
+
userIds: string | string[],
|
|
192
|
+
content: string,
|
|
193
|
+
options: ProactiveSendOptions = {},
|
|
194
|
+
): Promise<SendResult> {
|
|
195
|
+
const { msgType = "text", title, log } = options;
|
|
196
|
+
const userIdArray = Array.isArray(userIds) ? userIds : [userIds];
|
|
197
|
+
|
|
198
|
+
const payload = buildMsgPayload(msgType, content, title);
|
|
199
|
+
if ("error" in payload) {
|
|
200
|
+
return { ok: false, error: payload.error };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const token = await getAccessToken(config);
|
|
205
|
+
const body = {
|
|
206
|
+
robotCode: config.clientId,
|
|
207
|
+
userIds: userIdArray,
|
|
208
|
+
msgKey: payload.msgKey,
|
|
209
|
+
msgParam: JSON.stringify(payload.msgParam),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
log?.info?.(
|
|
213
|
+
`发送单聊消息: userIds=${userIdArray.join(",")}, msgType=${msgType}`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const resp = await intclawHttp.post(
|
|
217
|
+
`${INTCLAW_API}/v1.0/robot/oToMessages/batchSend`,
|
|
218
|
+
body,
|
|
219
|
+
{
|
|
220
|
+
headers: {
|
|
221
|
+
"x-acs-intclaw-access-token": token,
|
|
222
|
+
"Content-Type": "application/json",
|
|
223
|
+
},
|
|
224
|
+
timeout: 10_000,
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (resp.data?.processQueryKey) {
|
|
229
|
+
log?.info?.(
|
|
230
|
+
`发送成功: processQueryKey=${resp.data.processQueryKey}`,
|
|
231
|
+
);
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
processQueryKey: resp.data.processQueryKey,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
log?.warn?.(
|
|
239
|
+
`发送响应异常: ${JSON.stringify(resp.data)}`,
|
|
240
|
+
);
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
error: resp.data?.message || "Unknown error",
|
|
244
|
+
};
|
|
245
|
+
} catch (err: any) {
|
|
246
|
+
const errMsg = err.response?.data?.message || err.message;
|
|
247
|
+
log?.error?.(`发送失败: ${errMsg}`);
|
|
248
|
+
return { ok: false, error: errMsg };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 使用普通消息 API 发送群聊消息(降级方案)
|
|
254
|
+
*/
|
|
255
|
+
export async function sendNormalToGroup(
|
|
256
|
+
config: IntclawConfig,
|
|
257
|
+
openConversationId: string,
|
|
258
|
+
content: string,
|
|
259
|
+
options: ProactiveSendOptions = {},
|
|
260
|
+
): Promise<SendResult> {
|
|
261
|
+
const { msgType = "text", title, log } = options;
|
|
262
|
+
|
|
263
|
+
const payload = buildMsgPayload(msgType, content, title);
|
|
264
|
+
if ("error" in payload) {
|
|
265
|
+
return { ok: false, error: payload.error };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const token = await getAccessToken(config);
|
|
270
|
+
const body = {
|
|
271
|
+
robotCode: config.clientId,
|
|
272
|
+
openConversationId,
|
|
273
|
+
msgKey: payload.msgKey,
|
|
274
|
+
msgParam: JSON.stringify(payload.msgParam),
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
log?.info?.(
|
|
278
|
+
`发送群聊消息: openConversationId=${openConversationId}, msgType=${msgType}`,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const resp = await intclawHttp.post(
|
|
282
|
+
`${INTCLAW_API}/v1.0/robot/groupMessages/send`,
|
|
283
|
+
body,
|
|
284
|
+
{
|
|
285
|
+
headers: {
|
|
286
|
+
"x-acs-intclaw-access-token": token,
|
|
287
|
+
"Content-Type": "application/json",
|
|
288
|
+
},
|
|
289
|
+
timeout: 10_000,
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
if (resp.data?.processQueryKey) {
|
|
294
|
+
log?.info?.(
|
|
295
|
+
`发送成功: processQueryKey=${resp.data.processQueryKey}`,
|
|
296
|
+
);
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
processQueryKey: resp.data.processQueryKey,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
log?.warn?.(
|
|
304
|
+
`发送响应异常: ${JSON.stringify(resp.data)}`,
|
|
305
|
+
);
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
error: resp.data?.message || "Unknown error",
|
|
309
|
+
};
|
|
310
|
+
} catch (err: any) {
|
|
311
|
+
const errMsg = err.response?.data?.message || err.message;
|
|
312
|
+
log?.error?.(`发送失败: ${errMsg}`);
|
|
313
|
+
return { ok: false, error: errMsg };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 主动发送文本消息到IntClaw
|
|
320
|
+
*/
|
|
321
|
+
export async function sendToUser(
|
|
322
|
+
config: IntclawConfig,
|
|
323
|
+
userId: string | string[],
|
|
324
|
+
text: string,
|
|
325
|
+
options?: ProactiveSendOptions,
|
|
326
|
+
): Promise<SendResult> {
|
|
327
|
+
if (!config?.clientId || !config?.clientSecret) {
|
|
328
|
+
return { ok: false, error: "Missing clientId or clientSecret" };
|
|
329
|
+
}
|
|
330
|
+
if (!userId || (Array.isArray(userId) && userId.length === 0)) {
|
|
331
|
+
return { ok: false, error: "userId is empty" };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 多用户:使用普通消息 API(不走 AI Card)
|
|
335
|
+
if (Array.isArray(userId)) {
|
|
336
|
+
return sendNormalToUser(config, userId, text, options || {});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return sendProactive(config, { userId }, text, options || {});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 主动发送文本消息到IntClaw群
|
|
344
|
+
*/
|
|
345
|
+
export async function sendToGroup(
|
|
346
|
+
config: IntclawConfig,
|
|
347
|
+
openConversationId: string,
|
|
348
|
+
text: string,
|
|
349
|
+
options?: ProactiveSendOptions,
|
|
350
|
+
): Promise<SendResult> {
|
|
351
|
+
if (!config?.clientId || !config?.clientSecret) {
|
|
352
|
+
return { ok: false, error: "Missing clientId or clientSecret" };
|
|
353
|
+
}
|
|
354
|
+
if (!openConversationId || typeof openConversationId !== "string") {
|
|
355
|
+
return { ok: false, error: "openConversationId is empty" };
|
|
356
|
+
}
|
|
357
|
+
return sendProactive(config, { openConversationId }, text, options || {});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 发送文本消息(用于 outbound 接口)
|
|
362
|
+
*/
|
|
363
|
+
export async function sendTextToIntClaw(params: {
|
|
364
|
+
config: IntclawConfig;
|
|
365
|
+
target: string;
|
|
366
|
+
text: string;
|
|
367
|
+
replyToId?: string;
|
|
368
|
+
}): Promise<SendResult> {
|
|
369
|
+
const { config, target, text, replyToId } = params;
|
|
370
|
+
|
|
371
|
+
const log = createLoggerFromConfig(config, 'sendTextToIntClaw');
|
|
372
|
+
|
|
373
|
+
// 参数校验
|
|
374
|
+
if (!target || typeof target !== "string") {
|
|
375
|
+
log.error("target 参数无效:", target);
|
|
376
|
+
return { ok: false, error: "Invalid target parameter" };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 判断目标是用户还是群
|
|
380
|
+
const isUser = !target.startsWith("cid");
|
|
381
|
+
const targetParam = isUser
|
|
382
|
+
? { type: "user" as const, userId: target }
|
|
383
|
+
: { type: "group" as const, openConversationId: target };
|
|
384
|
+
|
|
385
|
+
return sendProactive(config, targetParam, text, {
|
|
386
|
+
msgType: "text",
|
|
387
|
+
replyToId,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 发送媒体消息(用于 outbound 接口)
|
|
393
|
+
*/
|
|
394
|
+
export async function sendMediaToIntClaw(params: {
|
|
395
|
+
config: IntclawConfig;
|
|
396
|
+
target: string;
|
|
397
|
+
text?: string;
|
|
398
|
+
mediaUrl: string;
|
|
399
|
+
replyToId?: string;
|
|
400
|
+
}): Promise<SendResult> {
|
|
401
|
+
const log = createLoggerFromConfig(params.config, 'sendMediaToIntClaw');
|
|
402
|
+
|
|
403
|
+
log.info(
|
|
404
|
+
"开始处理,params:",
|
|
405
|
+
JSON.stringify({
|
|
406
|
+
target: params.target,
|
|
407
|
+
text: params.text,
|
|
408
|
+
mediaUrl: params.mediaUrl,
|
|
409
|
+
replyToId: params.replyToId,
|
|
410
|
+
hasConfig: !!params.config,
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const { config, target, text, mediaUrl, replyToId } = params;
|
|
415
|
+
|
|
416
|
+
// 参数校验
|
|
417
|
+
if (!target || typeof target !== "string") {
|
|
418
|
+
log.error("target 参数无效:", target);
|
|
419
|
+
return { ok: false, error: "Invalid target parameter" };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 判断目标是用户还是群
|
|
423
|
+
const isUser = !target.startsWith("cid");
|
|
424
|
+
const targetParam = isUser
|
|
425
|
+
? { type: "user" as const, userId: target }
|
|
426
|
+
: { type: "group" as const, openConversationId: target };
|
|
427
|
+
|
|
428
|
+
log.info("参数解析完成,mediaUrl:", mediaUrl, "type:", typeof mediaUrl);
|
|
429
|
+
|
|
430
|
+
// 参数校验
|
|
431
|
+
if (!mediaUrl) {
|
|
432
|
+
log.info("mediaUrl 为空,返回错误提示");
|
|
433
|
+
return sendProactive(config, targetParam, text ?? "⚠️ 缺少媒体文件 URL", {
|
|
434
|
+
msgType: "text",
|
|
435
|
+
replyToId,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 1. 先发送文本消息(如果有且不为空)
|
|
440
|
+
// 注意:只有在 text 有实际内容时才发送,避免发送空消息
|
|
441
|
+
if (text && text.trim().length > 0) {
|
|
442
|
+
log.info("先发送文本消息:", text);
|
|
443
|
+
await sendProactive(config, targetParam, text, {
|
|
444
|
+
msgType: "text",
|
|
445
|
+
replyToId,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 2. 上传媒体文件并发送媒体消息
|
|
450
|
+
try {
|
|
451
|
+
log.info("开始获取 oapiToken");
|
|
452
|
+
const oapiToken = await getOapiAccessToken(config);
|
|
453
|
+
log.info("oapiToken 获取成功");
|
|
454
|
+
|
|
455
|
+
// 根据文件扩展名判断媒体类型
|
|
456
|
+
log.info("开始解析文件扩展名,mediaUrl:", mediaUrl);
|
|
457
|
+
const ext = mediaUrl.toLowerCase().split(".").pop() || "";
|
|
458
|
+
log.info("文件扩展名:", ext);
|
|
459
|
+
let mediaType: "image" | "file" | "video" | "voice" = "file";
|
|
460
|
+
|
|
461
|
+
if (["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(ext)) {
|
|
462
|
+
mediaType = "image";
|
|
463
|
+
} else if (
|
|
464
|
+
["mp4", "avi", "mov", "mkv", "flv", "wmv", "webm"].includes(ext)
|
|
465
|
+
) {
|
|
466
|
+
mediaType = "video";
|
|
467
|
+
} else if (
|
|
468
|
+
["mp3", "wav", "aac", "ogg", "m4a", "flac", "wma", "amr"].includes(ext)
|
|
469
|
+
) {
|
|
470
|
+
mediaType = "voice";
|
|
471
|
+
}
|
|
472
|
+
log.info("媒体类型判断完成:", mediaType);
|
|
473
|
+
|
|
474
|
+
// 上传文件到IntClaw
|
|
475
|
+
// 根据媒体类型设置不同的大小限制(IntClaw OAPI 官方限制)
|
|
476
|
+
let maxSize: number;
|
|
477
|
+
switch (mediaType) {
|
|
478
|
+
case "image":
|
|
479
|
+
maxSize = 10 * 1024 * 1024; // 图片最大 10MB
|
|
480
|
+
break;
|
|
481
|
+
case "voice":
|
|
482
|
+
maxSize = 2 * 1024 * 1024; // 语音最大 2MB
|
|
483
|
+
break;
|
|
484
|
+
case "video":
|
|
485
|
+
case "file":
|
|
486
|
+
maxSize = 20 * 1024 * 1024; // 视频和文件最大 20MB
|
|
487
|
+
break;
|
|
488
|
+
default:
|
|
489
|
+
maxSize = 20 * 1024 * 1024; // 默认 20MB
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
log.info("准备调用 uploadMediaToIntClaw,参数:", { mediaUrl, mediaType, maxSizeMB: (maxSize / (1024 * 1024)).toFixed(0) });
|
|
493
|
+
if (!oapiToken) {
|
|
494
|
+
log.error("oapiToken 为空,无法上传媒体文件");
|
|
495
|
+
return sendProactive(
|
|
496
|
+
config,
|
|
497
|
+
targetParam,
|
|
498
|
+
"⚠️ 媒体文件处理失败:缺少 oapiToken",
|
|
499
|
+
{ msgType: "text", replyToId },
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const uploadResult = await uploadMediaToIntClaw(
|
|
503
|
+
mediaUrl,
|
|
504
|
+
mediaType,
|
|
505
|
+
oapiToken,
|
|
506
|
+
maxSize,
|
|
507
|
+
log,
|
|
508
|
+
);
|
|
509
|
+
log.info("uploadMediaToIntClaw 返回结果:", uploadResult);
|
|
510
|
+
|
|
511
|
+
if (!uploadResult) {
|
|
512
|
+
// 上传失败,发送文本消息提示
|
|
513
|
+
log.error("上传失败,返回错误提示");
|
|
514
|
+
return sendProactive(config, targetParam, "⚠️ 媒体文件上传失败", {
|
|
515
|
+
msgType: "text",
|
|
516
|
+
replyToId,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// uploadResult 现在是对象,包含 mediaId、cleanMediaId、downloadUrl
|
|
521
|
+
log.info("提取 media_id:", uploadResult.mediaId);
|
|
522
|
+
|
|
523
|
+
// 3. 根据媒体类型发送对应的消息
|
|
524
|
+
const fileName = mediaUrl.split("/").pop() || "file";
|
|
525
|
+
|
|
526
|
+
if (mediaType === "image") {
|
|
527
|
+
// 图片消息 - 发送真正的图片消息,使用原始 mediaId(带 @)
|
|
528
|
+
const result = await sendProactive(config, targetParam, uploadResult.mediaId, {
|
|
529
|
+
msgType: "image",
|
|
530
|
+
replyToId,
|
|
531
|
+
});
|
|
532
|
+
return {
|
|
533
|
+
...result,
|
|
534
|
+
processQueryKey: result.processQueryKey || "image-message-sent",
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 对于视频,使用视频标记机制
|
|
539
|
+
if (mediaType === "video") {
|
|
540
|
+
// 构建视频标记
|
|
541
|
+
const videoMarker = `[INTCLAW_VIDEO]{"path":"${mediaUrl}"}[/INTCLAW_VIDEO]`;
|
|
542
|
+
|
|
543
|
+
// 直接处理视频标记(上传并发送视频消息)
|
|
544
|
+
const { processVideoMarkers } = await import("./media.js");
|
|
545
|
+
await processVideoMarkers(
|
|
546
|
+
videoMarker, // 只传入标记,不包含原始文本
|
|
547
|
+
"",
|
|
548
|
+
config,
|
|
549
|
+
oapiToken,
|
|
550
|
+
console,
|
|
551
|
+
true, // useProactiveApi
|
|
552
|
+
targetParam,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
// 如果有原始文本,单独发送
|
|
556
|
+
if (text?.trim()) {
|
|
557
|
+
const result = await sendProactive(config, targetParam, text, {
|
|
558
|
+
msgType: "text",
|
|
559
|
+
replyToId,
|
|
560
|
+
});
|
|
561
|
+
return {
|
|
562
|
+
...result,
|
|
563
|
+
processQueryKey: result.processQueryKey || "video-text-sent",
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 视频已发送,返回成功
|
|
568
|
+
return {
|
|
569
|
+
ok: true,
|
|
570
|
+
processQueryKey: "video-message-sent",
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 对于音频、文件,发送真正的文件消息
|
|
575
|
+
const fs = await import("fs");
|
|
576
|
+
const stats = fs.statSync(mediaUrl);
|
|
577
|
+
|
|
578
|
+
// 获取文件扩展名作为 fileType
|
|
579
|
+
const fileType = ext || "file";
|
|
580
|
+
|
|
581
|
+
// 构建文件信息
|
|
582
|
+
const fileInfo = {
|
|
583
|
+
fileName: fileName,
|
|
584
|
+
fileType: fileType,
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// 使用 sendFileProactive 发送文件消息
|
|
588
|
+
const { sendFileProactive } = await import("./media.ts");
|
|
589
|
+
await sendFileProactive(config, targetParam, fileInfo, uploadResult.mediaId, log);
|
|
590
|
+
|
|
591
|
+
// 返回成功结果
|
|
592
|
+
return {
|
|
593
|
+
ok: true,
|
|
594
|
+
processQueryKey: "file-message-sent",
|
|
595
|
+
};
|
|
596
|
+
} catch (err: any) {
|
|
597
|
+
log.error("发送媒体消息失败:", err.message);
|
|
598
|
+
// 发生错误,发送文本消息提示
|
|
599
|
+
return sendProactive(
|
|
600
|
+
config,
|
|
601
|
+
targetParam,
|
|
602
|
+
`⚠️ 媒体文件处理失败: ${err.message}`,
|
|
603
|
+
{ msgType: "text", replyToId },
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* 智能发送消息
|
|
610
|
+
*/
|
|
611
|
+
export async function sendProactive(
|
|
612
|
+
config: IntclawConfig,
|
|
613
|
+
target: { userId?: string; userIds?: string[]; openConversationId?: string },
|
|
614
|
+
content: string,
|
|
615
|
+
options: ProactiveSendOptions = {},
|
|
616
|
+
): Promise<SendResult> {
|
|
617
|
+
const log = createLoggerFromConfig(config, 'sendProactive');
|
|
618
|
+
|
|
619
|
+
log.info(
|
|
620
|
+
"开始处理,参数:",
|
|
621
|
+
JSON.stringify({
|
|
622
|
+
target,
|
|
623
|
+
contentLength: content?.length,
|
|
624
|
+
hasOptions: !!options,
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
if (!options.msgType) {
|
|
629
|
+
const hasMarkdown =
|
|
630
|
+
/^[#*>-]|[*_`#\[\]]/.test(content) ||
|
|
631
|
+
(content && typeof content === "string" && content.includes("\n"));
|
|
632
|
+
if (hasMarkdown) {
|
|
633
|
+
options.msgType = "markdown";
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// 直接实现发送逻辑,不要递归调用 sendToUser/sendToGroup
|
|
638
|
+
if (target.userId || target.userIds) {
|
|
639
|
+
const userIds = target.userIds || [target.userId!];
|
|
640
|
+
const userId = userIds[0];
|
|
641
|
+
log.info("发送给用户,userId:", userId);
|
|
642
|
+
|
|
643
|
+
// 构建发送参数
|
|
644
|
+
return sendProactiveInternal(
|
|
645
|
+
config,
|
|
646
|
+
{ type: "user", userId },
|
|
647
|
+
content,
|
|
648
|
+
options,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (target.openConversationId) {
|
|
653
|
+
log.info(
|
|
654
|
+
"发送给群聊,openConversationId:",
|
|
655
|
+
target.openConversationId,
|
|
656
|
+
);
|
|
657
|
+
return sendProactiveInternal(
|
|
658
|
+
config,
|
|
659
|
+
{ type: "group", openConversationId: target.openConversationId },
|
|
660
|
+
content,
|
|
661
|
+
options,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
log.error("target 参数缺少必要字段:", target);
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
error: "Must specify userId, userIds, or openConversationId",
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* 内部发送实现
|
|
674
|
+
*/
|
|
675
|
+
async function sendProactiveInternal(
|
|
676
|
+
config: IntclawConfig,
|
|
677
|
+
target: { type: "user"; userId: string } | { type: "group"; openConversationId: string },
|
|
678
|
+
content: string,
|
|
679
|
+
options: ProactiveSendOptions,
|
|
680
|
+
): Promise<SendResult> {
|
|
681
|
+
const log = createLoggerFromConfig(config, 'sendProactiveInternal');
|
|
682
|
+
|
|
683
|
+
log.info(
|
|
684
|
+
"开始处理,参数:",
|
|
685
|
+
JSON.stringify({
|
|
686
|
+
target,
|
|
687
|
+
contentLength: content?.length,
|
|
688
|
+
msgType: options.msgType,
|
|
689
|
+
targetType: target?.type,
|
|
690
|
+
hasTarget: !!target,
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// 参数校验
|
|
695
|
+
if (!target || typeof target !== "object") {
|
|
696
|
+
log.error("target 参数无效:", target);
|
|
697
|
+
return { ok: false, error: "Invalid target parameter" };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const {
|
|
701
|
+
msgType = "text",
|
|
702
|
+
log: externalLog,
|
|
703
|
+
} = options;
|
|
704
|
+
|
|
705
|
+
// 发送普通消息
|
|
706
|
+
try {
|
|
707
|
+
log.info(
|
|
708
|
+
"准备发送普通消息,target.type:",
|
|
709
|
+
target.type,
|
|
710
|
+
);
|
|
711
|
+
const token = await getAccessToken(config);
|
|
712
|
+
const isUser = target.type === "user";
|
|
713
|
+
log.info(
|
|
714
|
+
"isUser:",
|
|
715
|
+
isUser,
|
|
716
|
+
"target:",
|
|
717
|
+
JSON.stringify(target),
|
|
718
|
+
);
|
|
719
|
+
const targetId = isUser ? target.userId : target.openConversationId;
|
|
720
|
+
log.info("targetId:", targetId);
|
|
721
|
+
|
|
722
|
+
// ✅ 根据目标类型选择不同的 API
|
|
723
|
+
const webhookUrl = isUser
|
|
724
|
+
? `${INTCLAW_API}/v1.0/robot/oToMessages/batchSend`
|
|
725
|
+
: `${INTCLAW_API}/v1.0/robot/groupMessages/send`;
|
|
726
|
+
|
|
727
|
+
// 使用 buildMsgPayload 构建消息体(支持所有消息类型)
|
|
728
|
+
const payload = buildMsgPayload(msgType, content, options.title);
|
|
729
|
+
if ("error" in payload) {
|
|
730
|
+
log.error("构建消息失败:", payload.error);
|
|
731
|
+
return { ok: false, error: payload.error };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const body: any = {
|
|
735
|
+
robotCode: config.clientId,
|
|
736
|
+
msgKey: payload.msgKey,
|
|
737
|
+
msgParam: JSON.stringify(payload.msgParam),
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// ✅ 根据目标类型设置不同的参数
|
|
741
|
+
if (isUser) {
|
|
742
|
+
body.userIds = [targetId];
|
|
743
|
+
} else {
|
|
744
|
+
body.openConversationId = targetId;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
externalLog?.info?.(
|
|
748
|
+
`发送${isUser ? '单聊' : '群聊'}消息:${isUser ? 'userIds=' : 'openConversationId='}${targetId}`,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const resp = await intclawHttp.post(webhookUrl, body, {
|
|
752
|
+
headers: {
|
|
753
|
+
"x-acs-intclaw-access-token": token,
|
|
754
|
+
"Content-Type": "application/json",
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// 重要:IntClaw接口有时会出现 HTTP 200 但业务失败的情况,需要打印返回体辅助排查
|
|
759
|
+
try {
|
|
760
|
+
const dataPreview = JSON.stringify(resp.data ?? {});
|
|
761
|
+
const truncated =
|
|
762
|
+
dataPreview.length > 2000 ? `${dataPreview.slice(0, 2000)}...(truncated)` : dataPreview;
|
|
763
|
+
const msg = `发送${isUser ? "单聊" : "群聊"}消息响应:status=${resp.status}, processQueryKey=${resp.data?.processQueryKey ?? ""}, data=${truncated}`;
|
|
764
|
+
log.info(msg);
|
|
765
|
+
externalLog?.info?.(msg);
|
|
766
|
+
} catch {
|
|
767
|
+
const msg = `发送${isUser ? "单聊" : "群聊"}消息响应:status=${resp.status}, processQueryKey=${resp.data?.processQueryKey ?? ""}`;
|
|
768
|
+
log.info(msg);
|
|
769
|
+
externalLog?.info?.(msg);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
ok: true,
|
|
774
|
+
processQueryKey: resp.data?.processQueryKey,
|
|
775
|
+
};
|
|
776
|
+
} catch (err: any) {
|
|
777
|
+
const status = err?.response?.status;
|
|
778
|
+
const respData = err?.response?.data;
|
|
779
|
+
let respPreview = "";
|
|
780
|
+
try {
|
|
781
|
+
const raw = JSON.stringify(respData ?? {});
|
|
782
|
+
respPreview = raw.length > 2000 ? `${raw.slice(0, 2000)}...(truncated)` : raw;
|
|
783
|
+
} catch {
|
|
784
|
+
respPreview = String(respData ?? "");
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const baseMsg = err?.message ? String(err.message) : String(err);
|
|
788
|
+
const extra =
|
|
789
|
+
typeof status === "number"
|
|
790
|
+
? ` status=${status}${respPreview ? `, data=${respPreview}` : ""}`
|
|
791
|
+
: respPreview
|
|
792
|
+
? ` data=${respPreview}`
|
|
793
|
+
: "";
|
|
794
|
+
|
|
795
|
+
const msg = `发送${target.type === "user" ? "单聊" : "群聊"}消息失败:${baseMsg}${extra}`;
|
|
796
|
+
log.error(msg);
|
|
797
|
+
externalLog?.error?.(msg);
|
|
798
|
+
return { ok: false, error: baseMsg };
|
|
799
|
+
}
|
|
800
|
+
}
|