基于C# .NET Framework 4.8与飞书API的指令式文件助手:从文字指令到跨端文件传输

文末附完整代码仓库地址,建议先 Star 再阅读。

一、从一次深夜拷文件说起

凌晨两点,我刚躺下,手机屏幕亮了。

是测试同事发来的消息:“睡了吗?线上有个紧急问题,日志在你电脑上,我急用,能不能发我一份?”

人在被窝,电脑在书房,屏幕已锁。远程桌面方案折腾过,但这个时候去书房开电脑、解锁、找文件、发邮件,那今晚就别睡了。

痛点就这么朴实无华:你需要电脑里某个文件,但人不在电脑前——或者懒得走过去。

后来我琢磨了一个方案:在飞书上给电脑发条消息,电脑收到指令后自动把文件扔回来

这不是科幻,这就是我们今天要聊的“指令式文件助手”。核心逻辑清晰到一句话能说清楚:

用户在飞书移动端输入文字指令 → 飞书推送给 WinForm 客户端 → 客户端解析指令定位文件 → 通过飞书接口回传文件。

对,就这么直白。

二、技术架构:两个方案,我为什么选了 WebSocket

方案的核心是消息接收端:WinForm 怎么收到飞书的消息?

飞书开放平台提供两种方式,我直接对比一下实战感受。

对比维度 Webhook 模式 WebSocket 长连接模式
公网要求 需要公网地址 + 内网穿透(如 ngrok) 不需要,客户端主动连接
适用环境 有固定公网 IP 或可接受穿透方案 局域网、动态 IP、无公网环境
连接稳定性 依赖回调地址可用性,天然被动 需自行维护重连与心跳,但更可控
开发复杂 低(飞书推过来你就收) 中(需要维护连接状态)

我选 WebSocket 的理由很粗暴:公司没有固定公网 IP,也不想在生产环境挂个 ngrok。WebSocket 模式让客户端主动发起连接,完全绕过公网地址的硬性要求,对个人开发者或小团队更友好 。

架构图大概长这样:

┌─────────────┐        ┌─────────────────┐        ┌─────────────────┐
│  飞书移动端  │ ──文字指令──▶ │  飞书开放平台   │ ──推送事件──▶ │  WinForm客户端  │
│  (用户输入)  │ ◀─文件消息── │  (消息/文件API) │ ◀─文件回传── │  (指令解析+文件定位) │
└─────────────┘        └─────────────────┘        └─────────────────┘

飞书应用配置几个关键点

  • 创建企业自建应用,获取 App IDApp Secret
  • 开启事件订阅权限:im:message:receive(接收消息)、im:message:send(发送消息)、im:file:readim:file:write
  • WebSocket 事件订阅选 im.message.receive_v1
  • 别忘了把应用发布上线(或创建测试版本),否则收不到回调

三、指令设计:别让用户猜你怎么写

指令设计是这个工具的灵魂。设计得烂,用户用一次就弃;设计得好,天天离不开。

我的指令格式很简单:

发送 [文件名/通配符]
获取 [文件名/通配符]

说白了就是动作 + 目标,跟平时说话一样。

实战中的例子:

  • 发送 项目预算表.xlsx —— 精确匹配
  • 发送 *.log —— 通配符匹配所有日志文件
  • 获取 截图.png —— 同名功能,图省事
  • 发送 所有图片 —— 匹配 .jpg.png.jpeg 等常见图片格式

文件定位逻辑分三层

  1. 精确匹配:搜索配置目录下文件名完全一致的文件,命中即返回
  2. 通配符匹配:支持 *(任意多字符)和 ?(单字符),如 *.xlsx 匹配所有 Excel 文件
  3. 分类匹配所有图片所有文档 这类语义指令,映射到对应扩展名集合

匹配到多个文件时,不会一股脑全发——而是先回一个文件列表让用户选,避免刷屏和文件大小超限。

四、核心代码实现:从 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 IDApp 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 刷新并发问题

前面提过了:用 lockSemaphoreSlim 保证同一时刻只有一个线程去刷新 token,否则高并发下会疯狂刷,触发限流 。

六、总结与后续演进

这套方案在我本地跑了三个月,核心价值就一句话:让电脑变成了飞书上的一个“文件机器人”,动动手指就能把文件从电脑搬到手机,全程无需额外客户端。

后续可以做的优化

  • 支持更多飞书卡片交互(如文件列表用卡片展示,点选发送)
  • 添加文件预览摘要(文本文件自动截取前 N 行)
  • 支持多目录搜索(配置多个根目录)
  • 接入 Redis 做事件去重,避免分布式部署下的重复处理

代码已整理成完整示例,包含 WinForm UI 和后台服务,欢迎 Star 和 PR。

附录——本文关键 NuGet 包

  • Mud.Feishu.WebSocket — WebSocket 长连接事件订阅
  • Microsoft.Extensions.FileSystemGlobbing — 文件通配匹配

项目地址:https://github.com/your-repo/feishu-file-helper (示例)

本文关键词:C# .NET Framework 4.8、飞书 API、WebSocket 长连接、文件通配、tenant_access_token 缓存

适用场景:个人开发者、小团队内部工具、桌面端文件自动推送

分享到:

本文链接:https://www.biyeyuanma.cn/post/186.html

猜你喜欢

随机文章
热门标签
图片名称

服务热线

加我微信

加我微信