飞书开放平台最近开始内测了输入框的能力,基于输入框,为消息卡片提供了进一步业务系统打通的可能性,你可以不需要开发一整个网页应用,只需要借助飞书机器人和飞书消息卡片,就可以实现一套业务交互逻辑。
流程图示意
目标说明
这里首先确定要实现的逻辑:这里我要做的是一个短链接应用,功能很简单,点击下方的机器人菜单,并在弹出的窗口中输入对应的短链接后缀和要跳转的链接,点击确定就会帮我创建一个短链接。
具体效果如下:
如果后缀已经被占用,则展示如下内容:
在实现这个功能时,我首先使用了飞书提供的输入框组件 的能力和表单组件 能力,来实现整个业务交互,当然,你也可以根据业务形态,来选择合适的组件,构成一整个输入表单。
实现逻辑
整体的功能可以分为三步:
点击按钮 :机器人需要响应点击事件,并发送一个带有输入框的消息卡片。
验证卡片输入内容 :消息卡片中提供了输入框,但是用户的输入是否我们能用,需要设计一些验证的能力。
反馈用户是否创建成功 :当我们创建成功后,需要给开发者提示,告诉他是否已经创建成功,帮助他结束整个流程。
接下来就是具体的实现步骤了。
点击按钮并回复卡片
首先,我先是使用了机器人的菜单功能,来实现在机器人底部配置菜单。你需要访问飞书开发者后台,找到机器人能力中的「机器人自定义菜单」,就可以配置一个机器人的自定义菜单了。机器人菜单支持跳转到指定链接,或者是推送事件,我选择推送事件,这样我就可以在服务端响应用户的创建的行为。这里我设定了事件内容为 create ,便于后续处理。
机器人菜单的处理则可以参考机器人菜单使用说明 ,通过订阅「机器人自定义事件」来完成对于相应行为的接受和对应的处理。
这部分的处理逻辑可以参考如下代码
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 : "" ,
},
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 {};
}
}
}
Code language: JavaScript ( javascript )
对卡片输入内容进行校验
在完成卡片响应的设定后,接下来我实现的是校验的逻辑,这里分为两层:第一层是客户端可以完成的校验:比如短链接应该少于 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" : "确认提交吗"
}
}
}
]
}
]
}
Code language: JSON / JSON with Comments ( json )
这部分的关键是用 form 组件包裹 Input 组件,从而规避了 Input 组件输入内容就会发送到服务端校验的问题。
3. 在服务端验证有无
这部分逻辑我在实现的时候相对简单,没有专门去进行校验(主要是因为我的短链接服务和机器人是两个不同的服务),而是通过短链服务返回 200 还是 401 来判断是否出现了重复的问题,所以这里只是简单的使用了一个 try catch 来完成校验。
需要注意的是,这里你会注意到,返回是直接返回了一段 JSON String,这是因为触发这个事件是通过消息卡片的回调能力,如果你在消息卡片的回调能力返回一个 JSON,就会直接把 UI 层面的卡片渲染为你返回的卡片结果。靠着这个功能,我来实现的成功与失败返回不同的内容。
if (Object .hasOwn(ctx.body, "action" ) && ctx.body.action) {
try {
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
}
}
});
}
}
Code language: JavaScript ( javascript )
完整代码参考
整个机器人的部分的代码只有 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 {
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 {
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' }
}
Code language: JavaScript ( javascript )