@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,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IntClaw插件工具函数
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { IntclawConfig, ResolvedIntclawAccount } from '../types/index.ts';
|
|
6
|
+
import { INTCLAW_CONFIG } from '../../config.ts';
|
|
7
|
+
|
|
8
|
+
// ============ 常量 ============
|
|
9
|
+
|
|
10
|
+
/** 默认账号 ID,用于标记单账号模式(无 accounts 配置)时的内部标识 */
|
|
11
|
+
export const DEFAULT_ACCOUNT_ID = '__default__';
|
|
12
|
+
|
|
13
|
+
/** 新会话触发命令 */
|
|
14
|
+
export const NEW_SESSION_COMMANDS = ['/new', '/reset', '/clear', '新会话', '重新开始', '清空对话'];
|
|
15
|
+
|
|
16
|
+
/** IntClaw API 常量 */
|
|
17
|
+
export const INTCLAW_API = INTCLAW_CONFIG.API_BASE_URL;
|
|
18
|
+
export const INTCLAW_OAPI = INTCLAW_CONFIG.OAPI_BASE_URL;
|
|
19
|
+
|
|
20
|
+
// ============ 会话管理 ============
|
|
21
|
+
|
|
22
|
+
/** OpenClaw 标准会话上下文 */
|
|
23
|
+
export interface SessionContext {
|
|
24
|
+
channel: 'intclaw-connector';
|
|
25
|
+
accountId: string;
|
|
26
|
+
chatType: 'direct' | 'group';
|
|
27
|
+
peerId: string;
|
|
28
|
+
conversationId?: string;
|
|
29
|
+
senderName?: string;
|
|
30
|
+
groupSubject?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 构建 OpenClaw 标准会话上下文
|
|
35
|
+
* 遵循 OpenClaw session.dmScope 机制,让 Gateway 根据配置自动处理会话隔离
|
|
36
|
+
*/
|
|
37
|
+
export function buildSessionContext(params: {
|
|
38
|
+
accountId: string;
|
|
39
|
+
senderId: string;
|
|
40
|
+
senderName?: string;
|
|
41
|
+
conversationType: string;
|
|
42
|
+
conversationId?: string;
|
|
43
|
+
groupSubject?: string;
|
|
44
|
+
separateSessionByConversation?: boolean;
|
|
45
|
+
groupSessionScope?: 'group' | 'group_sender';
|
|
46
|
+
sharedMemoryAcrossConversations?: boolean;
|
|
47
|
+
}): SessionContext {
|
|
48
|
+
const {
|
|
49
|
+
accountId,
|
|
50
|
+
senderId,
|
|
51
|
+
senderName,
|
|
52
|
+
conversationType,
|
|
53
|
+
conversationId,
|
|
54
|
+
groupSubject,
|
|
55
|
+
separateSessionByConversation,
|
|
56
|
+
groupSessionScope,
|
|
57
|
+
sharedMemoryAcrossConversations,
|
|
58
|
+
} = params;
|
|
59
|
+
const isDirect = conversationType === '1';
|
|
60
|
+
|
|
61
|
+
// sharedMemoryAcrossConversations=true 时,所有会话共享记忆
|
|
62
|
+
if (sharedMemoryAcrossConversations === true) {
|
|
63
|
+
return {
|
|
64
|
+
channel: 'intclaw-connector',
|
|
65
|
+
accountId,
|
|
66
|
+
chatType: isDirect ? 'direct' : 'group',
|
|
67
|
+
peerId: accountId, // 使用 accountId 作为 peerId,实现跨会话记忆共享
|
|
68
|
+
conversationId: isDirect ? undefined : conversationId,
|
|
69
|
+
senderName,
|
|
70
|
+
groupSubject: isDirect ? undefined : groupSubject,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// separateSessionByConversation=false 时,不区分单聊/群聊,按用户维度维护 session
|
|
75
|
+
if (separateSessionByConversation === false) {
|
|
76
|
+
return {
|
|
77
|
+
channel: 'intclaw-connector',
|
|
78
|
+
accountId,
|
|
79
|
+
chatType: isDirect ? 'direct' : 'group',
|
|
80
|
+
peerId: senderId, // 只用 senderId,不区分会话
|
|
81
|
+
senderName,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 以下是 separateSessionByConversation=true(默认)的逻辑
|
|
86
|
+
if (isDirect) {
|
|
87
|
+
// 单聊:peerId 为发送者 ID,由 OpenClaw Gateway 根据 dmScope 配置处理
|
|
88
|
+
return {
|
|
89
|
+
channel: 'intclaw-connector',
|
|
90
|
+
accountId,
|
|
91
|
+
chatType: 'direct',
|
|
92
|
+
peerId: senderId,
|
|
93
|
+
senderName,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 群聊:根据 groupSessionScope 配置决定会话隔离策略
|
|
98
|
+
if (groupSessionScope === 'group_sender') {
|
|
99
|
+
// 群内每个用户独立会话
|
|
100
|
+
return {
|
|
101
|
+
channel: 'intclaw-connector',
|
|
102
|
+
accountId,
|
|
103
|
+
chatType: 'group',
|
|
104
|
+
peerId: `${conversationId}:${senderId}`,
|
|
105
|
+
conversationId,
|
|
106
|
+
senderName,
|
|
107
|
+
groupSubject,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 默认:整个群共享一个会话
|
|
112
|
+
return {
|
|
113
|
+
channel: 'intclaw-connector',
|
|
114
|
+
accountId,
|
|
115
|
+
chatType: 'group',
|
|
116
|
+
peerId: conversationId || senderId,
|
|
117
|
+
conversationId,
|
|
118
|
+
senderName,
|
|
119
|
+
groupSubject,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 检查消息是否是新会话命令
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeSlashCommand(text: string): string {
|
|
127
|
+
const trimmed = text.trim();
|
|
128
|
+
const lower = trimmed.toLowerCase();
|
|
129
|
+
if (NEW_SESSION_COMMANDS.some((cmd) => lower === cmd.toLowerCase())) {
|
|
130
|
+
return '/new';
|
|
131
|
+
}
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ============ Access Token 缓存 ============
|
|
136
|
+
|
|
137
|
+
type CachedToken = {
|
|
138
|
+
token: string;
|
|
139
|
+
expiryMs: number;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// 注意:这里仍被部分新逻辑引用(如 message-handler),必须支持多账号,不能用全局单例缓存
|
|
143
|
+
const apiTokenCache = new Map<string, CachedToken>();
|
|
144
|
+
const oapiTokenCache = new Map<string, CachedToken>();
|
|
145
|
+
|
|
146
|
+
function cacheKey(config: IntclawConfig): string {
|
|
147
|
+
const clientId = String((config as any)?.clientId ?? '').trim();
|
|
148
|
+
|
|
149
|
+
// 添加校验
|
|
150
|
+
if (!clientId) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
'Invalid IntclawConfig: clientId is required for token caching. ' +
|
|
153
|
+
'Please ensure your configuration includes a valid clientId.'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return clientId;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 获取IntClaw Access Token(新版 API)
|
|
162
|
+
*/
|
|
163
|
+
export async function getAccessToken(config: IntclawConfig): Promise<string> {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const key = cacheKey(config);
|
|
166
|
+
const cached = apiTokenCache.get(key);
|
|
167
|
+
if (cached && cached.expiryMs > now + 60_000) {
|
|
168
|
+
return cached.token;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { intclawHttp } = await import('./http-client.ts');
|
|
172
|
+
const response = await intclawHttp.post(`${INTCLAW_API}/v1.0/oauth2/accessToken`, {
|
|
173
|
+
appKey: config.clientId,
|
|
174
|
+
appSecret: config.clientSecret,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const token = response.data.accessToken as string;
|
|
178
|
+
const expireInSec = Number(response.data.expireIn ?? 0);
|
|
179
|
+
apiTokenCache.set(key, { token, expiryMs: now + expireInSec * 1000 });
|
|
180
|
+
return token;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 获取IntClaw OAPI Access Token(旧版 API,用于媒体上传等)
|
|
185
|
+
*/
|
|
186
|
+
export async function getOapiAccessToken(config: IntclawConfig): Promise<string | null> {
|
|
187
|
+
try {
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const key = cacheKey(config);
|
|
190
|
+
const cached = oapiTokenCache.get(key);
|
|
191
|
+
if (cached && cached.expiryMs > now + 60_000) {
|
|
192
|
+
return cached.token;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { intclawOapiHttp } = await import('./http-client.ts');
|
|
196
|
+
const resp = await intclawOapiHttp.get(`${INTCLAW_OAPI}/gettoken`, {
|
|
197
|
+
params: { appkey: config.clientId, appsecret: config.clientSecret },
|
|
198
|
+
});
|
|
199
|
+
if (resp.data?.errcode === 0 && resp.data?.access_token) {
|
|
200
|
+
const token = String(resp.data.access_token);
|
|
201
|
+
const expiresInSec = Number(resp.data.expires_in ?? 7200);
|
|
202
|
+
oapiTokenCache.set(key, { token, expiryMs: now + expiresInSec * 1000 });
|
|
203
|
+
return token;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============ 用户 ID 转换 ============
|
|
212
|
+
|
|
213
|
+
/** staffId → unionId 缓存(带过期时间的 LRU 缓存) */
|
|
214
|
+
const MAX_UNION_ID_CACHE_SIZE = 1000;
|
|
215
|
+
const UNION_ID_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 小时
|
|
216
|
+
|
|
217
|
+
interface UnionIdCacheEntry {
|
|
218
|
+
unionId: string;
|
|
219
|
+
timestamp: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const unionIdCache = new Map<string, UnionIdCacheEntry>();
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 通过 oapi 旧版接口将 staffId 转换为 unionId
|
|
226
|
+
*/
|
|
227
|
+
export async function getUnionId(
|
|
228
|
+
staffId: string,
|
|
229
|
+
config: IntclawConfig,
|
|
230
|
+
log?: any,
|
|
231
|
+
): Promise<string | null> {
|
|
232
|
+
// 检查缓存
|
|
233
|
+
const cached = unionIdCache.get(staffId);
|
|
234
|
+
if (cached && Date.now() - cached.timestamp < UNION_ID_CACHE_TTL) {
|
|
235
|
+
return cached.unionId;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const token = await getOapiAccessToken(config);
|
|
240
|
+
if (!token) {
|
|
241
|
+
log?.error?.('[IntClaw] getUnionId: 无法获取 oapi access_token');
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const { intclawOapiHttp } = await import('./http-client.ts');
|
|
245
|
+
const resp = await intclawOapiHttp.get(`${INTCLAW_OAPI}/user/get`, {
|
|
246
|
+
params: { access_token: token, userid: staffId },
|
|
247
|
+
timeout: 10_000,
|
|
248
|
+
});
|
|
249
|
+
const unionId = resp.data?.unionid;
|
|
250
|
+
if (unionId) {
|
|
251
|
+
// 写入缓存前检查大小
|
|
252
|
+
if (unionIdCache.size >= MAX_UNION_ID_CACHE_SIZE) {
|
|
253
|
+
// 删除最旧的条目
|
|
254
|
+
let oldestKey: string | null = null;
|
|
255
|
+
let oldestTime = Date.now();
|
|
256
|
+
|
|
257
|
+
for (const [key, entry] of unionIdCache.entries()) {
|
|
258
|
+
if (entry.timestamp < oldestTime) {
|
|
259
|
+
oldestTime = entry.timestamp;
|
|
260
|
+
oldestKey = key;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (oldestKey) {
|
|
265
|
+
unionIdCache.delete(oldestKey);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
unionIdCache.set(staffId, { unionId, timestamp: Date.now() });
|
|
270
|
+
log?.info?.(`[IntClaw] getUnionId: ${staffId} → ${unionId}`);
|
|
271
|
+
return unionId;
|
|
272
|
+
}
|
|
273
|
+
log?.error?.(`[IntClaw] getUnionId: 响应中无 unionid 字段: ${JSON.stringify(resp.data)}`);
|
|
274
|
+
return null;
|
|
275
|
+
} catch (err: any) {
|
|
276
|
+
log?.error?.(`[IntClaw] getUnionId 失败: ${err.message}`);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ============ 消息去重 ============
|
|
282
|
+
|
|
283
|
+
/** 消息去重缓存 Map<messageId, timestamp> - 防止同一消息被重复处理 */
|
|
284
|
+
const processedMessages = new Map<string, number>();
|
|
285
|
+
|
|
286
|
+
/** 消息去重缓存过期时间(5分钟) */
|
|
287
|
+
const MESSAGE_DEDUP_TTL = 5 * 60 * 1000;
|
|
288
|
+
|
|
289
|
+
/** 定时清理器 */
|
|
290
|
+
let cleanupTimer: NodeJS.Timeout | null = null;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 清理过期的消息去重缓存
|
|
294
|
+
*/
|
|
295
|
+
export function cleanupProcessedMessages(): void {
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
for (const [msgId, timestamp] of processedMessages.entries()) {
|
|
298
|
+
if (now - timestamp > MESSAGE_DEDUP_TTL) {
|
|
299
|
+
processedMessages.delete(msgId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 启动定时清理机制
|
|
306
|
+
*/
|
|
307
|
+
export function startMessageCleanup(): void {
|
|
308
|
+
if (cleanupTimer) return; // 防止重复启动
|
|
309
|
+
|
|
310
|
+
// 每 5 分钟清理一次
|
|
311
|
+
cleanupTimer = setInterval(() => {
|
|
312
|
+
cleanupProcessedMessages();
|
|
313
|
+
}, 5 * 60 * 1000);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 停止定时清理机制
|
|
318
|
+
*/
|
|
319
|
+
export function stopMessageCleanup(): void {
|
|
320
|
+
if (cleanupTimer) {
|
|
321
|
+
clearInterval(cleanupTimer);
|
|
322
|
+
cleanupTimer = null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 检查消息是否已处理过(去重)
|
|
328
|
+
*/
|
|
329
|
+
export function isMessageProcessed(messageId: string): boolean {
|
|
330
|
+
if (!messageId) return false;
|
|
331
|
+
return processedMessages.has(messageId);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 标记消息为已处理
|
|
336
|
+
*/
|
|
337
|
+
export function markMessageProcessed(messageId: string): void {
|
|
338
|
+
if (!messageId) return;
|
|
339
|
+
processedMessages.set(messageId, Date.now());
|
|
340
|
+
// 定期清理(每处理100条消息清理一次)
|
|
341
|
+
if (processedMessages.size >= 100) {
|
|
342
|
+
cleanupProcessedMessages();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ============ 配置工具 ============
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 获取IntClaw配置
|
|
350
|
+
*/
|
|
351
|
+
export function getIntclawConfig(cfg: any): IntclawConfig {
|
|
352
|
+
return (cfg?.channels as any)?.['intclaw-connector'] || {};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 检查是否已配置
|
|
357
|
+
*/
|
|
358
|
+
export function isIntclawConfigured(cfg: any): boolean {
|
|
359
|
+
const config = getIntclawConfig(cfg);
|
|
360
|
+
return Boolean(config.clientId && config.clientSecret);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 构建媒体系统提示词
|
|
365
|
+
*/
|
|
366
|
+
export function buildMediaSystemPrompt(): string {
|
|
367
|
+
return `## IntClaw图片和文件显示规则
|
|
368
|
+
|
|
369
|
+
你正在IntClaw中与用户对话。
|
|
370
|
+
|
|
371
|
+
### 一、图片显示
|
|
372
|
+
|
|
373
|
+
显示图片时,直接使用本地文件路径,系统会自动上传处理。
|
|
374
|
+
|
|
375
|
+
**正确方式**:
|
|
376
|
+
\`\`\`markdown
|
|
377
|
+

|
|
378
|
+

|
|
379
|
+

|
|
380
|
+
\`\`\`
|
|
381
|
+
|
|
382
|
+
**禁止**:
|
|
383
|
+
- 不要自己执行 curl 上传
|
|
384
|
+
- 不要猜测或构造 URL
|
|
385
|
+
- **不要对路径进行转义(如使用反斜杠 \\ )**
|
|
386
|
+
|
|
387
|
+
直接输出本地路径即可,系统会自动上传到IntClaw。
|
|
388
|
+
|
|
389
|
+
### 二、视频分享
|
|
390
|
+
|
|
391
|
+
**何时分享视频**:
|
|
392
|
+
- ✅ 用户明确要求**分享、发送、上传**视频时
|
|
393
|
+
- ❌ 仅生成视频保存到本地时,**不需要**分享
|
|
394
|
+
|
|
395
|
+
**视频标记格式**:
|
|
396
|
+
当需要分享视频时,在回复**末尾**添加:
|
|
397
|
+
|
|
398
|
+
\`\`\`
|
|
399
|
+
[INTCLAW_VIDEO]{"path":"<本地视频路径>"}[/INTCLAW_VIDEO]
|
|
400
|
+
\`\`\`
|
|
401
|
+
|
|
402
|
+
**支持格式**:mp4(最大 20MB)
|
|
403
|
+
|
|
404
|
+
**重要**:
|
|
405
|
+
- 视频大小不得超过 20MB,超过限制时告知用户
|
|
406
|
+
- 仅支持 mp4 格式
|
|
407
|
+
- 系统会自动提取视频时长、分辨率并生成封面
|
|
408
|
+
|
|
409
|
+
### 三、音频分享
|
|
410
|
+
|
|
411
|
+
**何时分享音频**:
|
|
412
|
+
- ✅ 用户明确要求**分享、发送、上传**音频/语音文件时
|
|
413
|
+
- ❌ 仅生成音频保存到本地时,**不需要**分享
|
|
414
|
+
|
|
415
|
+
**音频标记格式**:
|
|
416
|
+
当需要分享音频时,在回复**末尾**添加:
|
|
417
|
+
|
|
418
|
+
\`\`\`
|
|
419
|
+
[INTCLAW_AUDIO]{"path":"<本地音频路径>"}[/INTCLAW_AUDIO]
|
|
420
|
+
\`\`\`
|
|
421
|
+
|
|
422
|
+
**支持格式**:ogg、amr(最大 20MB)
|
|
423
|
+
|
|
424
|
+
**重要**:
|
|
425
|
+
- 音频大小不得超过 20MB,超过限制时告知用户
|
|
426
|
+
- 系统会自动提取音频时长
|
|
427
|
+
|
|
428
|
+
### 四、文件分享
|
|
429
|
+
|
|
430
|
+
**何时分享文件**:
|
|
431
|
+
- ✅ 用户明确要求**分享、发送、上传**文件时
|
|
432
|
+
- ❌ 仅生成文件保存到本地时,**不需要**分享
|
|
433
|
+
|
|
434
|
+
**文件标记格式**:
|
|
435
|
+
当需要分享文件时,在回复**末尾**添加:
|
|
436
|
+
|
|
437
|
+
\`\`\`
|
|
438
|
+
[INTCLAW_FILE]{"path":"<本地文件路径>","fileName":"<文件名>","fileType":"<扩展名>"}[/INTCLAW_FILE]
|
|
439
|
+
\`\`\`
|
|
440
|
+
|
|
441
|
+
**支持的文件类型**:几乎所有常见格式
|
|
442
|
+
|
|
443
|
+
**重要**:文件大小不得超过 20MB,超过限制时告知用户文件过大。`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ============ 消息表情回复 ============
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 在用户消息上贴 🤔思考中 表情,表示正在处理
|
|
450
|
+
*/
|
|
451
|
+
export async function addEmotionReply(config: IntclawConfig, data: any, log?: any): Promise<void> {
|
|
452
|
+
if (!data.msgId || !data.conversationId) return;
|
|
453
|
+
try {
|
|
454
|
+
const token = await getAccessToken(config);
|
|
455
|
+
const { intclawHttp } = await import('./http-client.ts');
|
|
456
|
+
await intclawHttp.post(`${INTCLAW_API}/v1.0/robot/emotion/reply`, {
|
|
457
|
+
robotCode: data.robotCode ?? config.clientId,
|
|
458
|
+
openMsgId: data.msgId,
|
|
459
|
+
openConversationId: data.conversationId,
|
|
460
|
+
emotionType: 2,
|
|
461
|
+
emotionName: '🤔思考中',
|
|
462
|
+
textEmotion: {
|
|
463
|
+
emotionId: '2659900',
|
|
464
|
+
emotionName: '🤔思考中',
|
|
465
|
+
text: '🤔思考中',
|
|
466
|
+
backgroundId: 'im_bg_1',
|
|
467
|
+
},
|
|
468
|
+
}, {
|
|
469
|
+
headers: { 'x-acs-intclaw-access-token': token, 'Content-Type': 'application/json' },
|
|
470
|
+
timeout: 5_000,
|
|
471
|
+
});
|
|
472
|
+
log?.info?.(`[IntClaw][Emotion] 贴表情成功: msgId=${data.msgId}`);
|
|
473
|
+
} catch (err: any) {
|
|
474
|
+
log?.warn?.(`[IntClaw][Emotion] 贴表情失败(不影响主流程): ${err.message}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 撤回用户消息上的 🤔思考中 表情
|
|
480
|
+
*/
|
|
481
|
+
export async function recallEmotionReply(config: IntclawConfig, data: any, log?: any): Promise<void> {
|
|
482
|
+
if (!data.msgId || !data.conversationId) return;
|
|
483
|
+
try {
|
|
484
|
+
const token = await getAccessToken(config);
|
|
485
|
+
const { intclawHttp } = await import('./http-client.ts');
|
|
486
|
+
await intclawHttp.post(`${INTCLAW_API}/v1.0/robot/emotion/recall`, {
|
|
487
|
+
robotCode: data.robotCode ?? config.clientId,
|
|
488
|
+
openMsgId: data.msgId,
|
|
489
|
+
openConversationId: data.conversationId,
|
|
490
|
+
emotionType: 2,
|
|
491
|
+
emotionName: '🤔思考中',
|
|
492
|
+
textEmotion: {
|
|
493
|
+
emotionId: '2659900',
|
|
494
|
+
emotionName: '🤔思考中',
|
|
495
|
+
text: '🤔思考中',
|
|
496
|
+
backgroundId: 'im_bg_1',
|
|
497
|
+
},
|
|
498
|
+
}, {
|
|
499
|
+
headers: { 'x-acs-intclaw-access-token': token, 'Content-Type': 'application/json' },
|
|
500
|
+
timeout: 5_000,
|
|
501
|
+
});
|
|
502
|
+
log?.info?.(`[IntClaw][Emotion] 撤回表情成功: msgId=${data.msgId}`);
|
|
503
|
+
} catch (err: any) {
|
|
504
|
+
log?.warn?.(`[IntClaw][Emotion] 撤回表情失败(不影响主流程): ${err.message}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
package/.env.example
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# IntClaw Plugin Configuration Example
|
|
2
|
-
# Copy this to .env or add to your OpenClaw config
|
|
3
|
-
|
|
4
|
-
# WebSocket Server URL (required)
|
|
5
|
-
INTCLAW_WS_URL=wss://api.intclaw.example.com/ws
|
|
6
|
-
|
|
7
|
-
# API Key for authentication (required)
|
|
8
|
-
INTCLAW_API_KEY=your-api-key-here
|
|
9
|
-
|
|
10
|
-
# Reconnection interval in milliseconds (optional, default: 5000)
|
|
11
|
-
# INTCLAW_RECONNECT_INTERVAL=5000
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: intclaw_matrix
|
|
3
|
-
description: Instructs the AI on formatting and targeting rules for the intclaw_matrix_send tool
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# IntClaw Matrix Plugin SKILL
|
|
7
|
-
|
|
8
|
-
This skill activates when the user requests integration or message delivery through the IntClaw Matrix framework.
|
|
9
|
-
|
|
10
|
-
## Behavior
|
|
11
|
-
|
|
12
|
-
1. **Active Invocation**: Whenever the user explicitly asks you to "send a matrix message", "dispatch via matrix", or similar, YOU MUST call the `intclaw_matrix_send` tool.
|
|
13
|
-
2. **Target Resolution**: Always require the user to specify a full Matrix destination such as `!roomid:matrix.org` or `@username:homeserver.com`. If the destination is ambiguous or not provided, ask the user to clarify before dispatching.
|
|
14
|
-
3. **Account Targeting**: By default, use the `default` account ID. If the user expresses that they want to send it from a specific bot instance or account, pass that through the `account_id` parameter of the tool.
|
|
15
|
-
|
|
16
|
-
## Formatting Rules
|
|
17
|
-
|
|
18
|
-
- Output pure structured text without heavy Markdown when dispatching text payloads to `intclaw_matrix_send`. Matrix handles plaintext or basic HTML best. Do not wrap payloads in codeblocks unless explicitly communicating code.
|
|
19
|
-
- Report back the Matrix Event ID obtained from the tool output directly to the user upon a successful dispatch.
|
|
20
|
-
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ---
|
|
3
|
-
* status: active
|
|
4
|
-
* birth_time: "2026-03-19T09:42:00Z"
|
|
5
|
-
* original_intent: "Provide IntClaw bidirectional websocket channel and proxy messages to OpenClaw gateway using chunked streaming"
|
|
6
|
-
* version_count: 1
|
|
7
|
-
* ---
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import WebSocket from 'ws';
|
|
11
|
-
|
|
12
|
-
export async function start_intclaw_channel(gateway, config) {
|
|
13
|
-
const ws_url = 'wss://claw-dev.int-os.com/user-ws/';
|
|
14
|
-
|
|
15
|
-
const ws_conn = new WebSocket(ws_url, {
|
|
16
|
-
headers: {
|
|
17
|
-
'X-App-Key': config.appKey,
|
|
18
|
-
'X-App-Secret': config.appSecret
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
ws_conn.on('open', () => {
|
|
23
|
-
const auth_payload = {
|
|
24
|
-
type: 'auth_request',
|
|
25
|
-
app_key: config.appKey,
|
|
26
|
-
timestamp: Date.now()
|
|
27
|
-
};
|
|
28
|
-
ws_conn.send(JSON.stringify(auth_payload));
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
ws_conn.on('message', async (data) => {
|
|
32
|
-
try {
|
|
33
|
-
const msg = JSON.parse(data.toString());
|
|
34
|
-
|
|
35
|
-
if (msg.type === 'auth_response') {
|
|
36
|
-
if (msg.success && gateway.notifyChannelReady) {
|
|
37
|
-
await gateway.notifyChannelReady('intclaw');
|
|
38
|
-
}
|
|
39
|
-
} else if (msg.type === 'incoming_message') {
|
|
40
|
-
process_incoming_message(msg.payload, ws_conn, config);
|
|
41
|
-
} else if (msg.type === 'ping') {
|
|
42
|
-
ws_conn.send(JSON.stringify({ type: 'pong' }));
|
|
43
|
-
}
|
|
44
|
-
} catch (err) {
|
|
45
|
-
console.error(JSON.stringify({ error: "failed_to_handle_msg", reason: err.message }));
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
ws_conn.on('error', (err) => {
|
|
50
|
-
console.error(JSON.stringify({ error: "ws_error", reason: err.message }));
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
ws_conn.on('close', (code, reason) => {
|
|
54
|
-
console.log(JSON.stringify({ event: "ws_closed", code, reason: reason?.toString() }));
|
|
55
|
-
setTimeout(() => {
|
|
56
|
-
start_intclaw_channel(gateway, config);
|
|
57
|
-
}, 5000);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
return ws_conn;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function process_incoming_message(payload, ws_conn, config) {
|
|
64
|
-
const session_context = {
|
|
65
|
-
channel: 'intclaw',
|
|
66
|
-
account_id: payload.accountId || 'default',
|
|
67
|
-
chat_type: payload.peerKind || 'direct',
|
|
68
|
-
peer_id: payload.peerId
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const session_key = JSON.stringify(session_context);
|
|
72
|
-
const user_content = payload.text;
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
for await (const chunk of stream_from_gateway(user_content, session_key, config)) {
|
|
76
|
-
send_stream_chunk(ws_conn, payload, chunk, false);
|
|
77
|
-
}
|
|
78
|
-
// send end of stream marker
|
|
79
|
-
send_stream_chunk(ws_conn, payload, "", true);
|
|
80
|
-
} catch (err) {
|
|
81
|
-
console.error(JSON.stringify({ error: "gateway_stream_failed", reason: err.message }));
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async function* stream_from_gateway(user_content, session_key, config) {
|
|
86
|
-
const port = config.gatewayPort || 18789;
|
|
87
|
-
const url = `http://127.0.0.1:${port}/v1/chat/completions`;
|
|
88
|
-
|
|
89
|
-
const resp = await fetch(url, {
|
|
90
|
-
method: 'POST',
|
|
91
|
-
headers: {
|
|
92
|
-
'Content-Type': 'application/json',
|
|
93
|
-
'X-OpenClaw-Agent-Id': 'main'
|
|
94
|
-
},
|
|
95
|
-
body: JSON.stringify({
|
|
96
|
-
model: 'main',
|
|
97
|
-
messages: [{ role: 'user', content: user_content }],
|
|
98
|
-
stream: true,
|
|
99
|
-
user: session_key
|
|
100
|
-
})
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
if (!resp.ok) {
|
|
104
|
-
const err_text = await resp.text();
|
|
105
|
-
throw new Error(`status_${resp.status}_${err_text}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const reader = resp.body.getReader();
|
|
109
|
-
const decoder = new TextDecoder();
|
|
110
|
-
let buffer = '';
|
|
111
|
-
|
|
112
|
-
while (true) {
|
|
113
|
-
const { done, value } = await reader.read();
|
|
114
|
-
if (done) break;
|
|
115
|
-
|
|
116
|
-
buffer += decoder.decode(value, { stream: true });
|
|
117
|
-
const lines = buffer.split('\n');
|
|
118
|
-
buffer = lines.pop() || '';
|
|
119
|
-
|
|
120
|
-
for (const line of lines) {
|
|
121
|
-
if (!line.startsWith('data: ')) continue;
|
|
122
|
-
const data = line.slice(6).trim();
|
|
123
|
-
if (data === '[DONE]') return;
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
const chunk_obj = JSON.parse(data);
|
|
127
|
-
const content = chunk_obj.choices?.[0]?.delta?.content;
|
|
128
|
-
if (content) yield content;
|
|
129
|
-
} catch (e) {
|
|
130
|
-
// partial chunk, ignore
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function send_stream_chunk(ws_conn, incoming_payload, chunk_text, is_done) {
|
|
137
|
-
if (ws_conn.readyState !== WebSocket.OPEN) return;
|
|
138
|
-
|
|
139
|
-
const out_msg = {
|
|
140
|
-
type: 'outgoing_message',
|
|
141
|
-
payload: {
|
|
142
|
-
id: `intclaw_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
143
|
-
account_id: incoming_payload.accountId || 'default',
|
|
144
|
-
peer_id: incoming_payload.peerId,
|
|
145
|
-
peer_kind: incoming_payload.peerKind,
|
|
146
|
-
text: chunk_text,
|
|
147
|
-
reply_to_id: incoming_payload.id,
|
|
148
|
-
timestamp: Date.now(),
|
|
149
|
-
is_chunk: true,
|
|
150
|
-
is_done: is_done
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
ws_conn.send(JSON.stringify(out_msg));
|
|
155
|
-
}
|