imtoagent 0.2.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/README.md +234 -0
- package/bin/imtoagent +453 -0
- package/index.ts +1129 -0
- package/modules/agent/claude-adapter.ts +258 -0
- package/modules/agent/claude.ts +160 -0
- package/modules/agent/codex-adapter.ts +232 -0
- package/modules/agent/codex-exec-server.ts +513 -0
- package/modules/agent/codex.ts +275 -0
- package/modules/agent/opencode-adapter.ts +308 -0
- package/modules/agent/opencode.ts +247 -0
- package/modules/bot-context.ts +26 -0
- package/modules/capabilities.ts +189 -0
- package/modules/cli/setup.ts +424 -0
- package/modules/core/config.ts +275 -0
- package/modules/core/error.ts +124 -0
- package/modules/core/index.ts +39 -0
- package/modules/core/runtime.ts +282 -0
- package/modules/core/session.ts +256 -0
- package/modules/core/stats.ts +92 -0
- package/modules/core/types.ts +250 -0
- package/modules/im/feishu.ts +731 -0
- package/modules/im/telegram.ts +639 -0
- package/modules/im/wechat.ts +1094 -0
- package/modules/im/wecom.ts +603 -0
- package/modules/media/feishu-inbound-adapter.ts +108 -0
- package/modules/media/index.ts +27 -0
- package/modules/media/media-store.ts +273 -0
- package/modules/media/resolver.ts +178 -0
- package/modules/media/telegram-inbound-adapter.ts +124 -0
- package/modules/media/types.ts +76 -0
- package/modules/prompt-builder.ts +123 -0
- package/modules/proxy/anthropic-proxy.ts +1083 -0
- package/modules/proxy/codex-proxy.ts +657 -0
- package/modules/rate-limiter.ts +58 -0
- package/modules/types.ts +144 -0
- package/modules/utils/backend-check.ts +121 -0
- package/modules/utils/paths.ts +218 -0
- package/package.json +53 -0
- package/scripts/postinstall.ts +70 -0
- package/templates/config.template.json +57 -0
- package/templates/opencode.template.json +28 -0
- package/templates/providers.template.json +19 -0
- package/templates/soul.template/identity.md +6 -0
- package/templates/soul.template/profile.md +11 -0
- package/templates/soul.template/rules.md +7 -0
- package/templates/soul.template/skills.md +3 -0
- package/templates/soul.template/workspace.md +4 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// FeishuInboundAdapter — 飞书平台媒体下载适配器
|
|
3
|
+
// ================================================================
|
|
4
|
+
// 实现 InboundMediaAdapter 接口,负责从飞书 API 下载消息附件 buffer
|
|
5
|
+
// 不负责:存储、MIME sniff、扩展名处理(由 MediaStore 处理)
|
|
6
|
+
// ================================================================
|
|
7
|
+
|
|
8
|
+
import type { InboundMediaAdapter, DownloadedMedia, MediaResourceType } from '../media/types';
|
|
9
|
+
import * as Lark from '@larksuiteoapi/node-sdk';
|
|
10
|
+
|
|
11
|
+
export interface FeishuInboundAdapterOptions {
|
|
12
|
+
appId: string;
|
|
13
|
+
appSecret: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class FeishuInboundAdapter implements InboundMediaAdapter {
|
|
17
|
+
readonly platform = 'feishu';
|
|
18
|
+
private appId: string;
|
|
19
|
+
private appSecret: string;
|
|
20
|
+
private client: Lark.Client;
|
|
21
|
+
private _appToken: string | null = null;
|
|
22
|
+
private _appTokenExpiresAt = 0; // 飞书 tenant_token 约 2 小时有效,提前 5 分钟刷新
|
|
23
|
+
|
|
24
|
+
constructor(options: FeishuInboundAdapterOptions) {
|
|
25
|
+
this.appId = options.appId;
|
|
26
|
+
this.appSecret = options.appSecret;
|
|
27
|
+
this.client = new Lark.Client({ appId: options.appId, appSecret: options.appSecret });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async downloadResource(
|
|
31
|
+
messageId: string,
|
|
32
|
+
resourceKey: string,
|
|
33
|
+
type: MediaResourceType,
|
|
34
|
+
fileName?: string
|
|
35
|
+
): Promise<DownloadedMedia | null> {
|
|
36
|
+
try {
|
|
37
|
+
const token = await this.getAppToken();
|
|
38
|
+
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/resources/${resourceKey}?type=${type}`;
|
|
39
|
+
|
|
40
|
+
const resp = await fetch(url, {
|
|
41
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!resp.ok) {
|
|
45
|
+
// 容错:文件类型 502 时尝试用 media 类型重试
|
|
46
|
+
if (resp.status === 502 && type === 'file') {
|
|
47
|
+
console.log(`[FeishuInbound] file 类型返回 502,尝试 media 类型重试`);
|
|
48
|
+
const retryUrl = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/resources/${resourceKey}?type=media`;
|
|
49
|
+
const retryResp = await fetch(retryUrl, {
|
|
50
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
51
|
+
});
|
|
52
|
+
if (retryResp.ok) {
|
|
53
|
+
const retryBuffer = Buffer.from(await retryResp.arrayBuffer());
|
|
54
|
+
const retryContentType = retryResp.headers.get('content-type')?.split(';')[0].trim().toLowerCase() || undefined;
|
|
55
|
+
return {
|
|
56
|
+
buffer: retryBuffer,
|
|
57
|
+
fileName,
|
|
58
|
+
contentType: retryContentType,
|
|
59
|
+
sourceKey: resourceKey,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.error(`[FeishuInbound] 下载消息资源失败: HTTP ${resp.status} (key=${resourceKey})`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
68
|
+
const contentType = resp.headers.get('content-type')?.split(';')[0].trim().toLowerCase() || undefined;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
buffer,
|
|
72
|
+
fileName,
|
|
73
|
+
contentType,
|
|
74
|
+
sourceKey: resourceKey,
|
|
75
|
+
};
|
|
76
|
+
} catch (e: any) {
|
|
77
|
+
console.error(`[FeishuInbound] 下载消息资源异常: ${e.message}`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async getAppToken(): Promise<string> {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
// 提前 5 分钟刷新,避免刚好过期
|
|
85
|
+
if (this._appToken && this._appTokenExpiresAt > now + 5 * 60 * 1000) {
|
|
86
|
+
return this._appToken;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resp = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
app_id: this.appId,
|
|
94
|
+
app_secret: this.appSecret,
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const data = await resp.json();
|
|
99
|
+
if (data.code !== 0 || !data.tenant_access_token) {
|
|
100
|
+
throw new Error(`获取飞书 tenant_access_token 失败: ${JSON.stringify(data)}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this._appToken = data.tenant_access_token;
|
|
104
|
+
// 飞书 tenant_token 有效期约 2 小时
|
|
105
|
+
this._appTokenExpiresAt = now + 2 * 60 * 60 * 1000;
|
|
106
|
+
return this._appToken;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// modules/media — Inbound Media 适配器 + 抽象层
|
|
3
|
+
// ================================================================
|
|
4
|
+
// 架构概览:
|
|
5
|
+
//
|
|
6
|
+
// ┌──────────────────────────────────────────────┐
|
|
7
|
+
// │ InboundMediaResolver (编排层) │
|
|
8
|
+
// │ 下载 → 存储 → 分类 → 生成 Agent 提示 │
|
|
9
|
+
// └──────┬───────────────────────┬────────────────┘
|
|
10
|
+
// │ │
|
|
11
|
+
// ▼ ▼
|
|
12
|
+
// ┌──────────────┐ ┌──────────────┐
|
|
13
|
+
// │InboundMedia │ │ MediaStore │
|
|
14
|
+
// │Adapter (接口)│ │ (存储层) │
|
|
15
|
+
// └──────┬───────┘ └──────────────┘
|
|
16
|
+
// │
|
|
17
|
+
// ┌──────▼───────┐
|
|
18
|
+
// │FeishuInbound │ (未来: TelegramInboundAdapter, ...)
|
|
19
|
+
// │Adapter │
|
|
20
|
+
// └──────────────┘
|
|
21
|
+
// ================================================================
|
|
22
|
+
|
|
23
|
+
export * from './types';
|
|
24
|
+
export { MediaStore, sniffMimeFromBuffer, mimeFromFileName, extensionForMime, categorizeMedia } from './media-store';
|
|
25
|
+
export { InboundMediaResolver } from './resolver';
|
|
26
|
+
export { FeishuInboundAdapter } from './feishu-inbound-adapter';
|
|
27
|
+
export { TelegramInboundAdapter } from './telegram-inbound-adapter';
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// MediaStore — 统一媒体存储抽象层
|
|
3
|
+
// ================================================================
|
|
4
|
+
// 职责:
|
|
5
|
+
// 1. 保存 buffer 到本地,带正确扩展名
|
|
6
|
+
// 2. MIME sniff(优先用 buffer 内容判断,其次用文件名推断)
|
|
7
|
+
// 3. 媒体分类(image / document / audio / ...)
|
|
8
|
+
// 4. 生命周期管理(清理过期文件)
|
|
9
|
+
//
|
|
10
|
+
// 参考 OpenClaw 的 parseMessageWithAttachments + saveMediaBuffer 设计
|
|
11
|
+
// ================================================================
|
|
12
|
+
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import type { MediaEntry, MediaCategory } from './types';
|
|
17
|
+
import { getDataDir } from '../utils/paths';
|
|
18
|
+
|
|
19
|
+
// ================================================================
|
|
20
|
+
// MIME 推断
|
|
21
|
+
// ================================================================
|
|
22
|
+
|
|
23
|
+
/** 通过 buffer 头部字节 sniff MIME 类型 */
|
|
24
|
+
export function sniffMimeFromBuffer(buffer: Buffer): string | null {
|
|
25
|
+
if (buffer.length < 4) return null;
|
|
26
|
+
|
|
27
|
+
const header = buffer.toString('hex', 0, 16);
|
|
28
|
+
|
|
29
|
+
// PNG
|
|
30
|
+
if (header.startsWith('89504e47')) return 'image/png';
|
|
31
|
+
// JPEG
|
|
32
|
+
if (header.startsWith('ffd8ff')) return 'image/jpeg';
|
|
33
|
+
// GIF
|
|
34
|
+
if (header.startsWith('47494638')) return 'image/gif';
|
|
35
|
+
// WebP
|
|
36
|
+
if (header.startsWith('52494646') && buffer.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
|
|
37
|
+
// PDF
|
|
38
|
+
if (header.startsWith('25504446')) return 'application/pdf';
|
|
39
|
+
// ZIP / XLSX / DOCX / PPTX (PK)
|
|
40
|
+
if (header.startsWith('504b0304') || header.startsWith('504b0506')) {
|
|
41
|
+
// 需要进一步判断,先返回通用 ZIP
|
|
42
|
+
return 'application/zip';
|
|
43
|
+
}
|
|
44
|
+
// MP3
|
|
45
|
+
if (header.startsWith('494433') || header.startsWith('fff3') || header.startsWith('fff2')) return 'audio/mpeg';
|
|
46
|
+
// WAV
|
|
47
|
+
if (header.startsWith('52494646') && buffer.toString('ascii', 8, 12) === 'WAVE') return 'audio/wav';
|
|
48
|
+
// OGG
|
|
49
|
+
if (header.startsWith('4f676753')) return 'audio/ogg';
|
|
50
|
+
// MP4
|
|
51
|
+
if (header.startsWith('000000') && buffer.toString('ascii', 4, 8).includes('ftyp')) return 'video/mp4';
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 从文件名推断 MIME 类型 */
|
|
57
|
+
export function mimeFromFileName(fileName: string): string | null {
|
|
58
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
59
|
+
const mimeMap: Record<string, string> = {
|
|
60
|
+
// 图片
|
|
61
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
62
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.svg': 'image/svg+xml',
|
|
63
|
+
// 文档
|
|
64
|
+
'.pdf': 'application/pdf', '.doc': 'application/msword',
|
|
65
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
66
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv',
|
|
67
|
+
// 表格
|
|
68
|
+
'.xls': 'application/vnd.ms-excel',
|
|
69
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
70
|
+
// 演示文稿
|
|
71
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
72
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
73
|
+
// 音频
|
|
74
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
|
|
75
|
+
// 视频
|
|
76
|
+
'.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
77
|
+
// 压缩包
|
|
78
|
+
'.zip': 'application/zip', '.rar': 'application/x-rar-compressed',
|
|
79
|
+
'.tar': 'application/x-tar', '.gz': 'application/gzip', '.7z': 'application/x-7z-compressed',
|
|
80
|
+
// 代码
|
|
81
|
+
'.js': 'application/javascript', '.ts': 'text/typescript', '.py': 'text/x-python',
|
|
82
|
+
'.json': 'application/json', '.xml': 'application/xml', '.yaml': 'text/yaml', '.yml': 'text/yaml',
|
|
83
|
+
};
|
|
84
|
+
return mimeMap[ext] || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 从 MIME 类型推断扩展名 */
|
|
88
|
+
export function extensionForMime(mime: string): string {
|
|
89
|
+
const extMap: Record<string, string> = {
|
|
90
|
+
'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
|
|
91
|
+
'image/webp': '.webp', 'image/bmp': '.bmp',
|
|
92
|
+
'application/pdf': '.pdf',
|
|
93
|
+
'application/msword': '.doc',
|
|
94
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
|
95
|
+
'text/plain': '.txt', 'text/markdown': '.md', 'text/csv': '.csv',
|
|
96
|
+
'application/vnd.ms-excel': '.xls',
|
|
97
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
|
|
98
|
+
'application/vnd.ms-powerpoint': '.ppt',
|
|
99
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
|
|
100
|
+
'audio/mpeg': '.mp3', 'audio/wav': '.wav', 'audio/ogg': '.ogg',
|
|
101
|
+
'video/mp4': '.mp4', 'video/x-msvideo': '.avi',
|
|
102
|
+
'application/zip': '.zip', 'application/gzip': '.gz',
|
|
103
|
+
'application/json': '.json', 'application/xml': '.xml',
|
|
104
|
+
};
|
|
105
|
+
return extMap[mime] || '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ================================================================
|
|
109
|
+
// 媒体分类
|
|
110
|
+
// ================================================================
|
|
111
|
+
|
|
112
|
+
/** 根据 MIME 类型 + 文件名判断媒体分类 */
|
|
113
|
+
export function categorizeMedia(mimeType: string, fileName?: string): MediaCategory {
|
|
114
|
+
// 图片
|
|
115
|
+
if (mimeType.startsWith('image/')) return 'image';
|
|
116
|
+
// 音频
|
|
117
|
+
if (mimeType.startsWith('audio/')) return 'audio';
|
|
118
|
+
// 视频
|
|
119
|
+
if (mimeType.startsWith('video/')) return 'video';
|
|
120
|
+
|
|
121
|
+
const ext = fileName ? path.extname(fileName).toLowerCase() : '';
|
|
122
|
+
|
|
123
|
+
// 压缩包
|
|
124
|
+
if (['.zip', '.rar', '.tar', '.gz', '.7z', '.bz2', '.xz'].includes(ext)) return 'archive';
|
|
125
|
+
// 表格
|
|
126
|
+
if (['.xls', '.xlsx', '.csv', '.tsv', '.ods'].includes(ext)) return 'spreadsheet';
|
|
127
|
+
// 演示文稿
|
|
128
|
+
if (['.ppt', '.pptx', '.odp', '.key'].includes(ext)) return 'presentation';
|
|
129
|
+
// 文档
|
|
130
|
+
if (['.pdf', '.doc', '.docx', '.odt', '.rtf', '.txt', '.md'].includes(ext)) return 'document';
|
|
131
|
+
// 纯文本
|
|
132
|
+
if (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml') {
|
|
133
|
+
return 'text';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return 'other';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ================================================================
|
|
140
|
+
// MediaStore
|
|
141
|
+
// ================================================================
|
|
142
|
+
|
|
143
|
+
export interface MediaStoreOptions {
|
|
144
|
+
/** 存储根目录(默认使用数据目录下的 media/inbound) */
|
|
145
|
+
rootDir?: string;
|
|
146
|
+
/** 单文件最大字节数(默认 20MB) */
|
|
147
|
+
maxBytes?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class MediaStore {
|
|
151
|
+
private readonly rootDir: string;
|
|
152
|
+
private readonly maxBytes: number;
|
|
153
|
+
|
|
154
|
+
constructor(options?: MediaStoreOptions) {
|
|
155
|
+
this.maxBytes = options?.maxBytes ?? 20 * 1024 * 1024;
|
|
156
|
+
|
|
157
|
+
if (options?.rootDir) {
|
|
158
|
+
this.rootDir = options.rootDir;
|
|
159
|
+
} else {
|
|
160
|
+
// 默认: ~/.imtoagent/media/inbound/
|
|
161
|
+
this.rootDir = path.join(getDataDir(), 'media', 'inbound');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fs.mkdirSync(this.rootDir, { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 保存 buffer 到本地存储
|
|
169
|
+
*
|
|
170
|
+
* 策略(参考 OpenClaw):
|
|
171
|
+
* 1. sniff buffer 获取真实 MIME
|
|
172
|
+
* 2. 如果 sniff 失败,用 fileName 推断
|
|
173
|
+
* 3. 如果 MIME 是通用容器(octet-stream),用 fileName 覆盖
|
|
174
|
+
* 4. 文件名保留原始扩展名,前缀加时间戳防冲突
|
|
175
|
+
*/
|
|
176
|
+
save(
|
|
177
|
+
buffer: Buffer,
|
|
178
|
+
mimeType: string | undefined,
|
|
179
|
+
originalFileName?: string,
|
|
180
|
+
source?: string
|
|
181
|
+
): MediaEntry {
|
|
182
|
+
// 大小检查
|
|
183
|
+
if (buffer.length > this.maxBytes) {
|
|
184
|
+
throw new Error(`Media exceeds size limit: ${buffer.length} > ${this.maxBytes} bytes`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// MIME 推断优先级: sniff > provided > fileName-derived
|
|
188
|
+
const sniffedMime = sniffMimeFromBuffer(buffer);
|
|
189
|
+
const fileNameMime = originalFileName ? mimeFromFileName(originalFileName) : null;
|
|
190
|
+
|
|
191
|
+
// 通用容器 MIME 不够具体,优先用 sniff 或文件名推断
|
|
192
|
+
const isGeneric = (mime?: string) => !mime || mime === 'application/octet-stream';
|
|
193
|
+
|
|
194
|
+
let finalMime = sniffedMime || mimeType || fileNameMime || 'application/octet-stream';
|
|
195
|
+
|
|
196
|
+
// 如果 sniff 到了具体类型,优先使用
|
|
197
|
+
if (sniffedMime && !isGeneric(sniffedMime)) {
|
|
198
|
+
finalMime = sniffedMime;
|
|
199
|
+
} else if (isGeneric(mimeType) && fileNameMime) {
|
|
200
|
+
finalMime = fileNameMime;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 构造文件名(保留原始扩展名)
|
|
204
|
+
const localFileName = this.buildFileName(originalFileName, finalMime);
|
|
205
|
+
|
|
206
|
+
// 写入磁盘
|
|
207
|
+
const localPath = path.join(this.rootDir, localFileName);
|
|
208
|
+
fs.writeFileSync(localPath, buffer);
|
|
209
|
+
|
|
210
|
+
const category = categorizeMedia(finalMime, originalFileName);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
localPath,
|
|
214
|
+
mimeType: finalMime,
|
|
215
|
+
fileName: originalFileName || localFileName,
|
|
216
|
+
category,
|
|
217
|
+
sizeBytes: buffer.length,
|
|
218
|
+
source,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 清理过期文件(超过 maxAge 毫秒的文件) */
|
|
223
|
+
cleanup(maxAgeMs: number = 24 * 60 * 60 * 1000): number {
|
|
224
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
225
|
+
let count = 0;
|
|
226
|
+
|
|
227
|
+
const files = fs.readdirSync(this.rootDir);
|
|
228
|
+
for (const file of files) {
|
|
229
|
+
const filePath = path.join(this.rootDir, file);
|
|
230
|
+
try {
|
|
231
|
+
const stat = fs.statSync(filePath);
|
|
232
|
+
if (stat.mtimeMs < cutoff) {
|
|
233
|
+
fs.unlinkSync(filePath);
|
|
234
|
+
count++;
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// 忽略删除失败
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (count > 0) {
|
|
242
|
+
console.log(`[MediaStore] Cleaned up ${count} expired files`);
|
|
243
|
+
}
|
|
244
|
+
return count;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 获取存储根目录 */
|
|
248
|
+
getRootDir(): string {
|
|
249
|
+
return this.rootDir;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ================================================================
|
|
253
|
+
// Private
|
|
254
|
+
// ================================================================
|
|
255
|
+
|
|
256
|
+
private buildFileName(originalFileName?: string, mimeType?: string): string {
|
|
257
|
+
const timestamp = Date.now();
|
|
258
|
+
const random = Math.random().toString(36).slice(2, 10);
|
|
259
|
+
|
|
260
|
+
if (originalFileName) {
|
|
261
|
+
// 保留原始文件名 + 时间戳防冲突
|
|
262
|
+
const ext = path.extname(originalFileName);
|
|
263
|
+
const base = path.basename(originalFileName, ext);
|
|
264
|
+
// 截断过长的文件名
|
|
265
|
+
const truncatedBase = base.length > 50 ? base.slice(0, 50) : base;
|
|
266
|
+
return `${timestamp}-${random}-${truncatedBase}${ext}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 没有原始文件名,用 MIME 推断扩展名
|
|
270
|
+
const ext = mimeType ? extensionForMime(mimeType) : '';
|
|
271
|
+
return `${timestamp}-${random}${ext}`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// InboundMediaResolver — 媒体下载 → 存储 → Agent 提示 的编排层
|
|
3
|
+
// ================================================================
|
|
4
|
+
// 职责:
|
|
5
|
+
// 1. 使用 InboundMediaAdapter 下载原始 buffer
|
|
6
|
+
// 2. 使用 MediaStore 存储并获取标准化 MediaEntry
|
|
7
|
+
// 3. 构建 MessageAttachment 列表
|
|
8
|
+
// 4. 生成给 Agent 的差异化提示(按文件类型)
|
|
9
|
+
//
|
|
10
|
+
// 参考 OpenClaw 的 parseMessageWithAttachments 设计
|
|
11
|
+
// ================================================================
|
|
12
|
+
|
|
13
|
+
import type { InboundMediaAdapter, DownloadedMedia, MediaResourceType } from './types';
|
|
14
|
+
import type { MediaEntry } from './types';
|
|
15
|
+
import type { MessageAttachment } from '../core/types';
|
|
16
|
+
import { MediaStore } from './media-store';
|
|
17
|
+
|
|
18
|
+
/** 单个附件的下载请求描述 */
|
|
19
|
+
export interface MediaDownloadRequest {
|
|
20
|
+
/** 消息 ID */
|
|
21
|
+
messageId: string;
|
|
22
|
+
/** 平台资源 key(image_key / file_key 等) */
|
|
23
|
+
resourceKey: string;
|
|
24
|
+
/** 资源类型 */
|
|
25
|
+
type: MediaResourceType;
|
|
26
|
+
/** 原始文件名(如有) */
|
|
27
|
+
fileName?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolver 返回结果 */
|
|
31
|
+
export interface ResolveMediaResult {
|
|
32
|
+
/** 标准化附件列表(供 Agent 使用) */
|
|
33
|
+
attachments: MessageAttachment[];
|
|
34
|
+
/** 存储的媒体条目(含分类信息) */
|
|
35
|
+
entries: MediaEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 按媒体分类生成差异化的 Agent 提示
|
|
40
|
+
*
|
|
41
|
+
* 相比旧版 buildAttachmentHint,这里根据文件类型给 Agent 不同的操作引导:
|
|
42
|
+
* - PDF → 提示用 PDF 工具
|
|
43
|
+
* - Excel → 提示用表格解析
|
|
44
|
+
* - 纯文本 → 提示可直接 read
|
|
45
|
+
* - 压缩包 → 提示先解压
|
|
46
|
+
* - 未知二进制 → 提示用 file 命令或 sniff
|
|
47
|
+
*/
|
|
48
|
+
function buildHintForCategory(entry: MediaEntry): string {
|
|
49
|
+
const { localPath, mimeType, category, fileName } = entry;
|
|
50
|
+
|
|
51
|
+
switch (category) {
|
|
52
|
+
case 'image':
|
|
53
|
+
return `图片已保存到本地,路径: \`${localPath}\`,格式: ${mimeType},可使用图片查看工具打开`;
|
|
54
|
+
|
|
55
|
+
case 'audio':
|
|
56
|
+
return `音频文件路径: \`${localPath}\`,格式: ${mimeType},可用语音识别工具处理`;
|
|
57
|
+
|
|
58
|
+
case 'video':
|
|
59
|
+
return `视频文件路径: \`${localPath}\`,格式: ${mimeType}`;
|
|
60
|
+
|
|
61
|
+
case 'document':
|
|
62
|
+
return `文档文件路径: \`${localPath}\`,类型: ${mimeType},可直接读取(如果是文本/PDF)或用相应工具处理`;
|
|
63
|
+
|
|
64
|
+
case 'text':
|
|
65
|
+
return `文本文件路径: \`${localPath}\`,可直接用文件读取工具读取内容`;
|
|
66
|
+
|
|
67
|
+
case 'spreadsheet':
|
|
68
|
+
return `表格文件路径: \`${localPath}\`,类型: ${mimeType},可用表格解析工具(如 Python pandas/openpyxl)处理`;
|
|
69
|
+
|
|
70
|
+
case 'presentation':
|
|
71
|
+
return `演示文稿路径: \`${localPath}\`,类型: ${mimeType}`;
|
|
72
|
+
|
|
73
|
+
case 'archive':
|
|
74
|
+
return `压缩文件路径: \`${localPath}\`,类型: ${mimeType},需要先解压再处理`;
|
|
75
|
+
|
|
76
|
+
default:
|
|
77
|
+
return `文件路径: \`${localPath}\`,格式: ${mimeType},可用文件工具分析或直接读取`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* InboundMediaResolver
|
|
83
|
+
*
|
|
84
|
+
* 编排 IM 适配器的下载 + MediaStore 的存储,产出标准 MessageAttachment
|
|
85
|
+
*/
|
|
86
|
+
export class InboundMediaResolver {
|
|
87
|
+
private readonly adapter: InboundMediaAdapter;
|
|
88
|
+
private readonly store: MediaStore;
|
|
89
|
+
|
|
90
|
+
constructor(adapter: InboundMediaAdapter, store?: MediaStore) {
|
|
91
|
+
this.adapter = adapter;
|
|
92
|
+
this.store = store ?? new MediaStore();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 解析单个媒体附件
|
|
97
|
+
*/
|
|
98
|
+
async resolveOne(request: MediaDownloadRequest): Promise<{
|
|
99
|
+
attachment: MessageAttachment;
|
|
100
|
+
entry: MediaEntry;
|
|
101
|
+
} | null> {
|
|
102
|
+
try {
|
|
103
|
+
// 1. 通过适配器下载
|
|
104
|
+
const downloaded = await this.adapter.downloadResource(
|
|
105
|
+
request.messageId,
|
|
106
|
+
request.resourceKey,
|
|
107
|
+
request.type,
|
|
108
|
+
request.fileName
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (!downloaded) {
|
|
112
|
+
console.log(`[${this.adapter.platform}] 下载资源失败: ${request.resourceKey}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. 存入 MediaStore
|
|
117
|
+
const entry = this.store.save(
|
|
118
|
+
downloaded.buffer,
|
|
119
|
+
downloaded.contentType,
|
|
120
|
+
downloaded.fileName || request.fileName,
|
|
121
|
+
this.adapter.platform
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// 3. 构建 MessageAttachment(向后兼容旧格式)
|
|
125
|
+
const attachment = this.buildAttachment(entry, downloaded, request);
|
|
126
|
+
|
|
127
|
+
return { attachment, entry };
|
|
128
|
+
} catch (e: any) {
|
|
129
|
+
console.error(`[${this.adapter.platform}] 解析媒体异常: ${e.message}`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 批量解析多个媒体附件
|
|
136
|
+
*/
|
|
137
|
+
async resolveAll(requests: MediaDownloadRequest[]): Promise<ResolveMediaResult> {
|
|
138
|
+
const results = await Promise.all(
|
|
139
|
+
requests.map(req => this.resolveOne(req))
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const attachments: MessageAttachment[] = [];
|
|
143
|
+
const entries: MediaEntry[] = [];
|
|
144
|
+
|
|
145
|
+
for (const r of results) {
|
|
146
|
+
if (r) {
|
|
147
|
+
attachments.push(r.attachment);
|
|
148
|
+
entries.push(r.entry);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { attachments, entries };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ================================================================
|
|
156
|
+
// Private
|
|
157
|
+
// ================================================================
|
|
158
|
+
|
|
159
|
+
private buildAttachment(
|
|
160
|
+
entry: MediaEntry,
|
|
161
|
+
downloaded: DownloadedMedia,
|
|
162
|
+
request: MediaDownloadRequest
|
|
163
|
+
): MessageAttachment {
|
|
164
|
+
const type = entry.category === 'image' ? 'image'
|
|
165
|
+
: entry.category === 'audio' ? 'audio'
|
|
166
|
+
: 'file';
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
type,
|
|
170
|
+
localPath: entry.localPath,
|
|
171
|
+
filename: entry.fileName,
|
|
172
|
+
sourceKey: downloaded.sourceKey || request.resourceKey,
|
|
173
|
+
mimeType: entry.mimeType,
|
|
174
|
+
durationMs: undefined, // 音频需要的话由调用方补充
|
|
175
|
+
hint: buildHintForCategory(entry), // 预计算的差异化提示
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|