diff --git a/README.md b/README.md index 3840267..e19379e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - [x] [Telegram](https://github.com/zhayujie/bot-on-anything#6telegram) - [x] [QQ](https://github.com/zhayujie/bot-on-anything#5qq) - [x] [钉钉](https://github.com/zhayujie/bot-on-anything#10%E9%92%89%E9%92%89) - - [ ] 飞书 + - [x] [飞书](https://github.com/zhayujie/bot-on-anything#11%E9%A3%9E%E4%B9%A6) - [x] [Gmail](https://github.com/zhayujie/bot-on-anything#7gmail) - [x] [Slack](https://github.com/zhayujie/bot-on-anything#8slack) @@ -533,6 +533,48 @@ pip3 install requests flask 地址: https://open-dev.dingtalk.com/fe/app#/corp/robot 添加机器人,在开发管理中设置服务器出口 ip (在部署机执行`curl ifconfig.me`就可以得到)和消息接收地址(配置中的对外地址如 https://xx.xx.com:8081) +添加机器人,在开发管理中设置服务器出口ip(在部署机执行curl ifconfig.me就可以得到)和消息接收地址(配置中的对外地址如 https://xx.xx.com:8081) + +### 11.飞书 + +**依赖** + +```bash +pip3 install requests flask +``` +**配置** + +```json +"channel": { + "type": "dingtalk", + "feishu": { + "image_create_prefix": [ + "画", + "draw", + "Draw" + ], + "port": "8082",//对外端口 + "app_id": "xxx", //应用app_id + "app_secret": "xxx",//应用Secret + "verification_token": "xxx" //事件订阅 Verification Token + } +} +``` + +**生成机器人** + +地址: https://open.feishu.cn/app/ +1. 添加企业自建应用 +2. 开通权限 + - im:message + - 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 +3. 订阅菜单添加事件(接收消息v2.0) 配置请求地址(配置中的对外地址如 https://xx.xx.com:8081) +4. 版本管理与发布中上架应用,app中会收到审核信息,通过审核后在群里添加自建应用 + ### 通用配置 + `clear_memory_commands`: 对话内指令,主动清空前文记忆,字符串数组可自定义指令别名。 diff --git a/channel/channel_factory.py b/channel/channel_factory.py index 78a1a49..1346499 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -49,5 +49,9 @@ def create_channel(channel_type): from channel.dingtalk.dingtalk_channel import DingTalkChannel return DingTalkChannel() + elif channel_type == const.FEISHU: + from channel.feishu.feishu_channel import FeiShuChannel + return FeiShuChannel() + else: raise RuntimeError("unknown channel_type in config.json: " + channel_type) diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py new file mode 100644 index 0000000..07877d8 --- /dev/null +++ b/channel/feishu/feishu_channel.py @@ -0,0 +1,185 @@ +# encoding:utf-8 +import json +import hmac +import hashlib +import base64 +import time +import requests +from urllib.parse import quote_plus +from common import log +from flask import Flask, request, render_template, make_response +from common import const +from common import functions +from config import channel_conf +from config import channel_conf_val +from channel.channel import Channel +from urllib import request as url_request +from channel.feishu.store import MemoryStore + +class FeiShuChannel(Channel): + def __init__(self): + self.app_id = channel_conf( + const.FEISHU).get('app_id') + self.app_secret = channel_conf( + const.FEISHU).get('app_secret') + self.verification_token = channel_conf( + const.FEISHU).get('verification_token') + log.info("[FeiShu] app_id={}, app_secret={} verification_token={}".format( + self.app_id, self.app_secret, self.verification_token)) + self.memory_store = MemoryStore() + + def startup(self): + http_app.run(host='0.0.0.0', port=channel_conf( + const.FEISHU).get('port')) + + def get_tenant_access_token(self): + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/" + headers = { + "Content-Type": "application/json" + } + req_body = { + "app_id": self.app_id, + "app_secret": self.app_secret + } + + data = bytes(json.dumps(req_body), encoding='utf8') + req = url_request.Request(url=url, data=data, + headers=headers, method='POST') + try: + response = url_request.urlopen(req) + except Exception as e: + print(e.read().decode()) + return "" + + rsp_body = response.read().decode('utf-8') + rsp_dict = json.loads(rsp_body) + code = rsp_dict.get("code", -1) + if code != 0: + print("get tenant_access_token error, code =", code) + return "" + return rsp_dict.get("tenant_access_token", "") + + def notify_feishu(self, token, receive_type, receive_id, at_id, answer): + log.info("notify_feishu.receive_type = {} receive_id={}", + receive_type, receive_id) + + url = "https://open.feishu.cn/open-apis/im/v1/messages" + params = {"receive_id_type": receive_type} + + # text = at_id and "%s" % ( + # at_id, answer.lstrip()) or answer.lstrip() + text = answer.lstrip() + log.info("notify_feishu.text = {}", text) + msgContent = { + "text": text, + } + req = { + "receive_id": receive_id, # chat id + "msg_type": "text", + "content": json.dumps(msgContent), + } + payload = json.dumps(req) + headers = { + # your access token + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + } + response = requests.request( + "POST", url, params=params, headers=headers, data=payload + ) + log.info("notify_feishu.response.content = {}", response.content) + + def handle(self, message): + event = message["event"] + msg = event["message"] + messageId = msg["message_id"] + chat_type = msg["chat_type"] + sender_id = event["sender"]["sender_id"]["open_id"] + + prompt = json.loads(msg["content"])["text"] + prompt = prompt.replace("@_user_1", "") + + #重复 + r, v = self.memory_store.get(messageId) + if v: + return {'ret': 200} + + self.memory_store.set(messageId, True) + + # 非文本不处理 + message_type = msg["message_type"] + if message_type != "text": + return {'ret': 200} + if chat_type == "group": + mentions = msg["mentions"] + # 日常群沟通要@才生效 + if not mentions: + return {'ret': 200} + receive_type = "chat_id" + receive_id = msg.get("chat_id") + at_id = sender_id + elif chat_type == "p2p": + receive_type = "open_id" + receive_id = sender_id + at_id = None + + # 调用发消息 API 之前,先要获取 API 调用凭证:tenant_access_token + access_token = self.get_tenant_access_token() + if access_token == "": + log.error("send message access_token is empty") + return {'ret': 204} + + context = dict() + img_match_prefix = functions.check_prefix( + prompt, channel_conf_val(const.DINGTALK, 'image_create_prefix')) + if img_match_prefix: + prompt = prompt.split(img_match_prefix, 1)[1].strip() + context['type'] = 'IMAGE_CREATE' + context['from_user_id'] = str(sender_id) + reply = super().build_reply_content(prompt, context) + if img_match_prefix: + if not isinstance(reply, list): + return {'ret': 204} + images = "" + for url in reply: + images += f"[!['IMAGE_CREATE']({url})]({url})\n" + reply = images + # 机器人 echo 收到的消息 + self.notify_feishu(access_token, receive_type, + receive_id, at_id, reply) + return {'ret': 200} + + def handle_request_url_verify(self, post_obj): + # 原样返回 challenge 字段内容 + challenge = post_obj.get("challenge", "") + return {'challenge': challenge} + + +feishu = FeiShuChannel() +http_app = Flask(__name__,) + + +@http_app.route("/", methods=['POST']) +def chat(): + # log.info("[FeiShu] chat_headers={}".format(str(request.headers))) + log.info("[FeiShu] chat={}".format(str(request.data))) + obj = json.loads(request.data) + if not obj: + return {'ret': 201} + # 校验 verification token 是否匹配,token 不匹配说明该回调并非来自开发平台 + headers = obj.get("header") + if not headers: + return {'ret': 201} + token = headers.get("token", "") + if token != feishu.verification_token: + log.error("verification token not match, token = {}", token) + return {'ret': 201} + + # 根据 type 处理不同类型事件 + t = obj.get("type", "") + if "url_verification" == t: # 验证请求 URL 是否有效 + return feishu.handle_request_url_verify(obj) + elif headers.get("event_type", None) == "im.message.receive_v1": # 事件回调 + return feishu.handle(obj) + return {'ret': 202} + diff --git a/channel/feishu/store.py b/channel/feishu/store.py new file mode 100644 index 0000000..e9caf52 --- /dev/null +++ b/channel/feishu/store.py @@ -0,0 +1,67 @@ +# -*- coding: UTF-8 -*- + +import time +from threading import Lock + + +class Store(object): + """ + This is an interface to storage (Key, Value) pairs for sdk. + """ + + def get(self, key): # type: (str) -> Tuple[bool, str] + return False, '' + + def set(self, key, value, expire): # type: (str, str, int) -> None + """ + storage key, value into the store, value has an expire time.(unit: second) + """ + pass + + +class ExpireValue(object): + def __init__(self, value, expireTime): # type: (str, int) -> None + self.value = value + self.expireTime = expireTime + + +class MemoryStore(Store): + """ + This is an implement of `StoreInterface` which stores data in the memory + """ + + def __init__(self): # type: () -> None + self.data = {} # type: Dict[str, ExpireValue] + self.mutex = Lock() # type: Lock + + def get(self, key): # type: (str) -> Tuple[bool, str] + # print('get %s' % key) + self.mutex.acquire() + try: + val = self.data.get(key) + if val is None: + return False, "" + else: + if val.expireTime == -1: + return True, val.value + elif val.expireTime < int(time.time()): + self.data.pop(key) + return False, "" + else: + return True, val.value + finally: + self.mutex.release() + + def set(self, key, value, expire=None): # type: (str, str, int) -> None + # print('put %s=%s, expire=%s' % (key, value, expire)) + """ + storage key, value into the store, value has an expire time.(unit: second) + """ + self.mutex.acquire() + try: + self.data[key] = ExpireValue( + value, expire == None and -1 or int(time.time()) + expire) + finally: + self.mutex.release() + + diff --git a/common/const.py b/common/const.py index 6324c82..bd5d829 100644 --- a/common/const.py +++ b/common/const.py @@ -9,6 +9,7 @@ TELEGRAM = "telegram" SLACK = "slack" HTTP = "http" DINGTALK = "dingtalk" +FEISHU = "feishu" # model OPEN_AI = "openai" diff --git a/config-template.json b/config-template.json index e552c14..f409557 100644 --- a/config-template.json +++ b/config-template.json @@ -71,6 +71,17 @@ "dingtalk_token": "xx", "dingtalk_post_token": "xx", "dingtalk_secret": "xx" + }, + "feishu": { + "image_create_prefix": [ + "画", + "draw", + "Draw" + ], + "port": "8082", + "app_id": "xxx", + "app_secret": "xxx", + "verification_token": "xxx" } }, "common": { diff --git a/requirements.txt b/requirements.txt index db2fa6b..1a46046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ flask flask_socketio itchat-uos==1.5.0.dev0 openai -EdgeGPT \ No newline at end of file +EdgeGPT +requests \ No newline at end of file