下一步

企业微信存档会话

免费试用
 
企业微信存档会话
发布日期:2024-11-13 18:00:46 浏览次数: 855 来源:Fantasy小平

企业微信可以使用会话存档,然后我们可以使用企业微信提供的sdk把会话数据获取过来。

首先去下载对应版本的jdk,我选用linux版下面的java实现。

会话存档的开启,需要填写一个RSA公钥,因此我们先要生成一个RSA密钥对,它的要求是:使用模值为2048bit的秘钥,使用PKCS1,输出格式选择PEM/Base64。

这一句话很关键,因为java默认生成的是1024位的PKCS8。

这里我在这个网站直接生成密钥对:http://web.chacuo.net/netrsakeypair

接下来是开发环节。

服务器上新建一个lib,如/usr/local/xxx/lib,把libWeWorkFinanceSdk_Java.so放入该目录下

springboot项目启动参数加上:


-Djava.library.path=/usr/local/xxx/lib

Finance.java放入com.tencent.wework包下。

sdk在每个线程中可以保留一个实例,一直使用,所以将初始化sdk和销毁sdk的工作放在代码最外层,即:







long sdk = SessionArchiveUtil.initSDK();try{   xxx 存档会话接口调用和业务处理}finally{   SessionArchiveUtil.destroy();}

SessionArchiveUtil:






















    private static ThreadLocal<Long> sdkLocal = new ThreadLocal<>();    private static void setSDK(Long sdk){        sdkLocal.set(sdk);    }    public static Long getSDK(){        return sdkLocal.get();    }
   private static Long initSDK(){        ParamCheckUtil.objectNotNull(sdkLocal.get(),"sdk已经存在");        long sdk = Finance.NewSdk();        setSDK(sdk);        return sdk;    }
   public static void destroy(){        if(sdkLocal.get()!= null) {            Finance.DestroySdk(sdkLocal.get());            sdkLocal.remove();        }    }

这里使用ThreadLocal来做,简单方便。

获取聊天数据(也是在SessionArchiveUtil):

















































   private static RateLimiter limiter = RateLimiter.create(10);//每分钟不超过600次  ==> 每秒不超过10      /**     * 获取存档会话数据 这里是未解密的数据     * 不可超过600次/分钟。     * @param sdk     * @param seq     * @param limit  <=1000     * @param proxy     * @param passwd     * @param timeout  s     * @return     */    public static List<SessionArchiveChatDataVO.ChatData> getChatData(Long sdk, Long seq, Long limit, String proxy, String passwd, Long timeout){        if(timeout == null){            timeout = 5L;        }        if(limit == null){            limit = 20L;        }        ParamCheckUtil.objectNull(sdk,"sdk不能为空");        ParamCheckUtil.objectNull(seq,"seq不能为空");        //每次使用GetChatData拉取存档前需要调用NewSlice获取一个slice,在使用完slice中数据后,还需要调用FreeSlice释放。        long slice = Finance.NewSlice();        try {            limiter.acquire();            int ret = Finance.GetChatData(sdk.longValue(), seq.longValue(), limit.longValue(), proxy, passwd, timeout.longValue(), slice);            checkRet(ret,"init sdk err ret" + ret);            String originalDataStr = Finance.GetContentFromSlice(slice);            SessionArchiveChatDataVO sessionArchiveOriginalDataVO = JSON.parseObject(originalDataStr, SessionArchiveChatDataVO.class);            QYWXUtil.checkError(sessionArchiveOriginalDataVO);            List<SessionArchiveChatDataVO.ChatData> originalDataList = sessionArchiveOriginalDataVO.getChatdata();            return originalDataList;        }finally {            Finance.FreeSlice(slice);        }    }        /**     * 专门校验Finance接口的     * @param i     * @param msg     */    public static void checkRet(int i,String msg){        if(i != 0){            throw new BusinessException(ResponseEnum.FAIL.getCode(),msg);        }    }

注意接口使用了限流。

对应的实体类:


















