飞书第三方ISV服务商应用开发及上架教程
第一章 服务商入驻
1、使用管理员登录服务商管理后台
飞书ISV服务商注册地址应用敏捷开发,服务高效入驻。飞书开放平台致力于以先进的协同办公理念和产品助力企业成长,帮助企业打造愉悦高效的专属办公平台。https://open.feishu.cn/2、打开网站后,直接把页面向下拉取,找到成为ISV服务商【成为飞书 ISV,提供多元应用服务,与先进企业合作共赢】。
3、然后点击进去,注册,填写需要的信息。注意这里一定要有管理员权限去操作,不要使用子管理员或普通权限。
4、企也认证的话,需要企业法人用自己的帐号登录,然后去填写才可以,其余的人是无法提交信息的。这里要注意,避免花时间填写完了却无法提交。
飞书企业认证网址应用敏捷开发,服务高效入驻。飞书开放平台致力于以先进的协同办公理念和产品助力企业成长,帮助企业打造愉悦高效的专属办公平台。https://open.feishu.cn/isv/manage/info
第二章 应用配置
1、登录后,直接创建创建网页应用,如下图。
飞书创建应用登录地址应用敏捷开发,服务高效入驻。飞书开放平台致力于以先进的协同办公理念和产品助力企业成长,帮助企业打造愉悦高效的专属办公平台。https://open.feishu.cn/应用列表应用敏捷开发,服务高效入驻。飞书开放平台致力于以先进的协同办公理念和产品助力企业成长,帮助企业打造愉悦高效的专属办公平台。https://open.feishu.cn/app2、登录到应用列表后,点击【创建应用商店应用】,这里的应用商店应用就是企微中的【第三方服务商应用】,也就是钉钉中的【ISV服务商应用】,不过在飞书中叫做【ISV应用商店应用】。
3、创建应用的时候,选择【应用商店应用】,这就是ISV服务商应用。输入应用名称,应用描述。这里需要注意,如果企业没有认证成为ISV服务商,这里的应用商店应用按钮是灰色的,无法点击,直接点击右边的成为ISV服务商链接。
4、进入到应用详情中,打开【成员管理】,选择可见的开发者。
5、这里比较重要,填写【网页】配置项。我们先打开【网页】的开关,然后输入桌面端主页的网址,这里需要填写encode之后的网址,而不是自己的明文网址,下面的地址大家可以参照一下。
这里大家一定要写对自己的app_id,不同的环境的app_id是不一样的。
// 例如你的地址是http://my.feishu.com
// 那么你的地址就是下面的样子
// 其中%3A=英文的冒号
// %2F=英文的斜杠
http://open.feishu.cn/open-apis/authen/v1/index?app_id=cli_a38273c9f2e1100d&redirect_uri=http%3A%2F%2Fmy.feishu.com%2F&state=RANDOMSTATE
6、配置机器人,如果自己需要和用户交互,那么需要配置消息卡片请求的网址,否则只需要开启机器人就可以发送消息了。我们这里的DEMO填写了地址,是为了监听用户发送的消息,例如,用户在工作台打开,ISV应用,然后输入【上海天气】,这时候,飞书的服务台会把消息推送到下面的请求网址,也就是常规的callback地址,这时候我们接收到了用户的信息,就可以启用我们的AI机器人,将上海的天气发送给用户,这里的发送消息是通过服务商应用发送的。
7、其中的【小程序】,【扩展】,【小组件】,【移动应用登录】这些功能暂时不用就不需要配置了。
8、然后配置【安全设置】,这个很重要,这里的重定向URL和上面的【桌面端网页】配置的地址必须要一致,不然会提示非法请求,无法跳转到指定的地址。其中和企微的区别是企微的重定向地址是需要encode的,而飞书是不需要,飞书需要encode的是网页端地址和移动端地址。这个要注意。
// 例如重定向URL为http://www.baidu.com/
// 那么,配置的桌面端网页的地址也必须是http://www.baidu.com/
// 注意的是,这个地址结尾多了一个斜杠,两边都必须同时有斜杠,不能一个有一个没有,那么也会导致无法访问的。
// 网页地址DEMO
http://open.feishu.cn/open-apis/authen/v1/index?app_id=cli_a38286c9f2e1485d&redirect_uri=http%3A%2F%2Fmy.feishu.com%2F&state=RANDOMSTATE
// 重定向URLDEMO
http://my.feishu.com/
9、然后飞书就会跳转到重定向的URL,并且带上code参数,例如下面的地址是飞书服务器跳转过来的地址。
http://my.feishu.com/?code=e73va06d9f1e402dad46f51319c21154&state=RANDOMSTATE
10、IP白名单设置,如果开发阶段建议先不要设置白名单,这样可以直接在开发环境上用浏览器进行访问,直接打开浏览器,在地址栏输入自己配置的桌面端地址,如果配置正确,就会出现授权登录的页面。这个还是比钉钉和企微要人性化一点。
http://open.feishu.cn/open-apis/authen/v1/index?app_id=cli_a38286c9f2e1485d&redirect_uri=http%3A%2F%2Fmy.feishu.com%2F&state=RANDOMSTATE
11、然后就是重要的配置权限功能。以下的这些权限必须都获取。直接在权力配置中输入下面的权限就会出现,然后点击【开通权限】,测试环境是直接开通就行。这里有个功能点,所有的配置修改后,需要点击【版本管理与发布】,然后点击列表中的版本或者新增版本,然后点击编辑,直接保存就行,这里的保存相当于刷新的功能,然后新加入的权限和配置就会生效。这里发布的时候必须选择测试版本。
必须获取
contact:user.base:readonly
im:chat
im:message
im:message:readonly
im:message.group_at_msg
im:message.group_at_msg:readonly
im:message.p2p_msg
im:message.p2p_msg:readonly
im:message:send_as_bot
im:message:send_multi_users
im:resource
tenant:tenant:readonly
第三章 应用开发
1、飞书的比较简单,pom文件中不需要添加其余的jar。
2、解密工具类【FeishuDecrypt.java】。
package cn.renkai721.util;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
public class FeishuDecrypt {
private byte[] keyBs;
public FeishuDecrypt(String key) {
MessageDigest digest = null;
try {
digest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
// won't happen
}
keyBs = digest.digest(key.getBytes(StandardCharsets.UTF_8));
}
public String decrypt(String base64) throws Exception {
byte[] decode = Base64.getDecoder().decode(base64);
Cipher cipher = Cipher.getInstance("AES/CBC/NOPADDING");
byte[] iv = new byte[16];
System.arraycopy(decode, 0, iv, 0, 16);
byte[] data = new byte[decode.length - 16];
System.arraycopy(decode, 16, data, 0, data.length);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBs, "AES"), new IvParameterSpec(iv));
byte[] r = cipher.doFinal(data);
if (r.length > 0) {
int p = r.length - 1;
for (; p >= 0 && r[p] <= 16; p--) {
}
if (p != r.length - 1) {
byte[] rr = new byte[p + 1];
System.arraycopy(r, 0, rr, 0, p + 1);
r = rr;
}
}
return new String(r, StandardCharsets.UTF_8);
}
public static void main(String[] args) throws Exception {
FeishuDecrypt d = new FeishuDecrypt("test key");
System.out.println(d.decrypt("P37w+VZImNgPEO1RBhJ6RtKl7n6zymIbEG1pReEzghk="));
}
}
3、后台接口的callback接口。
package cn.renkai721.controller;
import cn.renkai721.bean.*;
import cn.renkai721.configuration.QywxProperties;
import cn.renkai721.service.*;
import cn.renkai721.util.*;
import com.alibaba.druid.util.StringUtils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
@EnableAsync
@RestController
@RequestMapping("/d3f")
@Slf4j
public class D3fController {
@Resource
private RedissonClient redissonClient;
@Autowired
private D3fService d3fService;
@PostMapping(value = "/feiShuCallback")
public Object d3fPost(
@RequestBody(required = false) JSONObject body) throws Exception {
log.info("接收d3f post请求:body={}",body);
Map<String, String> resultMap = new HashMap();
String encrypt = body.getString("encrypt");
if(StringUtils.isEmpty(encrypt)){String type = body.getString("type");
String challenge = body.getString("challenge");
if("url_verification".equalsIgnoreCase(type)){
log.info("机器人,消息卡片请求网址验证url_verification");
resultMap.put("challenge", challenge);
return resultMap;
}
}
try{
FeishuDecrypt d = new FeishuDecrypt(MsgUtil.val("encryptKey"));
String message = d.decrypt(encrypt);
log.info("message={}",message);
FeiShuCallbackBean messageObj = JSON.parseObject(message,FeiShuCallbackBean.class);
resultMap.put("challenge", messageObj.getChallenge());
if("url_verification".equals(messageObj.getType())){
log.info("验回调URL有效性");
return resultMap;
}else if("app_ticket".equals(messageObj.getEvent().getType())){
// 对于应用商店应用,开放平台会每隔1小时推送一次 app_ticket
RBucket<String> idBucket = redissonClient.getBucket(QywxProperties.app_ticket);
idBucket.set(messageObj.getEvent().getApp_ticket());
log.info("app_ticket={}",idBucket.get());
d3fService.get_app_access_token();
}else if("app_uninstalled".equals(messageObj.getEvent().getType())){
log.info("应用卸载");
log.info("app_uninstalled AuthCorpId={}",messageObj.getEvent().getTenant_key());
}else if("app_open".equals(messageObj.getEvent().getType())){
log.info("首次启用应用");
String tenant_access_token = d3fService.get_tenant_access_token(messageObj.getEvent().getTenant_key());
FeiShuTenantBean feiShuTenantBean = d3fService.get_tenant_info(tenant_access_token);
String corpId = messageObj.getEvent().getTenant_key();
String open_userid = messageObj.getEvent().getInstaller().getUnion_id();
String enterpriseName = feiShuTenantBean.getTenant().getName();
}
if(message.indexOf("im.message.receive_v1") != -1){
FeiShuCallbackV2Bean messageObjV2 = JSON.parseObject(message,FeiShuCallbackV2Bean.class);
log.info("接收到消息,messageObjV2={}",messageObjV2);
String corpId = messageObjV2.getHeader().getTenant_key();
// receive_id_type_union_id = "union_id";
// receive_id_type_open_id = "open_id";
// receive_id_type_user_id = "user_id";
// receive_id_type_chat_id = "chat_id";
// 飞书返回的用户ID很多,建议从头到尾都使用一个,不要换着使用
String union_id = messageObjV2.getEvent().getSender().getSender_id().getUnion_id();
d3fService.sendD3fTextMsg(corpId,MyConstants.receive_id_type_union_id,union_id,"暂未开启聊天功能。");
}
}catch (Exception e){
resultMap.put("challenge", "error");
e.printStackTrace();
throw e;
}
return resultMap;
}
}
4、d3fService.java中的方法。
public String get_app_access_token(){
RBucket<String> idBucket = redissonClient.getBucket(QywxProperties.app_access_token);
String app_access_token = idBucket.get();
log.info("get_app_access_token={}",app_access_token);
if(StringUtils.isEmpty(app_access_token)){
String app_ticket = this.get_app_ticket();
// token 有效期为 2 小时,在此期间调用该接口 token 不会改变。
// 当 token 有效期小于 30 分的时候,再次请求获取 token 的时候,会生成一个新的 token
// 与此同时老的 token 依然有效。
String url1= "https://open.feishu.cn/open-apis/auth/v3/app_access_token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map map = new HashMap<String, Object>();
map.put("app_id",MsgUtil.val("appId"));
map.put("app_secret",MsgUtil.val("appSecret"));
map.put("app_ticket",app_ticket);
HttpEntity httpEntity = new HttpEntity(map,headers);
try {
ResponseEntity<Object> postForEntity = restTemplate.postForEntity(url1, httpEntity, Object.class);
String postData1 = JSON.toJSONString(postForEntity.getBody());
log.info("get_app_access_token postData1={}",postData1);
String code = JSON.parseObject(postData1).getString("code");
if ("0".equals(code)) {
app_access_token = JSON.parseObject(postData1).getString("app_access_token");
String expires_in = JSON.parseObject(postData1).getString("expire");
if(!StringUtils.isEmpty(expires_in)){
idBucket.set(app_access_token,Integer.parseInt(expires_in), TimeUnit.SECONDS);
}else{
log.error("get_app_access_token is error");
}
}else{
log.error("get_app_access_token error");
}
}catch (Exception e){
log.error("调用"+url1+" 失败。 e={}",e);
e.printStackTrace();
}
}
return app_access_token;
}
public String get_tenant_access_token(String tenant_key){
String tenant_access_token = "";
String app_access_token = this.get_app_access_token();
String url1= "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map map = new HashMap<String, Object>();
map.put("app_access_token",app_access_token);
map.put("tenant_key",tenant_key);
HttpEntity httpEntity = new HttpEntity(map,headers);
try {
ResponseEntity<Object> postForEntity = restTemplate.postForEntity(url1, httpEntity, Object.class);
String postData1 = JSON.toJSONString(postForEntity.getBody());
log.info("get_tenant_access_token postData1={}",postData1);
String code = JSON.parseObject(postData1).getString("code");
if ("0".equals(code)) {
tenant_access_token = JSON.parseObject(postData1).getString("tenant_access_token");
}else{
log.error("get_tenant_access_token error");
}
}catch (Exception e){
log.error("调用"+url1+" 失败。 e={}",e);
e.printStackTrace();
}
return tenant_access_token;
}
public FeiShuTenantBean get_tenant_info(String tenant_access_token){
log.info("get_tenant_info tenant_access_token={}",tenant_access_token);
String url1= "https://open.feishu.cn/open-apis/tenant/v2/tenant/query";
FeiShuTenantBean feiShuTenantBean = null;
try {
String postData1 = HttpUtil.sendGetByFeishu(url1,tenant_access_token);
log.info("get_tenant_info postData1={}",postData1);
String postResultCode = JSON.parseObject(postData1).getString("code");
if ("0".equals(postResultCode)) {
String userData = JSON.parseObject(postData1).getString("data");
feiShuTenantBean = JSON.parseObject(userData,FeiShuTenantBean.class);
}else{
log.error("获取飞书企业信息失败");
}
}catch (Exception e){
log.error("调用"+url1+" 失败。 e={}",e);
e.printStackTrace();
log.error("获取飞书企业信息失败");
}
return feiShuTenantBean;
}
public void sendD3fTextMsg(String corpId, String toUserType, String toUser, String message){
// toUserType消息接收者id类型 open_id/user_id/union_id/email/chat_id
log.info("sendD3fTextMsg corpId={},toUser={},message={}"
,corpId,toUser,message);
String tenant_access_token = this.get_tenant_access_token(corpId);
String url1 = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type="+toUserType;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization","Bearer "+tenant_access_token);
Map messageMap = new HashMap<String, Object>();
messageMap.put("msg_type","text");
messageMap.put("receive_id",toUser);
Map textMessageMap = new HashMap<String, Object>();
textMessageMap.put("text",message);
messageMap.put("content",JSON.toJSONString(textMessageMap, SerializerFeature.WriteMapNullValue));
HttpEntity httpEntity = new HttpEntity(messageMap,headers);
//log.info("sendD3fTextMsg url={},HttpEntity={}",url1,httpEntity);
ResponseEntity<Object> postForEntity = restTemplate.postForEntity(url1, httpEntity, Object.class);
log.info("sendD3fTextMsg postForEntity={}",postForEntity);
}
public void sendTryFreeAccount(String corpId, String toUser,String toUserType){
String title = "申请试用";
String describe = "轻松选择适合您的自动化版本";
String url = "https://naturobot.com/freetrial/";
// imageKey需要自己用postman调用上传文件的接口,然后上传文件成功后,把返回的imageKey记录下来一直用
// 不知道飞书为什么发送一个外网图片一定要先上传到自己的服务器。
String imageKey = "img_v2_73501c09-031b-41ab-b8c0-4ab5dbe04c2g";
this.sendD3fPostMsg(corpId,toUser,toUserType,title,describe,url,imageKey);
}
public void sendD3fPostMsg(String corpId, String toUser,String toUserType, String title,String describe, String url,String imageKey){
log.info("sendD3fPostMsg corpId={},toUser={},title={},describe={},url={},imageKey={}"
,corpId,toUser,title,describe,url,imageKey);
String tenant_access_token = this.get_tenant_access_token(corpId);
String url1 = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type="+toUserType;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization","Bearer "+tenant_access_token);
Map messageMap = new HashMap<String, Object>();
messageMap.put("msg_type","post");
messageMap.put("receive_id",toUser);
StringBuffer html = new StringBuffer();
html.append("{\"zh_cn\":{\"title\":\""+title+"\",\"content\":[");
html.append("[{\"tag\":\"a\",\"href\":\""+url+"\",\"text\":\""+describe+"\"}],");
html.append("[{\"tag\":\"img\",\"image_key\":\""+imageKey+"\"}]]}}");
messageMap.put("content",html.toString());
HttpEntity httpEntity = new HttpEntity(messageMap,headers);
// log.info("sendD3fTextMsg url={},HttpEntity={}",url1,httpEntity);
ResponseEntity<Object> postForEntity = restTemplate.postForEntity(url1, httpEntity, Object.class);
log.info("sendD3fTextMsg postForEntity={}",postForEntity);
}
5、飞书所有的代码开发到此就结束了。至于代码中用到的bean,大家自行按照官网的消息解析。
第四章 官网需要的API地址
1、应用商店发布指南,必须看。
应用上架开发指南-必须看飞书开发文档中包含丰富多样的开发指南、教程和示例,让开发者获得愉悦、高效的应用开发体验。https://open.feishu.cn/document/uMzNwEjLzcDMx4yM3ATM/ugzNwEjL4cDMx4CO3ATM2、API调试台-是可视化在线API调用工具。
API调试台-是可视化在线API调用工具飞书开发文档中包含丰富多样的开发指南、教程和示例,让开发者获得愉悦、高效的应用开发体验。https://open.feishu.cn/api-explorer/cli_a38af0c276f8d00e?apiName=tenant_access_token&project=auth&resource=auth&version=v33、发送消息 content 说明。
发送消息 content 说明飞书开发文档中包含丰富多样的开发指南、教程和示例,让开发者获得愉悦、高效的应用开发体验。https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/im-v1/message/create_json4、图片 image的content说明。
图片 image飞书开发文档中包含丰富多样的开发指南、教程和示例,让开发者获得愉悦、高效的应用开发体验。https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/im-v1/message/create_json#7111df055、发送消息接口。
发送消息飞书开发文档中包含丰富多样的开发指南、教程和示例,让开发者获得愉悦、高效的应用开发体验。https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create6、上传图片接口。
上传图片飞书开发文档中包含丰富多样的开发指南、教程和示例,让开发者获得愉悦、高效的应用开发体验。https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create7、Encrypt Key 配置示例。
Encrypt Key 配置示例飞书开发文档中包含丰富多样的开发指南、教程和示例,让开发者获得愉悦、高效的应用开发体验。https://open.feishu.cn/document/ukTMukTMukTM/uYDNxYjL2QTM24iN0EjN/event-subscription-configure-/encrypt-key-encryption-configuration-case
第五章 发布版本
1、每一次的配置修改后,需要点击【版本管理与发布】,然后点击列表中的版本或者新增版本,然后点击编辑,直接保存就行。
2、这里的保存相当于刷新的功能,然后新加入的权限和配置就会生效。这里发布的时候必须选择测试版本。选择测试版本只有第一次才需要设置。
3、保存点击后,直接在客户端打开应用就行。编辑中把网页下拉到底,然后点击保存。
第六章 上架
1、目前还没有上架,后续上架后在继续更新该文章……