飞书自动回复机器人开发:@机器人自助查询物流信息

使用nodejs + 本地运行,

开发需求:在私信或群聊中@机器人,发送查询指令查询相关物流信息,

该功能需要向物流商索要API接口进行实时查询,由飞书机器人将查询结果回复到相应的用户。

群聊@机器人的回复效果如下:

深圳货代公司大多用这个二级域名的系统,接口基本通用

开发步骤

一、飞书开发者后台创建企业自建应用,添加机器人能力,获取 App ID 和 App Secret

二、创建nodejs项目

  1. package.json
javascript 复制代码
{
  "name": "echo_bot",
  "version": "1.0.0",
  "main": "index.js",
  "author": "chenli.idevlab@bytedance.com",
  "license": "MIT",
  "type": "module",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js"
  },
  "dependencies": {
    "@larksuiteoapi/node-sdk": "^1.40.0",
    "axios": "^1.17.0",
    "dayjs": "^1.11.21"
  }
}
  1. index.js
javascript 复制代码
import * as Lark from '@larksuiteoapi/node-sdk';
import { logistics } from './js/config.js';

const queryfba = async (message) => {
  const logisticsREG = /皓鹏|中煌/;
  const keywords = /[0-9A-Z]+/

  if (!keywords.test(message)) return '错误的指令,仅支持查件/转单号/费用 + FBA号/内部单号,例如:\n皓鹏查件 FBA192D5DTY2\n最多同时查询10个单号';
  if (!logisticsREG.test(message)) return message + ',该物流商未开放查询';

  const provid = message.match(logisticsREG)[0];
  const provider = logistics.find(item => item.name === provid);


  if (message.includes('转单号')) {
    const res = await provider.tracking_numbers(message);
    return res.join('\n\n\n');

  } else if (message.includes('查件')) {
    const res = await provider.tracking(message);
    return res.join('\n\n\n\n');
  
  } else if (message.includes('费用')) {
    const res = await provider.get_info(message);
    return res.join('\n\n\n');

  } else {
    return '错误的指令,仅支持查件/转单号/费用 + FBA号/内部单号,例如:\n皓鹏查件 FBA192D5DTY2';
  }



}

/**
 * 配置应用基础信息和请求域名。
 * App base information and request domain name.
 */
const baseConfig = {
  // 应用的 AppID, 你可以在开发者后台获取。 AppID of the application, you can get it in the developer console.
  appId: process.env.APP_ID || '你的appId',
  // 应用的 AppSecret,你可以在开发者后台获取。 AppSecret of the application, you can get it in the developer console.
  appSecret: process.env.APP_SECRET || '你的appSecret',
  // 请求域名,如:https://open.feishu.cn。 Request domain name, such as https://open.feishu.cn.
  domain: process.env.BASE_DOMAIN || 'https://open.feishu.cn',
};

/**
 * 创建 LarkClient 对象,用于请求OpenAPI, 并创建 LarkWSClient 对象,用于使用长连接接收事件。
 * Create LarkClient object for requesting OpenAPI, and create LarkWSClient object for receiving events using long connection.
 */
const client = new Lark.Client(baseConfig);
const wsClient = new Lark.WSClient(baseConfig);

/**
 * 注册事件处理器。
 * Register event handler.
 */
const eventDispatcher = new Lark.EventDispatcher({}).register({
  /**
   * 注册接收消息事件,处理接收到的消息。
   * Register event handler to handle received messages.
   * https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/events/receive
   */
  'im.message.receive_v1': async (data) => {
    const {
      message: { chat_id, content, message_type, chat_type },
    } = data;

    /**
     * 解析用户发送的消息。
     * Parse the message sent by the user.
     */

    let responseText = '';
    let queryContent = '';

    try {
      if (message_type === 'text') {
        responseText = JSON.parse(content).text;
        queryContent = await queryfba(responseText.replaceAll(/@[0-9a-zA-Z_]+\s/g, ''));
        console.log(responseText)

      } else {
        responseText = '解析消息失败,请发送文本消息 \nparse message failed, please send text message';
      }
    } catch (error) {
      // 解析消息失败,返回错误信息。 Parse message failed, return error message.
      responseText = '解析消息失败,请发送文本消息 \nparse message failed, please send text message';
    }



    if (chat_type === 'p2p') {
      /**
       * 使用SDK调用发送消息接口。 Use SDK to call send message interface.
       * https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create
       */
      await client.im.v1.message.create({
        params: {
          receive_id_type: 'chat_id', // 消息接收者的 ID 类型,设置为会话ID。 ID type of the message receiver, set to chat ID.
        },
        data: {
          receive_id: chat_id, // 消息接收者的 ID 为消息发送的会话ID。 ID of the message receiver is the chat ID of the message sending.
          content: JSON.stringify({ text: `${queryContent}` }),
          msg_type: 'text', // 设置消息类型为文本消息。 Set message type to text message.
        },
      });
    } else {
      /**
       * 使用SDK调用回复消息接口。 Use SDK to call send message interface.
       * https://open.feishu.cn/document/server-docs/im-v1/message/reply
       */
      await client.im.v1.message.reply({
        path: {
          message_id: data.message.message_id, // 要回复的消息 ID。 Message ID to reply.
        },
        data: {
          content: JSON.stringify({ text: `${queryContent}` }),
          msg_type: 'text', // 设置消息类型为文本消息。 Set message type to text message.
        },
      });
    }
  },
});