@Datapublic class SessionArchiveChatDataVO{
   private Integer errcode;    private String errmsg;
   private List<ChatData> chatdata;
   @Data    public static class ChatData {        private Long seq;        private String msgid;//消息id,消息的唯一标识,企业可以使用此字段进行消息去重  msgid以_external结尾的消息,表明该消息是一条外部消息。msgid以_updown_stream结尾的消息,表明该消息是一条上下游消息。        private Integer publickey_ver;        private String encrypt_random_key;        private String encrypt_chat_msg;    }}

获取到了消息,我们要对消息进行解密。还记得之前的RSA密钥对了吗,每次往企业微信里面填一次公钥,版本就加一。后面获取数据,解密的话,是需要用相应版本的私钥去解密encrypt_random_key,然后再用它用sdk去解密,得到明文消息。

首先是获得私钥:
































   /**     * 读取pkcs1格式的private key     * @param privKeyPEM     * @return     * @throws Exception     */    public static PrivateKey getPrivateKey(String privKeyPEM){        String privKeyPEMnew = privKeyPEM.replaceAll("\\n", "").replace("-----BEGIN RSA PRIVATE KEY-----", "").replace("-----END RSA PRIVATE KEY-----", "");        //byte[] bytes = java.util.Base64.getDecoder().decode(privKeyPEMnew);//Illegal base64 character d        byte[] bytes = org.apache.commons.codec.binary.Base64.decodeBase64(privKeyPEMnew);
       try {            DerInputStream derReader = new DerInputStream(bytes);            DerValue[] seq = derReader.getSequence(0);            BigInteger modulus = seq[1].getBigInteger();            BigInteger publicExp = seq[2].getBigInteger();            BigInteger privateExp = seq[3].getBigInteger();            BigInteger prime1 = seq[4].getBigInteger();            BigInteger prime2 = seq[5].getBigInteger();            BigInteger exp1 = seq[6].getBigInteger();            BigInteger exp2 = seq[7].getBigInteger();            BigInteger crtCoef = seq[8].getBigInteger();
           RSAPrivateCrtKeySpec keySpec = new RSAPrivateCrtKeySpec(modulus, publicExp, privateExp, prime1, prime2, exp1, exp2, crtCoef);            KeyFactory keyFactory = KeyFactory.getInstance("RSA");            PrivateKey privateKey = keyFactory.generatePrivate(keySpec);            return privateKey;        }catch (Exception e){            throw new RuntimeException(e);        }    }

然后解密encrypt_random_key:
































   public static String decrptyRandomKey(PrivateKey privateKey,String encrypt_random_key)  {        Cipher cipher = null;        try {            cipher = Cipher.getInstance("RSA");            cipher.init(Cipher.DECRYPT_MODE, privateKey);            // 64位解码加密后的字符串            byte[] inputArray = Base64.decodeBase64(encrypt_random_key.getBytes("UTF-8"));            int inputLength = inputArray.length;            // 最大解密字节数,超出最大字节数需要分组加密            int MAX_ENCRYPT_BLOCK = 256;            // 标识            int offSet = 0;            byte[] resultBytes = {};            byte[] cache = {};            while (inputLength - offSet > 0) {                if (inputLength - offSet > MAX_ENCRYPT_BLOCK) {                    cache = cipher.doFinal(inputArray, offSet, MAX_ENCRYPT_BLOCK);                    offSet += MAX_ENCRYPT_BLOCK;                } else {                    cache = cipher.doFinal(inputArray, offSet, inputLength - offSet);                    offSet = inputLength;                }                resultBytes = Arrays.copyOf(resultBytes, resultBytes.length + cache.length);                System.arraycopy(cache, 0, resultBytes, resultBytes.length - cache.length, cache.length);            }            return new String(resultBytes);
       } catch (Exception e) {            throw new RuntimeException(e);        }    }

最后解密数据:












