如何巧用飞书消息卡片输入框实现一套业务交互逻辑
飞书开放平台最近开始内测了输入框的能力,基于输入框,为消息卡片提供了进一步业务系统打通的可能性,你可以不需要开发一整个网页应用,只需要借助飞书机器人和飞书消息卡片,就可以实现一套业务交互逻辑。
流程图示意
目标说明
这里首先确定要实现的逻辑:这里我要做的是一个短链接应用,功能很简单,点击下方的机器人菜单,并在弹出的窗口中输入对应的短链接后缀和要跳转的链接,点击确定就会帮我创建一个短链接。
具体效果如下:
如果后缀已经被占用,则展示如下内容:
在实现这个功能时,我首先使用了飞书提供的输入框组件的能力和表单组件能力,来实现整个业务交互,当然,你也可以根据业务形态,来选择合适的组件,构成一整个输入表单。
实现逻辑
整体的功能可以分为三步:
- 点击按钮:机器人需要响应点击事件,并发送一个带有输入框的消息卡片。
- 验证卡片输入内容:消息卡片中提供了输入框,但是用户的输入是否我们能用,需要设计一些验证的能力。
- 反馈用户是否创建成功:当我们创建成功后,需要给开发者提示,告诉他是否已经创建成功,帮助他结束整个流程。
接下来就是具体的实现步骤了。
点击按钮并回复卡片
首先,我先是使用了机器人的菜单功能,来实现在机器人底部配置菜单。你需要访问飞书开发者后台,找到机器人能力中的「机器人自定义菜单」,就可以配置一个机器人的自定义菜单了。机器人菜单支持跳转到指定链接,或者是推送事件,我选择推送事件,这样我就可以在服务端响应用户的创建的行为。这里我设定了事件内容为 create ,便于后续处理。
机器人菜单的处理则可以参考机器人菜单使用说明,通过订阅「机器人自定义事件」来完成对于相应行为的接受和对应的处理。
这部分的处理逻辑可以参考如下代码
// 判断请求体当中是否有 header 字段 && 来源的事件是否是机器人菜单
if (Object.hasOwn(ctx.body, "header") && ctx.body.header.event_type == 'application.bot.menu_v6') {
// 请求的事件是否是创建短链接对应的事件。
if (ctx.body.event.event_key == "create") {
try {
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id, // 从事件体中提取事件的触发人
msg_type: 'interactive',
content: "", // 推送卡片 JSON
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
}
对卡片输入内容进行校验
在完成卡片响应的设定后,接下来我实现的是校验的逻辑,这里分为两层:第一层是客户端可以完成的校验:比如短链接应该少于 10 个字符。第二层是只有客户端才能完成的校验。
1. 在本地校验文件长短
如果每次发起请求都需要发送到服务端进行校验,则有比较高的校验成本。好在消息卡片提供了本地校验的能力,你可以通过 max_length
字段来验证输入框长短.
这里我是使用输入框组件的字段,来验证输入的内容长度不得大于 10 。
2. 输入两个参数才发起请求
在消息卡片的输入框组件中,只要输入内容就会发现校验,因此我不能直接使用输入框组件,而是需要借助 form 组件,来实现用户输入两个内容再手动发起提交。则具体我构建的卡片 JSON 是这样的。
{
"header": {
"template": "turquoise",
"title": {
"content": "创建短链接",
"tag": "plain_text"
}
},
"elements": [
{
"tag": "form",
"name": "form_1",
"elements": [
{
"tag": "input",
"name": "postfix",
"placeholder": {
"tag": "plain_text",
"content": "请输入后缀"
},
"max_length": 10,
"label": {
"tag": "plain_text",
"content": "请输入后缀:"
},
"label_position": "left",
"value": {
"k": "v"
}
},
{
"tag": "input",
"name": "link",
"placeholder": {
"tag": "plain_text",
"content": "请输入要跳转链接"
},
"label": {
"tag": "plain_text",
"content": "请输入要跳转链接:"
},
"label_position": "left",
"value": {
"k": "v"
}
},
{
"action_type": "form_submit",
"name": "submit",
"tag": "button",
"text": {
"content": "提交",
"tag": "lark_md"
},
"type": "primary",
"confirm": {
"title": {
"tag": "plain_text",
"content": "创建短链接"
},
"text": {
"tag": "plain_text",
"content": "确认提交吗"
}
}
}
]
}
]
}
这部分的关键是用 form 组件包裹 Input 组件,从而规避了 Input 组件输入内容就会发送到服务端校验的问题。
3. 在服务端验证有无
这部分逻辑我在实现的时候相对简单,没有专门去进行校验(主要是因为我的短链接服务和机器人是两个不同的服务),而是通过短链服务返回 200 还是 401 来判断是否出现了重复的问题,所以这里只是简单的使用了一个 try catch 来完成校验。
需要注意的是,这里你会注意到,返回是直接返回了一段 JSON String,这是因为触发这个事件是通过消息卡片的回调能力,如果你在消息卡片的回调能力返回一个 JSON,就会直接把 UI 层面的卡片渲染为你返回的卡片结果。靠着这个功能,我来实现的成功与失败返回不同的内容。
if (Object.hasOwn(ctx.body, "action") && ctx.body.action) {
try {
// create link
if (status == 200) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnlt0",
"template_variable": {
"source": ctx.body.action.form_value.postfix,
"target": ctx.body.action.form_value.link
}
}
})
}
return {};
} catch (e) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vZYuo",
"template_variable": {
"POSTFIX": ctx.body.action.form_value.postfix
}
}
});
}
}
完整代码参考
整个机器人的部分的代码只有 170 余行,不多,供你参考
import cloud from '@lafjs/cloud'
import axios from 'axios'
let appid = "";
let secret = ""
const lark = require('@larksuiteoapi/node-sdk');
const client = new lark.Client({
appId: appid,
appSecret: secret
});
export default async function (ctx: FunctionContext) {
console.log("event",ctx.body);
if (ctx.body.challenge) {
return ctx.body
}
if (Object.hasOwn(ctx.body, "action") && ctx.body.action) {
if (ctx.body.action.name != "submit") return { code: 1 };
try {
// function to create link
if (status == 200) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnlt0",
"template_variable": {
"source": ctx.body.action.form_value.postfix,
"target": ctx.body.action.form_value.link
}
}
})
}
return {};
} catch (e) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vZYuo",
"template_variable": {
"POSTFIX": ctx.body.action.form_value.postfix
}
}
});
}
}
if (Object.hasOwn(ctx.body, "header") && ctx.body.header.event_type == 'application.bot.menu_v6') {
// 处理按钮
if (ctx.body.event.event_key == "help") {
try {
let content = JSON.stringify({
template_id: "ctp_AAmFBFOpYX0S"
});
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBFOpYX0S",
}
}),
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
if (ctx.body.event.event_key == "mylink") {
try {
// function to get all my link
let links = data.data.map(item => {
return {
source: `[${item.Postfix}](https://link.feishu.io/${item.Postfix})`,
target: item.Link
}
})
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnHfs",
"template_variable": {
"CONTENT": links
}
}
}),
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
if (ctx.body.event.event_key == "create") {
try {
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: "{\"header\":{\"template\":\"turquoise\",\"title\":{\"content\":\"创建短链接\",\"tag\":\"plain_text\"}},\"elements\":[{\"tag\":\"form\",\"name\":\"form_1\",\"elements\":[{\"tag\":\"input\",\"name\":\"postfix\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀\"},\"max_length\":10,\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"tag\":\"input\",\"name\":\"link\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接\"},\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"action_type\":\"form_submit\",\"name\":\"submit\",\"tag\":\"button\",\"text\":{\"content\":\"提交\",\"tag\":\"lark_md\"},\"type\":\"primary\",\"confirm\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"创建短链接\"},\"text\":{\"tag\":\"plain_text\",\"content\":\"确认提交吗\"}}}]}]}",
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
}
return { data: 'hi, laf' }
}