/**
 * 启动长连接,并注册事件处理器。
 * Start long connection and register event handler.
 */
wsClient.start({ eventDispatcher });
  1. config.js
javascript 复制代码
import axios from 'axios'
import dayjs from 'dayjs'
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { formatMysqlTable } from './func.js';
import { request } from 'http';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const channel = (base) => {
    try {
        const rawData = fs.readFileSync(path.join(__dirname, `channel/${base}.json`), 'utf8');
        return JSON.parse(rawData);

    } catch (err) {
        console.error('读取或解析 JSON 失败:', err);
        return {}
    }
}

export const logistics = [
    {
        name: '皓鹏', //文档地址:http://haopbt.nextsls.com/api/v5/docs
        requestConfig: {
            baseURL: 'http://haopbt.nextsls.com',
            headers: {
                'Authorization': 'Bearer 你的Token',
                'Content-Type': 'application/json',
                'Accept': 'application/json',
                'Accept-Language': 'zh-CN'
            },
            timeout: 5000
        },
        channel: channel('haopbt'),

        get_keyword(keywords) {
            const REG = /[0-9A-Z]+/g;
            const match = keywords.toUpperCase().match(REG);

            return match.map(str => {
                const data = {
                    shipment: {
                        ext_numbers: '',
                        client_reference: '',
                        shipment_id: '',
                        tracking_number: '',
                        language: 'zh'
                    },
                    keyword: str
                }

                if (str.startsWith('FBA')) {
                    data.shipment.ext_numbers = str;
                    return data;
                };

                if (str.startsWith('KMY')) {
                    data.shipment.client_reference = str;
                    return data;
                };

                if (str.startsWith('BTH')) {
                    data.shipment.shipment_id = str;
                    return data;
                };

                data.shipment.tracking_number = str;
                return data;

            });

        },
        async tracking_numbers(keywords) {
            const api = axios.create(this.requestConfig)

            async function requestAll(datas) {
                const results = await Promise.allSettled(
                    datas.map(data => api.post('/api/v5/shipment/get_tracking_numbers', data))
                );

                return results.map((result, index) => {
                    if (result.status === 'fulfilled') {
                        if (result.value.data.status == 0) return result.value.data.info;

                        const data = result.value.data.data.shipment.parcels.filter(item => item.tracking_number);
                        if (data.length > 0) {
                            return datas[index].keyword + ' 转单号:\n' + data.map(item => item.tracking_number).join('\n');
                        }

                        return datas[index].keyword + ' 暂无转单号,注:卡派无转单号!';

                    } else {
                        return datas[index].keyword + ',查询失败,请重试。';

                    }
                });
            }

            const keyword = this.get_keyword(keywords);
            const allData = await requestAll(keyword);
            return allData;
        },


        async tracking(keywords) {
            const api = axios.create(this.requestConfig)

            async function requestAll(datas) {
                const results = await Promise.allSettled(
                    datas.map(data => api.post('/api/v5/shipment/get_tracking', data))
                );

                return results.map((result, index) => {
                    if (result.status === 'fulfilled') {
                        if (result.value.data.status == 0) return result.value.data.info;

                        const data = result.value.data.data.shipment
                        const tips = `${datas[index].keyword} 物流轨迹:${data.status == 'delivered' ? '已签收' : data.status}, ${data.carrier_code} #${data.tracking_number}`
                        const traces = data.traces.slice(0, 5).map(row => {
                            const info = row.info
                            const time = dayjs(row.time * 1000).format('YYYY-MM-DD HH:mm:ss')
                            row.info = ''
                            row.time = ''

                            let others = Object.values(row).filter(value => value);
                            others = others.length > 0 ? `[${others.join('] [')}]` : ''

                            return `├── ${time}\n└── ${info} ${others}`
                        }).join('\n│\n');

                        return `${tips}\n${traces}`

                    } else {
                        return datas[index].keyword + ',查询失败,请重试。';

                    }
                });
            }

            const keyword = this.get_keyword(keywords);
            const allData = await requestAll(keyword);
            return allData;
        },

        async get_info(keywords) {
            const api = axios.create(this.requestConfig)
            const channel = this.channel

            async function requestAll(datas) {
                const results = await Promise.allSettled(
                    datas.map(data => api.post('/api/v5/shipment/get_info', data))
                );

                return results.map((result, index) => {
                    if (result.status === 'fulfilled') {
                        if (result.value.data.status == 0) return result.value.data.info;

                        const {
                            shipment_id, //系统单号
                            service, //渠道
                            chargeable_weight, //收费重
                            actual_volume, //重量
                            volume_weight, //材积重
                            volume, //体积
                            charge_amount, //费用
                            charge_list, //数组对象,费用明细
                            attachments, //附件
                            status //运输状态
                        } = result.value.data.data.shipment

                        const table = [['查询', '计费重', '渠道'], [datas[index].keyword, chargeable_weight, channel[service]]]

                        charge_list.forEach(row => {
                            if (row.charge_type == '运费') {
                                table[0].push('单价', '运费')
                                table[1].push(row.unit_price, row.charge)
                                return
                            }

                            table[0].push(row.charge_type)
                            table[1].push(row.charge)

                        })

                        let total = 0
                        table[1].slice(4).forEach(item => total += Number(item))
                        table[0].push('总计')
                        table[1].push(total)

                        return formatMysqlTable(table, { maxLength: 30 });

                    } else {
                        return datas[index].keyword + ',查询失败,请重试。';

                    }
                });
            }

            const keyword = this.get_keyword(keywords);
            const allData = await requestAll(keyword);
            return allData;

        },

        async get_attachment(keywords) {
            const api = axios.create(this.requestConfig)

            async function requestAll(datas) {
                const results = await Promise.allSettled(
                    datas.map(data => api.post('/api/v5/shipment/get_info', data))
                );

                return results.map((result, index) => {
                    if (result.status === 'fulfilled') {
                        if (result.value.data.status == 0) return result.value.data.info;

                        const { attachments } = result.value.data.data.shipment

                        return { [datas[index].keyword]:JSON.stringify(attachments, null, 2) }

                    } else {
                        return datas[index].keyword + ',查询失败,请重试。';

                    }
                });
            }

            const keyword = this.get_keyword(keywords);
            const allData = await requestAll(keyword);
            return allData;

        },

        async get_services() {
            const api = axios.create(this.requestConfig);

            const data = {
                "services": {
                    "type": "b2b"
                }
            };

            const results = await api.post('/api/v5/shipment/get_services', data);
            const list = results.data.data.services.reduce((acc, row) => {
                acc[row.code] = row.name;
                return acc;
            }, {});

            return list;
        }

    }


];

