文末附完整代码仓库地址,建议先 Star 再阅读。
一、从一次深夜拷文件说起
凌晨两点,我刚躺下,手机屏幕亮了。
是测试同事发来的消息:“睡了吗?线上有个紧急问题,日志在你电脑上,我急用,能不能发我一份?”
人在被窝,电脑在书房,屏幕已锁。远程桌面方案折腾过,但这个时候去书房开电脑、解锁、找文件、发邮件,那今晚就别睡了。
痛点就这么朴实无华:你需要电脑里某个文件,但人不在电脑前——或者懒得走过去。
后来我琢磨了一个方案:在飞书上给电脑发条消息,电脑收到指令后自动把文件扔回来。
这不是科幻,这就是我们今天要聊的“指令式文件助手”。核心逻辑清晰到一句话能说清楚:
用户在飞书移动端输入文字指令 → 飞书推送给 WinForm 客户端 → 客户端解析指令定位文件 → 通过飞书接口回传文件。
对,就这么直白。
二、技术架构:两个方案,我为什么选了 WebSocket
方案的核心是消息接收端:WinForm 怎么收到飞书的消息?
飞书开放平台提供两种方式,我直接对比一下实战感受。
| 对比维度 | Webhook 模式 | WebSocket 长连接模式 |
|---|---|---|
| 公网要求 | 需要公网地址 + 内网穿透(如 ngrok) | 不需要,客户端主动连接 |
| 适用环境 | 有固定公网 IP 或可接受穿透方案 | 局域网、动态 IP、无公网环境 |
| 连接稳定性 | 依赖回调地址可用性,天然被动 | 需自行维护重连与心跳,但更可控 |
| 开发复杂 | 低(飞书推过来你就收) | 中(需要维护连接状态) |
我选 WebSocket 的理由很粗暴:公司没有固定公网 IP,也不想在生产环境挂个 ngrok。WebSocket 模式让客户端主动发起连接,完全绕过公网地址的硬性要求,对个人开发者或小团队更友好 。
架构图大概长这样:
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 飞书移动端 │ ──文字指令──▶ │ 飞书开放平台 │ ──推送事件──▶ │ WinForm客户端 │
│ (用户输入) │ ◀─文件消息── │ (消息/文件API) │ ◀─文件回传── │ (指令解析+文件定位) │
└─────────────┘ └─────────────────┘ └─────────────────┘
飞书应用配置几个关键点:
- 创建企业自建应用,获取
App ID和App Secret - 开启事件订阅权限:
im:message:receive(接收消息)、im:message:send(发送消息)、im:file:read、im:file:write - WebSocket 事件订阅选
im.message.receive_v1 - 别忘了把应用发布上线(或创建测试版本),否则收不到回调
三、指令设计:别让用户猜你怎么写
指令设计是这个工具的灵魂。设计得烂,用户用一次就弃;设计得好,天天离不开。
我的指令格式很简单:
发送 [文件名/通配符]
获取 [文件名/通配符]
说白了就是动作 + 目标,跟平时说话一样。
实战中的例子:
发送 项目预算表.xlsx—— 精确匹配发送 *.log—— 通配符匹配所有日志文件获取 截图.png—— 同名功能,图省事发送 所有图片—— 匹配.jpg、.png、.jpeg等常见图片格式
文件定位逻辑分三层:
- 精确匹配:搜索配置目录下文件名完全一致的文件,命中即返回
- 通配符匹配:支持
*(任意多字符)和?(单字符),如*.xlsx匹配所有 Excel 文件 - 分类匹配:
所有图片、所有文档这类语义指令,映射到对应扩展名集合
匹配到多个文件时,不会一股脑全发——而是先回一个文件列表让用户选,避免刷屏和文件大小超限。
四、核心代码实现:从 WebSocket 连接到文件回传
4.1 WebSocket 消息监听
飞书官方没有 .NET SDK,但 NuGet 上有社区实现的组件,我用了 Mud.Feishu.WebSocket,开箱即用支持自动重连和心跳 。
using Mud.Feishu.WebSocket;
public class FeishuMessageListener
{
private readonly FeishuWebSocketClient _client;
private readonly string _appId;
private readonly string _appSecret;
public FeishuMessageListener(string appId, string appSecret)
{
_appId = appId;
_appSecret = appSecret;
_client = new FeishuWebSocketClient(new FeishuWebSocketOptions
{
AppId = _appId,
AppSecret = _appSecret,
// 自动重连:指数退避 + 最大延迟 60 秒
ReconnectPolicy = new ExponentialBackoffReconnectPolicy
{
MaxDelay = TimeSpan.FromSeconds(60)
},
// 心跳间隔 30 秒,保持连接活跃
HeartbeatInterval = TimeSpan.FromSeconds(30)
});
// 注册消息事件处理器
_client.OnMessageReceived += HandleMessage;
}
public async Task StartAsync()
{
await _client.ConnectAsync();
Console.WriteLine("✅ WebSocket 已连接,等待指令中...");
}
}
关键点:ReconnectPolicy 必须配置,否则网络闪断后连接不会自动恢复,这坑我踩过 。
4.2 指令解析与文件定位
收到消息后,解析出指令中的文件名或通配符。
private void HandleMessage(object sender, FeishuMessageEventArgs e)
{
var message = e.Message;
// 只处理文本消息
if (message.MessageType != "text") return;
var content = message.Content;
var senderId = message.Sender?.SenderId?.OpenId;
// 解析指令:支持“发送 xxx”或“获取 xxx”两种格式
var fileName = ParseCommand(content);
if (string.IsNullOrEmpty(fileName))
{
ReplyText(senderId, "请发送“发送 文件名”或“获取 文件名”来索取文件。");
return;
}
// 文件定位
var files = LocateFiles(fileName, Config.SearchRoot);
if (!files.Any())
{
ReplyText(senderId, $"没有找到匹配“{fileName}”的文件。");
return;
}
if (files.Count() == 1)
{
// 单个文件直接发送
SendFile(senderId, files.First());
}
else
{
// 多个文件:先返回文件列表让用户选择
var fileList = string.Join("\n", files.Select((f, i) => $"{i+1}. {f.Name}"));
ReplyText(senderId, $"匹配到多个文件:\n{fileList}\n回复“发送 序号”获取指定文件。");
}
}
文件定位方法用 DirectoryInfo.EnumerateFiles + Matcher 实现通配符,效率比 GetFiles 高,尤其文件数多的时候 。
using Microsoft.Extensions.FileSystemGlobbing;
private List<FileInfo> LocateFiles(string pattern, string rootPath)
{
var matcher = new Matcher();
var result = new List<FileInfo>();
// 判断是否为通配符模式
if (pattern.Contains("*") || pattern.Contains("?"))
{
// 通配符:使用 glob 匹配
matcher.AddInclude($"**/{pattern}");
var matches = matcher.GetResultsInFullPath(rootPath);
result.AddRange(matches.Select(p => new FileInfo(p)));
}
else if (IsCategoryKeyword(pattern))
{
// 分类关键词:如“所有图片” → 匹配常见图片扩展名
var extensions = GetExtensionsForCategory(pattern);
foreach (var ext in extensions)
{
matcher.AddInclude($"**/*{ext}");
}
var matches = matcher.GetResultsInFullPath(rootPath);
result.AddRange(matches.Select(p => new FileInfo(p)));
}
else
{
// 精确匹配
var dir = new DirectoryInfo(rootPath);
var file = dir.GetFiles(pattern, SearchOption.AllDirectories).FirstOrDefault();
if (file != null) result.Add(file);
}
// 按修改时间倒序排序(最新的在前)
return result.OrderByDescending(f => f.LastWriteTime).ToList();
}
4.3 tenant_access_token 获取与缓存
调用飞书 API 之前必须先拿 tenant_access_token。飞书开放平台要求用 App ID 和 App Secret POST 换取,有效期 2 小时 。
public class TokenManager
{
private static readonly HttpClient _http = new HttpClient();
private static string _cachedToken;
private static DateTime _expireTime = DateTime.MinValue;
private static readonly object _lock = new object();
public string GetTenantAccessToken()
{
lock (_lock)
{
// 如果有效剩余时间小于 30 分钟,主动刷新
if (!string.IsNullOrEmpty(_cachedToken) &&
DateTime.Now < _expireTime.AddMinutes(-30))
{
return _cachedToken;
}
var request = new
{
app_id = Config.AppId,
app_secret = Config.AppSecret
};
var response = _http.PostAsJsonAsync(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
request).Result;
var json = response.Content.ReadAsStringAsync().Result;
var result = JsonSerializer.Deserialize<TokenResponse>(json);
if (result.Code != 0)
{
throw new Exception($"获取 token 失败: {result.Msg}");
}
_cachedToken = result.TenantAccessToken;
_expireTime = DateTime.Now.AddSeconds(result.Expire);
return _cachedToken;
}
}
}
⚠️ 这个坑值一顿饭:token 必须在过期前提前刷新,而不是等到调用失败再刷。否则并发场景下多个请求同时发现过期、同时刷新,轻则浪费资源,重则缓存击穿 。另外
app_id填错会返回10005,排查时候第一眼先看这个 。
4.4 上传文件并发送给用户
拿到 tenant_access_token 后,先把文件上传到飞书获取 file_key,再通过消息接口发送。
private async Task SendFileAsync(string openId, FileInfo file)
{
if (file.Length > 100 * 1024 * 1024)
{
await ReplyTextAsync(openId, $"文件“{file.Name}”超过 100MB,飞书不支持,请换个小点的。");
return;
}
var token = _tokenManager.GetTenantAccessToken();
// 1. 上传文件获取 file_key
using var formData = new MultipartFormDataContent();
using var fileStream = File.OpenRead(file.FullName);
var fileContent = new StreamContent(fileStream);
formData.Add(fileContent, "file", file.Name);
formData.Add(new StringContent("file"), "file_type"); // 文件类型
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var uploadResponse = await _http.PostAsync(
"https://open.feishu.cn/open-apis/im/v1/files",
formData);
var uploadResult = await uploadResponse.Content.ReadAsStringAsync();
var fileInfo = JsonSerializer.Deserialize<FileUploadResponse>(uploadResult);
if (fileInfo.Code != 0)
{
await ReplyTextAsync(openId, $"上传文件失败: {fileInfo.Msg}");
return;
}
// 2. 通过消息接口发送文件
var msgPayload = new
{
receive_id = openId,
msg_type = "file",
content = JsonSerializer.Serialize(new { file_key = fileInfo.Data.FileKey })
};
var msgResponse = await _http.PostAsJsonAsync(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
msgPayload);
// 处理结果...
}
注意:发图片走的是 /im/v1/images 接口,拿到的是 image_key,别混用 。
五、安全与避坑:这几点不改,上线即爆炸
5.1 Webhook 签名校验
如果用的是 Webhook 模式,签名校验必须开。否则 Webhook 地址泄露就等于把门钥匙贴在大街上 。
签名生成逻辑:时间戳 + 签名密钥拼接,HMAC-SHA256 加密,Base64 编码 。
5.2 文件搜索目录范围限制
打死也别让用户搜 C 盘根目录。我直接硬编码了 C:\Users\{用户名}\Documents\待发送 作为搜索根目录,并在配置里做了白名单控制,越权访问的请求直接拒绝。
5.3 文件大小限制
飞书单文件限制 100MB,发送前务必校验,超过就返回友好提示,不要硬发 。
5.4 token 刷新并发问题
前面提过了:用 lock 或 SemaphoreSlim 保证同一时刻只有一个线程去刷新 token,否则高并发下会疯狂刷,触发限流 。
六、总结与后续演进
这套方案在我本地跑了三个月,核心价值就一句话:让电脑变成了飞书上的一个“文件机器人”,动动手指就能把文件从电脑搬到手机,全程无需额外客户端。
后续可以做的优化:
- 支持更多飞书卡片交互(如文件列表用卡片展示,点选发送)
- 添加文件预览摘要(文本文件自动截取前 N 行)
- 支持多目录搜索(配置多个根目录)
- 接入 Redis 做事件去重,避免分布式部署下的重复处理
代码已整理成完整示例,包含 WinForm UI 和后台服务,欢迎 Star 和 PR。
附录——本文关键 NuGet 包:
Mud.Feishu.WebSocket— WebSocket 长连接事件订阅Microsoft.Extensions.FileSystemGlobbing— 文件通配匹配
本文关键词:C# .NET Framework 4.8、飞书 API、WebSocket 长连接、文件通配、tenant_access_token 缓存
适用场景:个人开发者、小团队内部工具、桌面端文件自动推送