   public static String decryptData(long sdk, String encrypt_key, String encrypt_chat_msg){        //每次使用DecryptData解密会话存档前需要调用NewSlice获取一个slice,在使用完slice中数据后,还需要调用FreeSlice释放。        long msg = Finance.NewSlice();        try {            int ret = Finance.DecryptData(sdk, encrypt_key, encrypt_chat_msg, msg);            checkRet(ret,"init sdk err ret" + ret);            return Finance.GetContentFromSlice(msg);        }finally {            Finance.FreeSlice(msg);        }    }

获得的这个解密内容是一个json字符串。该内容对应的实体类为:


































































































































































































































































































































































@Datapublic class SessionArchiveMsgVO {
   private String msgid;//消息id,消息的唯一标识,企业可以使用此字段进行消息去重    /**     * @see SessionArchiveMsgActionEnum     */    private String action;//消息动作,目前有send(发送消息)/recall(撤回消息)/switch(切换企业日志)三种类型    private String from;//消息发送方id。同一企业内容为userid,非相同企业为external_userid。消息如果是机器人发出,也为external_userid    private List<String> tolist;//息接收方列表,可能是多个,同一个企业内容为userid,非相同企业为external_userid。数组    private String roomid;//群聊消息的群id。如果是单聊则为空    private Long msgtime;//消息发送时间戳,utc时间,ms单位
   /**     * @see  SessionArchiveMsgTypeEnum     */    private String msgtype;    /**     * 机器人与外部联系人的账号都是external_userid,其中机器人的external_userid是以"wb"开头,     *          例如:"wbjc7bDwAAJVylUKpSA3Z5U11tDO4AAA",外部联系人的external_userid以"wo"或"wm"开头。     * 如果是机器人发出的消息,可以通过openapi拉取机器人详情:如何获取机器人详情?     * 如果是外部联系人发出的消息,可以通过openapi拉取外部联系人详情:如何获取外部联系人详情?     * 如果是引用/回复消息,发消息的用户的语言设置是中文,消息内容前面会加上“这是一条引用/回复消息:”,如果发消息的用户的语言设置是英文,消息内容的前面会加上“This is a quote/reply:”。     */    private Long time;//action=switch才有    private String user;//action=switch才有
   //msgtype = meeting_voice_call才有    private String voiceid;    private meeting_voice_call meeting_voice_call;
   //msgtype=voip_doc_share 才有    private String voipid;    private voip_doc_share voip_doc_share;
   //msgtype=external_redpacket才有  !! 注意,消息类型为redpacket也有这个  所以这里注释掉  留一个    //private redpacket redpacket;
   //msgtype=sphfeed 才有    private Long feed_type;    private String sph_name;    private String feed_desc;
   //msgtype=voiptext才有    private Long callduration;    private Long invitetype;
   //msgtype=qydiskfile才有    private info info;// ? 文档不清晰



   private Text text;    private Image image;    private Revoke revoke;    private Disagree disagree;    private Agree agree;    private Voice voice;    private Video video;    private Card card;    private Location location;    private Emotion emotion;
   private File file;    private Link link;    private Weapp weapp;    private Chatrecord chatrecord;    private Todo todo;    private Vote vote;    private Collect collect;    private Redpacket redpacket;    private Meeting meeting;    private meeting_notification meeting_notification;
   private docmsg docmsg;    private markdown markdown;    private news news;    private calendar calendar;    private mixed mixed;
   @Data    public static class info {        private String filename;    }
   /*@Data    public static class redpacket {        private Long type;        private String wish;        private Long totalcnt;        private Long totalamount;    }*/
   @Data    public static class voip_doc_share {        private String filename;        private String md5sum;        private Long filesize;        private String sdkfileid;    }
   @Data    public static class meeting_voice_call{        private Long endtime;        private String sdkfileid;        private List<demofiledata> demofiledata;        private List<sharescreendata> sharescreendata;
       @Data        public static class demofiledata {            private String filename;            private String demooperator;            private Long starttime;            private Long endtime;        }
       @Data        public static class sharescreendata {            private String share;            private Long starttime;            private Long endtime;        }    }
   @Data    public static class Text {        private String content;    }
   @Data    public static class Image {        private String md5sum;        private Long filesize;        private String sdkfileid;    }
   @Data    public static class Revoke {        private String pre_msgid;    }
   @Data    public static class Disagree {        private String userid;        private Long agree_time;//文档不清晰,到底是这个字段 还是下面的字段 ?        private Long disagree_time;    }
   @Data    public static class Agree {        private String userid;        private Long agree_time;    }
   @Data    public static class Voice {        private Long voice_size;        private Long play_length;        private String sdkfileid;        private String md5sum;    }
   @Data    public static class Video {        private Long filesize;        private Long play_length;        private String sdkfileid;        private String md5sum;    }
   @Data    public static class Card {        private String corpname;        private String userid;//名片所有者的id,同一公司是userid,不同公司是external_userid。String类型    }

   @Data    public static class Location {        private Double longitude;        private Double latitude;        private String address;        private String title;        private Long zoom;    }
   @Data    public static class Emotion {        private Integer type;//1表示gif 2表示png        private Integer width;        private Integer height;        private String sdkfileid;        private String md5sum;        private Long imagesize;    }
   @Data    public static class File {        private String sdkfileid;        private String md5sum;
       private String  filename;        private String fileext;
       private Long filesize;    }
   @Data    public static class Link {        private String title;        private String description;
       private String  link_url;        private String image_url;
   }
   @Data    public static class Weapp {        private String title;        private String description;
       private String  username;        private String displayname;
   }
   @Data    public static class Chatrecord {        private String title;        private List<Item> item;        @Data        public static class Item {            private String type;//每条聊天记录的具体消息类型:ChatRecordText/ ChatRecordFile/ ChatRecordImage/ ChatRecordVideo/ ChatRecordLink/ ChatRecordLocation/ ChatRecordMixed ….            private Long msgtime;            private String content;            private Boolean from_chatroom;
       }    }


   @Data    public static class Todo {        private String title;        private String content;    }
   @Data    public static class Vote {        private String votetitle;        private List<String> voteitem;        private Integer votetype;        private String voteid;    }
   @Data    public static class Collect {        private String room_name;        private String creator;        private String create_time;        private String title;        private List<Detail> details;
       @Data        public static class Detail {            private Long id;            private String ques;            private String type;//有Text(文本),Number(数字),Date(日期),Time(时间)。String类型        }    }

   @Data    public static class Redpacket {        private Long type;//1 普通红包、2 拼手气群红包、3 激励群红包        private String wish;        private Long totalcnt;        private Long totalamount;    }
   @Data    public static class Meeting {        private String topic;        private Long starttime;        private Long endtime;        private String address;        private String remarks;        private Long meetingid;    }
   @Data    public static class meeting_notification {        private Long meetingid;        private Long notification_type;        private String content;
   }
   @Data    public static class docmsg {        private String title;        private String link_url;        private String doc_creator;    }
   @Data    public static class markdown {        private String info;////?  啥格式    }
   @Data    public static class news {        private info info;        @Data        public static class info {            private List<item> item;
           @Data            public static class item {                private String title;                private String description;                private String url;                private String picurl;
           }        }    }

   @Data    public static class calendar {        private String title;        private String creatorname;        private List<String> attendeename;        private Long starttime;        private Long endtime;        private String place;        private String remarks;    }
   @Data    public static class mixed {        private List<item> item;        @Data        public static class item {            private String type;//是上面各类消息的type            private String content;//type不同   content不同  json        }    }}

枚举类:





















































































@Getter@AllArgsConstructorpublic enum SessionArchiveMsgTypeEnum {
   TEXT("text","文本"),
   IMAGE("image","图片"),
   REVOKE("revoke","撤回"),
   AGREE("agree","同意"),
   DISAGREE("disagree","不同意"),
   VOICE("voice","语音"),
   VIDEO("video","视频"),
   CARD("card","名片"),
   LOCATION("location","位置"),
   EMOTION("emotion","表情"),
   FILE("file","文件"),//
   LINK("link","链接"),
   WEAPP("weapp","小程序"),
   CHATRECORD("chatrecord","会话记录"),
   TODO("todo","待办"),
   VOTE("vote","投票"),
   COLLECT("collect","填表"),
   REDPACKET("redpacket","红包"),
   MEETING("meeting","会议邀请"),
   MEETING_NOTIFICATION("meeting_notification","会议控制"),
   DOCMSG("docmsg","在线文档"),
   MARKDOWN("markdown","MarkDown"),
   NEWS("news","图文"),
   CALENDAR("calendar","日程"),
   MIXED("mixed","混合类型"),
   MEETING_VOICE_CALL("meeting_voice_call","音频存档"),
   VOIP_DOC_SHARE("voip_doc_share","音频共享文档"),
   EXTERNAL_REDPACKET("external_redpacket","互通红包"),
   SPHFEED("sphfeed","视频号"),
   VOIPTEXT("voiptext","音视频通话"),
   QYDISKFILE("qydiskfile","微盘文件"),
   ;
   private String code;
   private String msg;

   public static String getNameByCode(String code){        SessionArchiveMsgTypeEnum[] enums = SessionArchiveMsgTypeEnum.values();        for(int i = 0; i < enums.length; i++){            SessionArchiveMsgTypeEnum anEnum = enums[i];            if(anEnum.getCode().equalsIgnoreCase(code)){                return anEnum.getMsg();            }        }        return null;    }}




























@Getter@AllArgsConstructorpublic enum SessionArchiveMsgActionEnum {
   SEND("send","发送消息"),
   RECALL("recall","撤回消息"),
   SWITCH("switch","切换企业日志"),
   ;
   private String code;
   private String msg;

   public static String getNameByCode(String code){        SessionArchiveMsgActionEnum[] enums = SessionArchiveMsgActionEnum.values();        for(int i = 0; i < enums.length; i++){            SessionArchiveMsgActionEnum anEnum = enums[i];            if(anEnum.getCode().equalsIgnoreCase(code)){                return anEnum.getMsg();            }        }        return null;    }}


获取媒体文件:
















































   /**     * 获取媒体文件     * @param sdkfileid     * @param proxy     * @param passwd     * @param timeout     * @param savefile  //绝对路径,一直到文件名     */    public static void GetMediaData(Long sdk,String sdkfileid,String proxy,String passwd,Long timeout,String savefile){        //拉取媒体文件        if(timeout == null){            timeout = 5L;        }        ParamCheckUtil.stringEmpty(sdkfileid,"sdkfileid不能为空");        ParamCheckUtil.stringEmpty(savefile,"savefile不能为空");        ParamCheckUtil.objectNull(sdk,"sdk不能为空");
       //媒体文件每次拉取的最大size为512k,因此超过512k的文件需要分片拉取。若该文件未拉取完整,sdk的IsMediaDataFinish接口会返回0,同时通过GetOutIndexBuf接口返回下次拉取需要传入GetMediaData的indexbuf。        //indexbuf一般格式如右侧所示,”Range:bytes=524288-1048575“,表示这次拉取的是从524288到1048575的分片。单个文件首次拉取填写的indexbuf为空字符串,拉取后续分片时直接填入上次返回的indexbuf即可。        String indexbuf = "";        while(true) {            //每次使用GetMediaData拉取存档前需要调用NewMediaData获取一个media_data,在使用完media_data中数据后,还需要调用FreeMediaData释放。            long media_data = Finance.NewMediaData();            try {                int ret = Finance.GetMediaData(sdk.longValue(), indexbuf, sdkfileid, proxy, passwd, timeout.longValue(), media_data);                checkRet(ret,"getmediadata ret:" + ret);                log.info("getmediadata outindex len:{}, data_len:{}, is_finis:{}\n",Finance.GetIndexLen(media_data),Finance.GetDataLen(media_data), Finance.IsMediaDataFinish(media_data));
               //大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。                FileOutputStream outputStream  = new FileOutputStream(new File(savefile), true);                outputStream.write(Finance.GetData(media_data));                outputStream.close();
               if(Finance.IsMediaDataFinish(media_data) == 1) {                    //已经拉取完成最后一个分片                    break;                } else {                    //获取下次拉取需要使用的indexbuf                    indexbuf = Finance.GetOutIndexBuf(media_data);                }            } catch(Exception e){                throw new RuntimeException(e);            }finally {                Finance.FreeMediaData(media_data);            }        }    }


对于使用sdk,可能会存在报错,甚至直接导致jvm崩溃:




A fatal error has been detected by the Java Runtime Environment:##  SIGSEGV (0xb) at pc=0x00007f65e5bdcdcd, pid=19004, tid=0x00007f65e75fd700

首先是要确保每个初始化获得的sdk,不存在并发调用的情况。也就是比如初始化一个sdk=100的量,在线程A和线程B之间都在并发调用,这是不允许的,只允许一个线程去使用和释放。

排除了这个,依旧报错,可以增大jvm堆内存和线程栈大小:




#!/bin/bashnohup java -Xmx512m -Xmn256m  -Xss20m -jar -Dspring.profiles.active=prd -Dlogging.config=./logback-prd.xml -Djava.library.path=/usr/local/xxx/lib xxx-0.0.1-SNAPSHOT.jar > console.log 2>&1 &tail -f console.log

特别是线程栈大小。猜测是由于一次要处理多条数据,且是2048位的RSA,对内存占用很大。

但是如果继续上调线程栈大小,也不可取。因为tomcat本身有很多线程、spring有内置的很多线程、还有redis连接线程、数据库连接等等。这些线程栈都变成30m或者50m的话,内存很快就会消耗光。因此最好是使用线程池,只要是跟会话存档sdk打交道的地方,都用线程池去执行。

定义线程池:





















  private synchronized static ThreadPoolExecutor getThreadPool(){        if(ThreadPool == null){            //使用线程池的目的,是由于企业微信的sdk,需要设置线程栈大小  但是统一调大了太耗费内存            ArrayBlockingQueue runnableTaskQueue = new ArrayBlockingQueue(4096);
           ThreadFactory threadFactory = new ThreadFactory() {                @Override                public Thread newThread(Runnable r) {                    return new Thread(null,r,"qw_session_archive_",1024 * 1024 * 50);//50M                }            };
           RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.AbortPolicy();
            //核心线程和最大线程设置为1             ThreadPool = new ThreadPoolExecutor(1, 1, 50,                    TimeUnit.MINUTES,runnableTaskQueue,threadFactory, rejectedExecutionHandler);        }        return ThreadPool;    }

封装任务:


























public class SessionArchiveChatDataDecryptCallable implements Callable<String> {    private SessionArchiveChatData encryptData;    private Long sdk;    public SessionArchiveChatDataDecryptCallable(SessionArchiveChatData encryptData,Long sdk){        this.encryptData = encryptData;        this.sdk = sdk;    }
   @Override    public String call() throws Exception {        SessionArchiveMapper sessionArchiveMapper = ApplicationContextHolder.getBean(SessionArchiveMapper.class);        SessionArchive sessionArchive = sessionArchiveMapper.selectOne(new LambdaQueryWrapper<SessionArchive>()                .eq(SessionArchive::getCorpId,encryptData.getCorpId())                .eq(SessionArchive::getVersion,encryptData.getPublickeyVer())        );        ParamCheckUtil.objectNull(sessionArchive,  "版本" + encryptData.getPublickeyVer() + "的对应公钥私钥没有" );
       String privateKeyStr = sessionArchive.getPrivateKey();        ParamCheckUtil.stringEmpty(privateKeyStr,"私钥为空");        PrivateKey privateKey = SessionArchiveUtil.getPrivateKey(privateKeyStr);        String encrypt_key = SessionArchiveUtil.decrptyRandomKey(privateKey, encryptData.getEncryptRandomKey());        String decryptMsg = SessionArchiveUtil.decryptData(sdk,encrypt_key,encryptData.getEncryptChatMsg());        return decryptMsg;    }}

提交到线程池:




    public static Future<String> submit(SessionArchiveChatDataDecryptCallable callable){        return getThreadPool().submit(callable);    }

53AI,让企业变得更强大

53AI公司的创始人杨芳贤曾是一位资深开源软件开发者,也是腾讯的TVP(腾讯云最具价值专家)。2014年,他的开源软件IBOS(全球用户量最大的开源协同办公平台)被开源中国评为最受欢迎的开源软件。2017年,他带领团队开发了首个微信小名片,初心是帮助成千上万与他一样的商务人士能更高效的见面、更高效的建立信任、更高效的完成价值传递。

2023年3月,他带领团队推出了53AI,至今已成功帮助上百家企业落地应用大模型,服务了包括中国联通、拉卡拉、开元教育、英氏控股、英迈中国、海尔金融、31会议、销售罗盘、昆仑学堂等数百家中大型企业。

描述11

按钮文案-
 
扫码咨询