//调用方法
async function getdata() {
    const provider = logistics.find(item => item.name === '皓鹏')
    const res = await provider.tracking_numbers('BTHPT10125064')
    console.log(res)
}

//获取渠道,写入json文件
export async function get_services_save_file(wuliu, base) {
    const provider = logistics.find(item => item.name == wuliu);
    const response = await provider.get_services();
    const jsonContent = JSON.stringify(response, null, 2);
    const fileName = path.join(__dirname, `channel/${base}.json`);

    try {
        fs.writeFileSync(fileName, jsonContent, 'utf8');
        console.log(`✅ 文件已保存: ${fileName}`);

        return fileName;

    } catch (err) {
        console.error('❌ 保存文件失败:', err);
        return ''

    }
}
  1. func.js
javascript 复制代码
/**
 * 将表格数据格式化为 MySQL 风格的字符串(正确处理中英文宽度差异)
 * @param {Array} data - 二维数组,第一行为表头
 * @param {Object} options - 配置选项
 * @param {number} options.maxLength - 每个单元格的最大长度(超出用...省略)
 * @param {string} options.ellipsis - 省略符号,默认为 '...'
 * @returns {string} 格式化后的表格字符串
 */
export function formatMysqlTable(data, options = {}) {
    if (!data || data.length === 0) {
        return 'No data';
    }

    const { maxLength = Infinity, ellipsis = '...' } = options;
    
    // 第一步:处理所有单元格,应用最大长度限制
    const processedData = data.map(row => 
        row.map(cell => {
            const str = String(cell);
            if (str.length > maxLength && maxLength > 0) {
                const keepLength = Math.max(0, maxLength - ellipsis.length);
                return str.substring(0, keepLength) + ellipsis;
            }
            return str;
        })
    );

    // 第二步:计算每列的最大显示宽度(考虑中英文差异)
    const colWidths = [];
    processedData.forEach(row => {
        row.forEach((cell, index) => {
            const displayWidth = getDisplayWidth(cell);
            colWidths[index] = Math.max(colWidths[index] || 0, displayWidth);
        });
    });

    // 第三步:生成分隔线
    const separator = '+' + colWidths.map(w => '-'.repeat(w + 2)).join('+') + '+';

    // 第四步:构建表格字符串
    let result = separator + '\n';
    
    // 表头
    const header = '|' + processedData[0].map((cell, i) => {
        return ` ${padToWidth(cell, colWidths[i])} `;
    }).join('|') + '|';
    result += header + '\n' + separator + '\n';

    // 数据行
    processedData.slice(1).forEach(row => {
        const line = '|' + row.map((cell, i) => {
            return ` ${padToWidth(cell, colWidths[i])} `;
        }).join('|') + '|';
        result += line + '\n';
    });
    
    result += separator;
    return result;
}

