diff --git a/server/routes/team.py b/server/routes/team.py index 5f5803a3..736822b8 100644 --- a/server/routes/team.py +++ b/server/routes/team.py @@ -175,29 +175,29 @@ def install_im_application_to_team_by_get_method(team_id, platform): ) app.logger.info("result %r", result) events = [ - "20", - "im.message.message_read_v1", - "im.message.reaction.created_v1", - "im.message.reaction.deleted_v1", - "im.message.recalled_v1", - "im.message.receive_v1", + "20", # 用户和机器人的会话首次被创建 + "im.message.message_read_v1", # 消息已读 + "im.message.reaction.created_v1", # 新增消息表情回复 + "im.message.reaction.deleted_v1", # 删除消息表情回复 + "im.message.recalled_v1", # 撤回消息 + "im.message.receive_v1", # 接收消息 ] scope_ids = [ - "8002", - "100032", - "6081", - "14", - "1", - "21001", - "20001", - "20011", - "3001", - "20012", - "20010", - "3000", - "20008", - "1000", - "20009", + "8002", # 获取应用信息 + "100032", # 获取通讯录基本信息 + "6081", # 以应用身份读取通讯录 + "14", # 获取用户基本信息 + "1", # 获取用户邮箱信息 + "21001", # 获取与更新群组信息 + "20001", # 获取与发送单聊、群组消息 + "20011", # 获取用户在群组中 @ 机器人的消息 + "3001", # 接收群聊中 @ 机器人消息事件 + "20012", # 获取群组中所有消息 + "20010", # 获取用户发给机器人的单聊消息 + "3000", # 读取用户发给机器人的单聊消息 + "20008", # 获取单聊、群组消息 + "1000", # 以应用的身份发消息 + "20009", # 获取上传图片或文件资源 ] hook_url = f"{os.environ.get('DOMAIN')}/api/feishu/hook/{app_id}" return redirect( @@ -260,9 +260,9 @@ def get_task_result_by_id(team_id, task_id): "data": { "task_id": task.id, "status": task.status, - "result": task.result - if isinstance(task.result, list) - else str(task.result), + "result": ( + task.result if isinstance(task.result, list) else str(task.result) + ), }, } ) diff --git a/server/routes/user.py b/server/routes/user.py index 2e148729..ece91c1f 100644 --- a/server/routes/user.py +++ b/server/routes/user.py @@ -1,8 +1,14 @@ from app import app -from flask import Blueprint, jsonify, request, session -from model.team import get_team_list_by_user_id, is_team_admin +from flask import Blueprint, Response, abort, jsonify, request, session +from model.team import ( + get_application_info_by_team_id, + get_team_list_by_user_id, + is_team_admin, +) from model.user import get_user_by_id +from tasks.lark.base import get_bot_by_application_id, get_repo_by_repo_id from utils.auth import authenticated +from utils.utils import download_file bp = Blueprint("user", __name__, url_prefix="/api") @@ -56,4 +62,38 @@ def set_account(): return jsonify({"code": 0, "msg": "success"}) +@bp.route("////image/", methods=["GET"]) +def get_image(team_id, message_id, repo_id, img_key): + """ + 1. 用 img_key 请求飞书接口下载 image + 2. 判断请求来源,如果是 GitHub 调用,则直接返回 image + 3. 用户调用 校验权限 + """ + + def download_and_respond(): + _, im_application = get_application_info_by_team_id(team_id) + bot, _ = get_bot_by_application_id(im_application.id) + image_content = download_file(img_key, message_id, bot, "image") + return Response(image_content, mimetype="image/png") + + # GitHub调用 + user_agent = request.headers.get("User-Agent") + if user_agent and user_agent.startswith("github-camo"): + return download_and_respond() + + # TODO 用户调用(弱需求, 通常来讲此接口不会被暴露), 需要进一步校验权限 + referer = request.headers.get("Referer") + if not referer: + # 公开仓库不校验 + repo = get_repo_by_repo_id(repo_id) + is_private = repo.extra.get("private", False) + app.logger.debug(f"is_private: {is_private}") + + # 私有仓库校验,先登录 + if is_private: + return abort(403) + + return download_and_respond() + + app.register_blueprint(bp) diff --git a/server/tasks/lark/base.py b/server/tasks/lark/base.py index 47b05906..430cf657 100644 --- a/server/tasks/lark/base.py +++ b/server/tasks/lark/base.py @@ -24,6 +24,11 @@ def get_chat_group_by_chat_id(chat_id): def get_repo_name_by_repo_id(repo_id): + repo = get_repo_by_repo_id(repo_id) + return repo.name + + +def get_repo_by_repo_id(repo_id): repo = ( db.session.query(Repo) .filter( @@ -32,7 +37,7 @@ def get_repo_name_by_repo_id(repo_id): ) .first() ) - return repo.name + return repo def get_bot_by_application_id(app_id): diff --git a/server/tasks/lark/chat.py b/server/tasks/lark/chat.py index d6562bc8..64e08ae6 100644 --- a/server/tasks/lark/chat.py +++ b/server/tasks/lark/chat.py @@ -1,5 +1,7 @@ import json import logging +import os +import re from urllib.parse import urlparse from celery_app import app, celery @@ -313,13 +315,8 @@ def create_issue( ) assignees = [code_users[openid][1] for openid in users if openid in code_users] - # 判断 content 中是否有 at - if "mentions" in data["event"]["message"]: - # 替换 content 中的 im_name 为 code_name - body = replace_im_name_to_github_name( - app_id, message_id, {"text": body}, data, team, *args, **kwargs - ) - body = body.replace("\n", "\r\n") + # 处理 body + body = process_desc(app_id, message_id, repo.id, body, data, team, *args, **kwargs) response = github_app.create_issue( team.name, repo.name, title, body, assignees, labels @@ -331,6 +328,44 @@ def create_issue( return response +def process_desc(app_id, message_id, repo_id, desc, data, team, *args, **kwargs): + """ + 处理发给 github 的 desc, 转换@、处理图片、换行 + """ + # 1. 判断 body 中是否有 at + if "mentions" in data["event"]["message"]: + # 替换 body 中的 im_name 为 code_name + desc = replace_im_name_to_github_name( + app_id, message_id, {"text": desc}, data, team, *args, **kwargs + ) + + # 2. 处理 body 中的图片 + desc = replace_images_keys_with_url(desc, team.id, message_id, repo_id) + + # github 只支持 \r\n + return desc.replace("\n", "\r\n") + + +def replace_images_keys_with_url(text, team_id, message_id, repo_id): + """ + replace image_key with image URL. + ![](image_key) -> ![](gitmaya.com/api////image/) + Args: + text (str): original text + + Returns: + str: replaced text + """ + host = os.environ.get("DOMAIN") + replaced_text = re.sub( + r"!\[.*?\]\((.*?)\)", + lambda match: f"![]({host}/api/{team_id}/{repo_id}/{message_id}/image/{match.group(1)})", + text, + ) + + return replaced_text + + @celery.task() def sync_issue( issue_id, issue_link, app_id, message_id, content, data, *args, **kwargs diff --git a/server/tasks/lark/issue.py b/server/tasks/lark/issue.py index 5c4052f1..39aff21f 100644 --- a/server/tasks/lark/issue.py +++ b/server/tasks/lark/issue.py @@ -21,7 +21,7 @@ from utils.lark.issue_manual_help import IssueManualHelp, IssueView from utils.lark.issue_tip_failed import IssueTipFailed from utils.lark.issue_tip_success import IssueTipSuccess -from utils.utils import upload_image +from utils.utils import process_image from .base import ( get_bot_by_application_id, @@ -122,12 +122,12 @@ def gen_issue_card_by_issue(bot, issue, repo_url, team, maunal=False): tags=tags, ) - # 处理 description 中的图片 + # 处理从 github 创建 Issue 时, description 中的图片 description = replace_images_with_keys( issue.description if issue.description else "", bot ) - # 处理 description 中的at + # 处理从 github 创建 Issue 时, description 中的 at description = replace_code_name_to_im_name(description) return IssueCard( @@ -160,7 +160,7 @@ def replace_images_with_keys(text, bot): markdown_pattern = r"!\[.*?\]\((.*?)\)" replaced_text = re.sub( markdown_pattern, - lambda match: f"![]({upload_image(match.group(1), bot)})", + lambda match: f"![]({process_image(match.group(1), bot)})", text, ) @@ -168,7 +168,7 @@ def replace_images_with_keys(text, bot): html_pattern = r"" replaced_text = re.sub( html_pattern, - lambda match: f"![]({upload_image(match.group(1), bot)})", + lambda match: f"![]({process_image(match.group(1), bot)})", replaced_text, ) @@ -601,12 +601,12 @@ def create_issue_comment(app_id, message_id, content, data, *args, **kwargs): ) comment_text = content["text"] - # 判断 content 中是否有 at - if "mentions" in data["event"]["message"]: - # 替换 content 中的 im_name 为 code_name - comment_text = replace_im_name_to_github_name( - app_id, message_id, content, data, team, *args, **kwargs - ) + from tasks.lark.chat import process_desc + + # 处理 desc + comment_text = process_desc( + app_id, message_id, repo.id, comment_text, data, team, *args, **kwargs + ) response = github_app.create_issue_comment( team.name, repo.name, issue.issue_number, comment_text diff --git a/server/utils/utils.py b/server/utils/utils.py index cccb0c13..0f52b161 100644 --- a/server/utils/utils.py +++ b/server/utils/utils.py @@ -1,15 +1,23 @@ import logging +import os import httpx from utils.redis import stalecache +def process_image(url, bot): + if not url or not url.startswith("http"): + return "" + + if url.startswith(f"{os.environ.get('DOMAIN')}/api"): + return url.split("/")[-1] + return upload_image(url, bot) + + # 使用 stalecache 装饰器,以 url 作为缓存键 @stalecache(expire=3600, stale=600) def upload_image(url, bot): logging.info("upload image: %s", url) - if not url or not url.startswith("http"): - return "" response = httpx.get(url, follow_redirects=True) if response.status_code == 200: # 函数返回值: iamg_key 存到缓存中 @@ -30,6 +38,18 @@ def upload_image_binary(img_bin, bot): return response["data"]["image_key"] +@stalecache(expire=3600, stale=600) +def download_file(file_key, message_id, bot, file_type="image"): + """ + 获取消息中的资源文件,包括音频,视频,图片和文件,暂不支持表情包资源下载。当前仅支持 100M 以内的资源文件的下载 + """ + # open-apis/im/v1/images/{img_key} 接口只能下载机器人自己上传的图片 + url = f"{bot.host}/open-apis/im/v1/messages/{message_id}/resources/{file_key}?type={file_type}" + + response = bot.get(url) + return response.content + + def query_one_page(query, page, size): offset = (page - 1) * int(size) return (