使用nodejs + 本地运行,
开发需求:在私信或群聊中@机器人,发送查询指令查询相关物流信息,
该功能需要向物流商索要API接口进行实时查询,由飞书机器人将查询结果回复到相应的用户。
群聊@机器人的回复效果如下:

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

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



二、创建nodejs项目
- 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"
}
}
- 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 });
- 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 ''
}
}
- 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);
}