/**
 * 计算字符串的显示宽度(中文=2,英文=1)
 * @param {string} str - 要计算的字符串
 * @returns {number} 显示宽度
 */
function getDisplayWidth(str) {
    let width = 0;
    for (let i = 0; i < str.length; i++) {
        const charCode = str.charCodeAt(i);
        // 中文字符范围:0x4E00-0x9FFF(基本汉字)
        // 还包括标点符号等全角字符
        if (
            (charCode >= 0x4E00 && charCode <= 0x9FFF) ||  // 基本汉字
            (charCode >= 0x3000 && charCode <= 0x303F) ||  // CJK符号和标点
            (charCode >= 0xFF00 && charCode <= 0xFFEF)     // 全角字符
        ) {
            width += 2;
        } else {
            width += 1;
        }
    }
    return width;
}

/**
 * 将字符串填充到指定显示宽度
 * @param {string} str - 原始字符串
 * @param {number} targetWidth - 目标显示宽度
 * @returns {string} 填充后的字符串
 */
function padToWidth(str, targetWidth) {
    const currentWidth = getDisplayWidth(str);
    const paddingNeeded = targetWidth - currentWidth;
    
    if (paddingNeeded <= 0) {
        return str;
    }
    
    // 添加空格填充
    return str + ' '.repeat(paddingNeeded);
}
相关推荐
先吃饱再说18 小时前
从“地狱”到“楼梯”再到“同步写法”:Node.js 异步的二十年与Node内置fs模块详解
node.js
知识收割者18 小时前
Nodejs也能写Agent - 10.LangChain篇 - 初识LangChain
node.js
茯苓gao19 小时前
嵌入式开发笔记:工业机器人通信协议深度解析——从现场总线到工业以太网
笔记·嵌入式硬件·学习·机器人·信息与通信
HjhIron20 小时前
Node.js 内置模块之 path 与 fs:从路径拼接到异步演进
node.js·promise
触底反弹20 小时前
🚀 Node.js path 和 fs 模块,从回调地狱到 async/await 的进化之路!
javascript·后端·node.js
sugar__salt21 小时前
Node.js path 与 fs 核心模块完全指南 —— 从路径处理到异步流程控制
javascript·node.js
鲁邦通物联网21 小时前
基于GPIO与高频滤波的状态机:非侵入式机器人梯控精准平层判定实现
机器人·巡检机器人·机器人梯控·agv梯控·非侵入式采集·机器人乘梯·机器人自主乘梯
牛哥带你学代码21 小时前
VLA 与机器人基础模型:具身智能的下一代操作系统
人工智能·机器人
屑曦晨21 小时前
npm 全局包迁移指南:从系统目录到用户目录
npm·node.js