mirror of
https://github.com/labring/FastGPT.git
synced 2026-04-26 02:07:28 +08:00
feishu stream (#6629)
This commit is contained in:
@@ -0,0 +1,539 @@
|
|||||||
|
# 微信个人号文件接收方案设计
|
||||||
|
|
||||||
|
> 文件路径:`packages/service/support/outLink/wechat/`
|
||||||
|
|
||||||
|
## 一、背景与现状
|
||||||
|
|
||||||
|
### 当前架构
|
||||||
|
|
||||||
|
```
|
||||||
|
ILink API (长轮询) → mq.ts → messageParser.ts → outlinkInvokeChat()
|
||||||
|
```
|
||||||
|
|
||||||
|
- **消息拉取**:`ILinkClient.getUpdates()` 长轮询,返回 `WeixinMessage[]`
|
||||||
|
- **消息解析**:`messageParser.ts` 只处理 `text`(type=1)和 `voice`(type=3)
|
||||||
|
- **查询构造**:仅传递 `{ text: { content: group.text } }` 给 workflow
|
||||||
|
- **文件支持**:❌ 完全缺失
|
||||||
|
|
||||||
|
### FastGPT 已有基础设施
|
||||||
|
|
||||||
|
| 能力 | 实现位置 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 图片存储 | `file/image/controller.ts` → `uploadMongoImg()` | base64 存 MongoDB,返回内部 URL |
|
||||||
|
| 文件存储 | `file/gridfs/` (BucketNameEnum.chat) | GridFS 流式存储 |
|
||||||
|
| Chat 输入类型 | `global/core/chat/type.ts` → `UserChatItemValueItemType` | 支持 `text` + `file(image/file)` |
|
||||||
|
|
||||||
|
### `UserChatItemValueItemType` 结构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UserChatItemValueItemType = {
|
||||||
|
text?: { content: string };
|
||||||
|
file?: {
|
||||||
|
type: 'image' | 'file'; // ChatFileTypeEnum
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
url: string; // 内部访问 URL
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标
|
||||||
|
|
||||||
|
支持微信用户通过 iLink 渠道向 FastGPT 发送:
|
||||||
|
|
||||||
|
| 类型 | 来源 | 处理方式 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 图片 | `item_list[].type = 2` | 下载 → 存 MongoImage → 内部 URL |
|
||||||
|
| 文件/附件 | `item_list[].type = 5` | 下载 → 存 GridFS(chat) → 内部 URL |
|
||||||
|
| 视频 | `item_list[].type = 4` | 下载 → 存 GridFS(chat) → 内部 URL(可选)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、S3 存储方案
|
||||||
|
|
||||||
|
### 核心选择:`S3ChatSource.uploadChatFileByBuffer()`
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|----|
|
||||||
|
| 存储路径 | `chat/{appId}/{uId}/{chatId}/{filename}` |
|
||||||
|
| 存储桶 | Private Bucket(私有,需鉴权访问) |
|
||||||
|
| TTL | 1 小时(由 `MongoS3TTL` 自动清理) |
|
||||||
|
| 访问 URL | `createGetChatFileURL({ key, expiredHours: 24 })` 生成预签名 URL |
|
||||||
|
|
||||||
|
### 调用关系
|
||||||
|
|
||||||
|
```
|
||||||
|
getS3ChatSource()
|
||||||
|
└── uploadChatFileByBuffer({ appId, chatId, uId, filename, buffer, contentType })
|
||||||
|
└── getFileS3Key.chat(...) → key = "chat/{appId}/{uId}/{chatId}/{filename}"
|
||||||
|
└── uploadFileByBuffer({ key, buffer, contentType })
|
||||||
|
├── MongoS3TTL.create({ expiredTime: +1h }) // 文件 1 小时后自动清理
|
||||||
|
├── client.uploadObject(...)
|
||||||
|
└── returns { key, accessUrl } // accessUrl 预签名 2h
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL 传入 workflow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// UserChatItemValueItemType.file
|
||||||
|
{
|
||||||
|
type: 'image' | 'file', // ChatFileTypeEnum
|
||||||
|
name: string, // 原始文件名
|
||||||
|
key: string, // S3 object key,用于后续删除/引用
|
||||||
|
url: string // 预签名 URL(accessUrl,2h 内有效)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 由于 workflow 在文件上传后立即执行,2 小时访问窗口完全够用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、ILink API 消息结构(推断)
|
||||||
|
|
||||||
|
基于 iLink WeChat Bot API 常见规范,图片/文件消息的 `item_list` 元素结构:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 图片消息项(type = 2)
|
||||||
|
type ImageMessageItem = {
|
||||||
|
type: 2;
|
||||||
|
image_item?: {
|
||||||
|
cdn_url: string; // 图片 CDN 直链(有效期有限)
|
||||||
|
aes_key?: string; // 加密 key(若内容加密)
|
||||||
|
file_size?: number; // 字节数
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件消息项(type = 5)
|
||||||
|
type FileMessageItem = {
|
||||||
|
type: 5;
|
||||||
|
file_item?: {
|
||||||
|
file_id?: string; // 文件 ID(用于获取下载链接)
|
||||||
|
file_name: string; // 原始文件名
|
||||||
|
cdn_url?: string; // 文件 CDN 直链(有效期有限)
|
||||||
|
file_size?: number;
|
||||||
|
file_ext?: string; // 扩展名 e.g. "pdf"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:实际字段需与 iLink API 文档对齐。本方案设计为可扩展结构,字段名通过常量隔离,便于修正。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、方案设计
|
||||||
|
|
||||||
|
### 4.1 整体流程
|
||||||
|
|
||||||
|
```
|
||||||
|
ILink getUpdates()
|
||||||
|
↓
|
||||||
|
MessageItem[]
|
||||||
|
↓ (messageParser)
|
||||||
|
ParsedMessageGroup {
|
||||||
|
userId, text, contextToken, msgIds,
|
||||||
|
files: [{ type, tempUrl, name }] ← 新增
|
||||||
|
}
|
||||||
|
↓ (mq.ts - processUserGroup)
|
||||||
|
downloadAndStoreFiles() ← 新增
|
||||||
|
↓
|
||||||
|
UserChatItemValueItemType[] = [
|
||||||
|
{ text: { content: "..." } },
|
||||||
|
{ file: { type: "image", url: "http://..." } },
|
||||||
|
{ file: { type: "file", url: "http://...", name: "report.pdf" } }
|
||||||
|
]
|
||||||
|
↓
|
||||||
|
outlinkInvokeChat({ query: [...] })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 变更范围
|
||||||
|
|
||||||
|
共涉及 **4 个文件**:
|
||||||
|
|
||||||
|
| 文件 | 变更类型 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `ilinkClient.ts` | 扩展类型 + 新增方法 | 添加图片/文件 Item 类型;添加 `downloadMedia()` |
|
||||||
|
| `messageParser.ts` | 扩展逻辑 + 类型 | 解析图片/文件 item,收集 `tempUrl` |
|
||||||
|
| `wechat/fileHandler.ts` | **新建** | 下载 → 存储 → 返回内部 URL |
|
||||||
|
| `mq.ts` | 扩展逻辑 | 调用 fileHandler,拼装 query 数组 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、详细设计
|
||||||
|
|
||||||
|
### 5.1 `ilinkClient.ts` — 扩展类型与下载能力
|
||||||
|
|
||||||
|
#### 新增类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 MessageItem 基础上扩展
|
||||||
|
export type MessageItem = {
|
||||||
|
type: number;
|
||||||
|
text_item?: { text: string };
|
||||||
|
voice_item?: { text: string };
|
||||||
|
ref_msg?: { title?: string };
|
||||||
|
// 新增
|
||||||
|
image_item?: {
|
||||||
|
cdn_url?: string;
|
||||||
|
file_size?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
file_item?: {
|
||||||
|
file_id?: string;
|
||||||
|
file_name?: string;
|
||||||
|
cdn_url?: string;
|
||||||
|
file_size?: number;
|
||||||
|
file_ext?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 媒体下载结果
|
||||||
|
export type MediaDownloadResult = {
|
||||||
|
buffer: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
fileName?: string;
|
||||||
|
fileSize: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新增方法 `downloadMedia()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 最大允许下载文件大小(20MB)
|
||||||
|
const MAX_MEDIA_SIZE = 20 * 1024 * 1024;
|
||||||
|
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
async downloadMedia(url: string, fileName?: string): Promise<MediaDownloadResult> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: this.buildHeaders(), // 携带 token 鉴权
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Media download failed: HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const contentLength = Number(res.headers.get('content-length') || 0);
|
||||||
|
if (contentLength > MAX_MEDIA_SIZE) {
|
||||||
|
throw new Error(`File too large: ${contentLength} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
if (buffer.length > MAX_MEDIA_SIZE) {
|
||||||
|
throw new Error(`File too large: ${buffer.length} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') || 'application/octet-stream';
|
||||||
|
const mimeType = contentType.split(';')[0].trim();
|
||||||
|
|
||||||
|
return { buffer, mimeType, fileName, fileSize: buffer.length };
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 `messageParser.ts` — 扩展解析逻辑
|
||||||
|
|
||||||
|
#### 新增常量与类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const MSG_ITEM_IMAGE = 2;
|
||||||
|
const MSG_ITEM_FILE = 5;
|
||||||
|
|
||||||
|
// 解析出的媒体引用(尚未下载,只存 URL 和元信息)
|
||||||
|
export type ParsedMediaRef = {
|
||||||
|
type: 'image' | 'file';
|
||||||
|
tempUrl: string; // iLink CDN URL(有效期有限)
|
||||||
|
name?: string; // 原始文件名
|
||||||
|
};
|
||||||
|
|
||||||
|
// 扩展 ParsedMessageGroup
|
||||||
|
export type ParsedMessageGroup = {
|
||||||
|
userId: string;
|
||||||
|
text: string;
|
||||||
|
contextToken: string;
|
||||||
|
msgIds: string[];
|
||||||
|
mediaRefs: ParsedMediaRef[]; // 新增:待下载的媒体引用
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新 `extractTextFromItem` 和 `groupMessagesByUser`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// extractTextFromItem 保持不变,仍只返回文本
|
||||||
|
|
||||||
|
// 新增:从 item 提取媒体引用
|
||||||
|
export function extractMediaRefFromItem(
|
||||||
|
item: NonNullable<WeixinMessage['item_list']>[number]
|
||||||
|
): ParsedMediaRef | null {
|
||||||
|
if (item.type === MSG_ITEM_IMAGE && item.image_item?.cdn_url) {
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
tempUrl: item.image_item.cdn_url,
|
||||||
|
name: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.type === MSG_ITEM_FILE && item.file_item?.cdn_url) {
|
||||||
|
return {
|
||||||
|
type: 'file',
|
||||||
|
tempUrl: item.file_item.cdn_url,
|
||||||
|
name: item.file_item.file_name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `groupMessagesByUser` 中:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 处理逻辑变更:消息中若包含文件但无文本,也纳入分组(原逻辑会跳过无文本消息)
|
||||||
|
for (const msg of msgs) {
|
||||||
|
if (msg.message_type !== MSG_TYPE_USER) continue;
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
const mediaRefs: ParsedMediaRef[] = [];
|
||||||
|
|
||||||
|
for (const item of msg.item_list ?? []) {
|
||||||
|
if (!text) {
|
||||||
|
const t = extractTextFromItem(item);
|
||||||
|
if (t) text = t;
|
||||||
|
}
|
||||||
|
const ref = extractMediaRefFromItem(item);
|
||||||
|
if (ref) mediaRefs.push(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键变更:无文本但有媒体时也处理
|
||||||
|
if (!text && mediaRefs.length === 0) continue;
|
||||||
|
|
||||||
|
// ... 合并到分组(mediaRefs 追加合并)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 `wechat/fileHandler.ts` — 新建文件处理模块
|
||||||
|
|
||||||
|
图片和文件**统一存入 S3 私有桶**(`chat` source),通过 `S3ChatSource.uploadChatFileByBuffer()` 写入,
|
||||||
|
上传完成后用 `createGetChatFileURL()` 生成预签名 URL 传给 workflow。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { MediaDownloadResult } from './ilinkClient';
|
||||||
|
import { getS3ChatSource } from '../../../common/s3/sources/chat';
|
||||||
|
import type { UserChatItemFileItemType } from '@fastgpt/global/core/chat/type';
|
||||||
|
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
|
||||||
|
import mime from 'mime-types';
|
||||||
|
|
||||||
|
export type MediaStoreResult = UserChatItemFileItemType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一入口:下载 iLink CDN 文件 → 存入 S3(chat) → 返回预签名 URL
|
||||||
|
*
|
||||||
|
* S3 key: chat/{appId}/{uId}/{chatId}/{filename}
|
||||||
|
* TTL: 文件 1h(MongoS3TTL 自动清理);预签名 URL 24h
|
||||||
|
*/
|
||||||
|
export async function downloadAndStoreMedia(params: {
|
||||||
|
type: 'image' | 'file';
|
||||||
|
tempUrl: string;
|
||||||
|
name?: string;
|
||||||
|
appId: string;
|
||||||
|
chatId: string;
|
||||||
|
uId: string;
|
||||||
|
downloadFn: (url: string, name?: string) => Promise<MediaDownloadResult>;
|
||||||
|
}): Promise<MediaStoreResult | null> {
|
||||||
|
const { type, tempUrl, name, appId, chatId, uId, downloadFn } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const media = await downloadFn(tempUrl, name);
|
||||||
|
|
||||||
|
const ext = mime.extension(media.mimeType) || 'bin';
|
||||||
|
const filename = media.fileName || `wechat_${type}_${Date.now()}.${ext}`;
|
||||||
|
|
||||||
|
const chatSource = getS3ChatSource();
|
||||||
|
|
||||||
|
// 上传到 S3,返回 { key, accessUrl }
|
||||||
|
// accessUrl 是 createExternalUrl({ key, expiredHours: 2 }) 的预签名 URL
|
||||||
|
const { key, accessUrl } = await chatSource.uploadChatFileByBuffer({
|
||||||
|
appId,
|
||||||
|
chatId,
|
||||||
|
uId,
|
||||||
|
filename,
|
||||||
|
buffer: media.buffer,
|
||||||
|
contentType: media.mimeType
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成更长有效期的预签名 URL 供 workflow 访问(24h)
|
||||||
|
const url = await chatSource.createGetChatFileURL({
|
||||||
|
key,
|
||||||
|
expiredHours: 24,
|
||||||
|
external: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: type === 'image' ? ChatFileTypeEnum.image : ChatFileTypeEnum.file,
|
||||||
|
name: filename,
|
||||||
|
key,
|
||||||
|
url
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// 单个文件失败不中断整体流程,上层记录日志
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 `mq.ts` — 扩展 processUserGroup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function processUserGroup(
|
||||||
|
outLink: OutLinkSchema<WechatAppType>,
|
||||||
|
group: ParsedMessageGroup
|
||||||
|
): Promise<void> {
|
||||||
|
const app = outLink.app;
|
||||||
|
const chatId = `wechat_${outLink.shareId}_${group.userId}`;
|
||||||
|
const client = new ILinkClient(app.baseUrl, app.token);
|
||||||
|
|
||||||
|
// 1. 并发下载所有媒体文件(最多 5 个,防止超载)
|
||||||
|
const maxFiles = 5;
|
||||||
|
const mediaRefs = group.mediaRefs.slice(0, maxFiles);
|
||||||
|
|
||||||
|
const fileResults = await Promise.allSettled(
|
||||||
|
mediaRefs.map((ref) =>
|
||||||
|
downloadAndStoreMedia({
|
||||||
|
type: ref.type,
|
||||||
|
tempUrl: ref.tempUrl,
|
||||||
|
name: ref.name,
|
||||||
|
teamId: outLink.teamId,
|
||||||
|
shareId: outLink.shareId,
|
||||||
|
downloadFn: (url, name) => client.downloadMedia(url, name)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. 构造 UserChatItemValueItemType[]
|
||||||
|
const query: UserChatItemValueItemType[] = [];
|
||||||
|
|
||||||
|
// 文本(如有)
|
||||||
|
if (group.text) {
|
||||||
|
query.push({ text: { content: group.text } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功上传的文件
|
||||||
|
for (const result of fileResults) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
query.push({ file: result.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若 query 为空(全部失败),回复提示
|
||||||
|
if (query.length === 0) {
|
||||||
|
await client.sendMessage({
|
||||||
|
to_user_id: group.userId,
|
||||||
|
text: '文件处理失败,请重试。',
|
||||||
|
context_token: group.contextToken
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await outlinkInvokeChat({
|
||||||
|
outLinkConfig: outLink,
|
||||||
|
chatId,
|
||||||
|
query,
|
||||||
|
messageId: group.msgIds[group.msgIds.length - 1],
|
||||||
|
chatUserId: group.userId,
|
||||||
|
replyCallback: async (replyContent: string) => {
|
||||||
|
await client.sendMessage({
|
||||||
|
to_user_id: group.userId,
|
||||||
|
text: replyContent,
|
||||||
|
context_token: group.contextToken
|
||||||
|
});
|
||||||
|
return { errcode: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 错误处理(原有逻辑不变)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、文件大小与类型限制
|
||||||
|
|
||||||
|
| 配置项 | 建议值 | 说明 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 单文件最大 | 20 MB | WeChat 个人号文件上限通常 ≤ 20MB |
|
||||||
|
| 单消息最多文件 | 5 个 | 防止并发过多 |
|
||||||
|
| 允许图片 MIME | `image/*` | 统一走图片存储 |
|
||||||
|
| 允许文件类型 | 不限制(由 workflow 处理) | GridFS 存储,workflow 层可再过滤 |
|
||||||
|
| 下载超时 | 30s | 防止 CDN 链接失效导致卡死 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、错误处理策略
|
||||||
|
|
||||||
|
| 场景 | 处理方式 |
|
||||||
|
|------|----------|
|
||||||
|
| CDN URL 过期 / 无效 | 跳过该文件,继续处理文本;文件全部失败时回复提示 |
|
||||||
|
| 文件过大 | 抛出错误,日志记录,回复"文件过大"提示 |
|
||||||
|
| 图片 MIME 不合法 | `uploadMongoImg` 内部校验,返回 null,跳过 |
|
||||||
|
| 单文件下载超时 | AbortController 控制,记录日志后 null |
|
||||||
|
| 全部文件失败但有文本 | 仍走 workflow,文件部分缺失 |
|
||||||
|
| 全部内容失败 | 发送固定错误消息给用户 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、待确认事项
|
||||||
|
|
||||||
|
在开始编码前,需要确认以下 iLink API 细节:
|
||||||
|
|
||||||
|
1. **图片消息 item type 值**:是否为 `2`?字段名是否为 `image_item`?
|
||||||
|
2. **文件消息 item type 值**:是否为 `5`?字段名是否为 `file_item`?
|
||||||
|
3. **CDN URL 有效期**:URL 可以直接 fetch 还是需要携带 token 鉴权?
|
||||||
|
4. **是否有专用下载 API**:是否有类似 `/ilink/bot/getmedia?file_id=xxx` 的接口?
|
||||||
|
5. **文件大小限制**:iLink 对单消息文件大小有何限制?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、新增文件清单
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/service/support/outLink/wechat/
|
||||||
|
├── ilinkClient.ts 修改:扩展 MessageItem 类型,新增 downloadMedia()
|
||||||
|
├── messageParser.ts 修改:新增 extractMediaRefFromItem(),扩展 ParsedMessageGroup
|
||||||
|
├── fileHandler.ts 新建:下载 + 存储媒体文件
|
||||||
|
├── mq.ts 修改:调用 fileHandler,拼装 query
|
||||||
|
└── type.ts 保持不变
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、测试用例设计
|
||||||
|
|
||||||
|
| 用例 | 输入 | 期望输出 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 纯文字消息 | text item | 不变,query=[text] |
|
||||||
|
| 图片消息 | image item + 无文本 | query=[file(image)] |
|
||||||
|
| 图片 + 文字 | text + image | query=[text, file(image)] |
|
||||||
|
| 文件附件 | file item | query=[file(file)] |
|
||||||
|
| 多文件 | 3 个图片 | query=[file, file, file] |
|
||||||
|
| 超大文件 | 文件 > 20MB | 跳过,回复提示 |
|
||||||
|
| CDN 失效 | 404 URL | 跳过,文本正常走 |
|
||||||
|
| 全部文件失败 + 无文本 | 空 | 发送错误提示 |
|
||||||
@@ -6,6 +6,7 @@ description: 'FastGPT V4.14.10 更新说明'
|
|||||||
|
|
||||||
## 🚀 新增内容
|
## 🚀 新增内容
|
||||||
|
|
||||||
|
1. 飞书发布渠道,支持流输出。
|
||||||
|
|
||||||
## ⚙️ 优化
|
## ⚙️ 优化
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"document/content/docs/self-host/upgrading/4-14/4140.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/4-14/4140.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
"document/content/docs/self-host/upgrading/4-14/4141.en.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/4-14/4141.en.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
"document/content/docs/self-host/upgrading/4-14/4141.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/4-14/4141.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
|
"document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-03-24T09:59:30+08:00",
|
||||||
"document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
"document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
"document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
@@ -239,7 +240,7 @@
|
|||||||
"document/content/docs/self-host/upgrading/4-14/41481.en.mdx": "2026-03-09T12:02:02+08:00",
|
"document/content/docs/self-host/upgrading/4-14/41481.en.mdx": "2026-03-09T12:02:02+08:00",
|
||||||
"document/content/docs/self-host/upgrading/4-14/41481.mdx": "2026-03-09T17:39:53+08:00",
|
"document/content/docs/self-host/upgrading/4-14/41481.mdx": "2026-03-09T17:39:53+08:00",
|
||||||
"document/content/docs/self-host/upgrading/4-14/4149.en.mdx": "2026-03-23T12:17:04+08:00",
|
"document/content/docs/self-host/upgrading/4-14/4149.en.mdx": "2026-03-23T12:17:04+08:00",
|
||||||
"document/content/docs/self-host/upgrading/4-14/4149.mdx": "2026-03-23T23:36:27+08:00",
|
"document/content/docs/self-host/upgrading/4-14/4149.mdx": "2026-03-23T23:37:12+08:00",
|
||||||
"document/content/docs/self-host/upgrading/outdated/40.en.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/outdated/40.en.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
"document/content/docs/self-host/upgrading/outdated/40.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/outdated/40.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
"document/content/docs/self-host/upgrading/outdated/41.en.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/outdated/41.en.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
@@ -381,7 +382,7 @@
|
|||||||
"document/content/docs/self-host/upgrading/upgrade-intruction.en.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/upgrade-intruction.en.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
"document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-03-03T17:39:47+08:00",
|
"document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-03-03T17:39:47+08:00",
|
||||||
"document/content/docs/toc.en.mdx": "2026-03-23T12:10:44+08:00",
|
"document/content/docs/toc.en.mdx": "2026-03-23T12:10:44+08:00",
|
||||||
"document/content/docs/toc.mdx": "2026-03-23T11:57:05+08:00",
|
"document/content/docs/toc.mdx": "2026-03-24T09:59:30+08:00",
|
||||||
"document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00",
|
"document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00",
|
||||||
"document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00",
|
"document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00",
|
||||||
"document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00",
|
"document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00",
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const getErrText = (err: any, def = ''): any => {
|
|||||||
return replaceSensitiveText(msg);
|
return replaceSensitiveText(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getErrResponse = (err: any): any => {
|
||||||
|
return err?.response?.data || err?.response || err;
|
||||||
|
};
|
||||||
|
|
||||||
export class UserError extends Error {
|
export class UserError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import _, { type AxiosRequestConfig } from 'axios';
|
import _, { type AxiosRequestConfig } from 'axios';
|
||||||
import { ProxyAgent } from 'proxy-agent';
|
import { ProxyAgent } from 'proxy-agent';
|
||||||
|
import { isDevEnv } from '@fastgpt/global/common/system/constants';
|
||||||
|
|
||||||
export function createProxyAxios(config?: AxiosRequestConfig) {
|
export function createProxyAxios(config?: AxiosRequestConfig) {
|
||||||
|
if (isDevEnv) {
|
||||||
|
return _.create(config);
|
||||||
|
}
|
||||||
const agent = new ProxyAgent();
|
const agent = new ProxyAgent();
|
||||||
|
|
||||||
return _.create({
|
return _.create({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { authOutLinkLimit } from './auth';
|
|||||||
import { addOutLinkUsage } from '../../../support/outLink/tools';
|
import { addOutLinkUsage } from '../../../support/outLink/tools';
|
||||||
import { getLogger, LogCategories } from '../../../common/logger';
|
import { getLogger, LogCategories } from '../../../common/logger';
|
||||||
import { appendRedisCache } from '../../../common/redis/cache';
|
import { appendRedisCache } from '../../../common/redis/cache';
|
||||||
import { getErrText } from '@fastgpt/global/common/error/utils';
|
import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils';
|
||||||
import { getUsageSourceByPublishChannel } from '@fastgpt/global/support/wallet/usage/tools';
|
import { getUsageSourceByPublishChannel } from '@fastgpt/global/support/wallet/usage/tools';
|
||||||
import { getChatSourceByPublishChannel } from '@fastgpt/global/core/chat/utils';
|
import { getChatSourceByPublishChannel } from '@fastgpt/global/core/chat/utils';
|
||||||
import { WORKFLOW_MAX_RUN_TIMES } from '../../../core/workflow/constants';
|
import { WORKFLOW_MAX_RUN_TIMES } from '../../../core/workflow/constants';
|
||||||
@@ -75,7 +75,10 @@ export type outLinkInvokeChatProps<T extends OutlinkAppType> = {
|
|||||||
res?: NextApiResponse;
|
res?: NextApiResponse;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
chatUserId: string;
|
chatUserId: string;
|
||||||
replyCallback?: (replyContent: string) => Promise<any>;
|
// Called once with complete response content (all channels except wecom)
|
||||||
|
onReply?: (replyContent: string) => Promise<void>;
|
||||||
|
// Called for each streaming chunk (feishu and other push channels)
|
||||||
|
onStreamChunk?: (text: string) => Promise<void>;
|
||||||
streamId?: string;
|
streamId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +94,8 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
|
|||||||
res,
|
res,
|
||||||
messageId,
|
messageId,
|
||||||
chatUserId,
|
chatUserId,
|
||||||
replyCallback,
|
onReply,
|
||||||
|
onStreamChunk,
|
||||||
streamId
|
streamId
|
||||||
}: outLinkInvokeChatProps<T>) {
|
}: outLinkInvokeChatProps<T>) {
|
||||||
const streamResKey = `${STREAM_CACHE_KEY_PREFIX}${streamId}`;
|
const streamResKey = `${STREAM_CACHE_KEY_PREFIX}${streamId}`;
|
||||||
@@ -112,7 +116,7 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
|
|||||||
const userQuestion = query.find((item) => item.text)?.text?.content || '';
|
const userQuestion = query.find((item) => item.text)?.text?.content || '';
|
||||||
if (RESET_CHAT_INPUT[userQuestion]) {
|
if (RESET_CHAT_INPUT[userQuestion]) {
|
||||||
await resetChat({ appId: outLinkConfig.appId, chatId });
|
await resetChat({ appId: outLinkConfig.appId, chatId });
|
||||||
await replyCallback?.(RESET_CHAT_REPLY);
|
await onReply?.(RESET_CHAT_REPLY);
|
||||||
if (streamId) {
|
if (streamId) {
|
||||||
await appendRedisCache(streamResKey, RESET_CHAT_REPLY, 60);
|
await appendRedisCache(streamResKey, RESET_CHAT_REPLY, 60);
|
||||||
await appendRedisCache(streamResKey, STREAM_END_FLAG, 60);
|
await appendRedisCache(streamResKey, STREAM_END_FLAG, 60);
|
||||||
@@ -144,7 +148,9 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
|
|||||||
ip: chatId
|
ip: chatId
|
||||||
});
|
});
|
||||||
|
|
||||||
const workflowStreamResponse = streamId
|
const enableStreaming = !!streamId || !!onStreamChunk;
|
||||||
|
|
||||||
|
const workflowStreamResponse = enableStreaming
|
||||||
? async ({
|
? async ({
|
||||||
write,
|
write,
|
||||||
event,
|
event,
|
||||||
@@ -158,7 +164,12 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
|
|||||||
try {
|
try {
|
||||||
const text = data.choices?.[0]?.delta?.content;
|
const text = data.choices?.[0]?.delta?.content;
|
||||||
if (text) {
|
if (text) {
|
||||||
await appendRedisCache(streamResKey, text, 60);
|
if (streamId) {
|
||||||
|
await appendRedisCache(streamResKey, text, 60);
|
||||||
|
}
|
||||||
|
if (onStreamChunk) {
|
||||||
|
await onStreamChunk(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Outlink real-time streaming failed', {
|
logger.error('Outlink real-time streaming failed', {
|
||||||
@@ -171,8 +182,10 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Append first
|
// Initialize Redis key only when needed
|
||||||
await appendRedisCache(streamResKey, '', 120);
|
if (streamId) {
|
||||||
|
await appendRedisCache(streamResKey, '', 120);
|
||||||
|
}
|
||||||
|
|
||||||
// Merge global variables from database
|
// Merge global variables from database
|
||||||
const variables = chatDetail?.variables ?? {};
|
const variables = chatDetail?.variables ?? {};
|
||||||
@@ -202,7 +215,7 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
|
|||||||
histories,
|
histories,
|
||||||
query: query,
|
query: query,
|
||||||
chatConfig,
|
chatConfig,
|
||||||
stream: streamId ? true : false,
|
stream: enableStreaming,
|
||||||
workflowStreamResponse,
|
workflowStreamResponse,
|
||||||
runtimeEdges: storeEdges2RuntimeEdges(edges),
|
runtimeEdges: storeEdges2RuntimeEdges(edges),
|
||||||
runtimeNodes: storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes)),
|
runtimeNodes: storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes)),
|
||||||
@@ -225,31 +238,13 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
|
|||||||
const replyResult = await (async () => {
|
const replyResult = await (async () => {
|
||||||
try {
|
try {
|
||||||
if (streamId) {
|
if (streamId) {
|
||||||
// if streamId is provided, do not reply
|
// wecom model: Redis handles streaming, no reply needed
|
||||||
return;
|
return { success: true };
|
||||||
}
|
}
|
||||||
const result = await replyCallback?.(responseContent);
|
await onReply?.(responseContent);
|
||||||
|
return { success: true };
|
||||||
if (result.errcode !== 0) {
|
|
||||||
logger.error('Outlink official account reply failed', {
|
|
||||||
errmsg: result.errmsg
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errmsg: result.errmsg
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Outlink official account reply success', {
|
|
||||||
responseContent,
|
|
||||||
result
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: result
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Outlink reply callback failed', { error });
|
logger.error('Outlink reply callback failed', { error: getErrResponse(error) });
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
errmsg: getErrText(error)
|
errmsg: getErrText(error)
|
||||||
@@ -306,9 +301,10 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await appendRedisCache(streamResKey, STREAM_END_FLAG, 60);
|
if (streamId) {
|
||||||
|
await appendRedisCache(streamResKey, STREAM_END_FLAG, 60);
|
||||||
await replyCallback?.(`App run error: ${getErrText(error)}`);
|
}
|
||||||
|
await onReply?.(`App run error: ${getErrText(error)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Outlink invoke chat fallback reply failed', {
|
logger.error('Outlink invoke chat fallback reply failed', {
|
||||||
shareId: outLinkConfig.shareId,
|
shareId: outLinkConfig.shareId,
|
||||||
|
|||||||
@@ -126,13 +126,12 @@ async function processUserGroup(
|
|||||||
query: [{ text: { content: group.text } }],
|
query: [{ text: { content: group.text } }],
|
||||||
messageId: group.msgIds[group.msgIds.length - 1],
|
messageId: group.msgIds[group.msgIds.length - 1],
|
||||||
chatUserId: group.userId,
|
chatUserId: group.userId,
|
||||||
replyCallback: async (replyContent: string) => {
|
onReply: async (replyContent: string) => {
|
||||||
await client.sendMessage({
|
await client.sendMessage({
|
||||||
to_user_id: group.userId,
|
to_user_id: group.userId,
|
||||||
text: replyContent,
|
text: replyContent,
|
||||||
context_token: group.contextToken
|
context_token: group.contextToken
|
||||||
});
|
});
|
||||||
return { errcode: 0 };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user