Files
gzth/yuthon_wechat/models/wecom_apps.py
T
2026-06-15 09:32:17 +08:00

517 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import json
from datetime import datetime, timedelta
from urllib.parse import quote
from odoo import models, fields, api
import requests
_logger = logging.getLogger(__name__)
class WeComApps(models.Model):
_name = "wecom.apps"
name = fields.Char(string="应用名称")
corpid = fields.Char(string="corpid")
agent_id = fields.Char(string="agent_id")
corpsecret = fields.Char(string="corpsecret")
company_id = fields.Many2one("res.company", string="公司", default=lambda self: self.env.company)
access_token = fields.Char(string="access_token", default="1")
token_expiration_time = fields.Datetime(string="Token失效时间")
wx_work_image_path = fields.Char(string="企业微信图片保存地址")
# 接收消息服务器回调配置
callback_token = fields.Char(string="回调Token", help="接收消息服务器配置的Token,用于消息签名验证")
encoding_aes_key = fields.Char(string="EncodingAESKey", help="接收消息服务器配置的消息加解密密钥,43位字符")
def sync_access_token(self):
"""
获取access_token
:return: access_token
"""
data = {
"corpid": self.corpid,
"corpsecret": self.corpsecret
}
access_token = self.sudo().env['wecom.api'].function_get_access_token(data)
self.access_token = access_token
return access_token
def sync_function_api(self, apps_type, api_function, data, category="default"):
response = self.sync_function_api_base(apps_type, api_function, data, category)
result_data = json.loads(response.content)
return result_data
def sync_function_api_base(self, apps_type, api_function, data, category="default"):
wecom_app = self.sudo().env["wecom.apps.config"].search([("wecom_apps_type", "=", apps_type), ("wecom_apps_category", "=", category)], limit=1).wecom_apps_id
response = self.sudo().env['wecom.api'].function_http_request(api_function, data, wecom_app.access_token)
result_data = json.loads(response.content)
if result_data.get("errcode", 0) in [40014, 42001]:
wecom_app.sync_access_token()
wecom_app = self.sudo().env["wecom.apps.config"].search([("wecom_apps_type", "=", apps_type), ("wecom_apps_category", "=", category)],
limit=1).wecom_apps_id
response = self.sudo().env['wecom.api'].function_http_request(api_function, data, wecom_app.access_token)
return response
def sync_department_list(self):
wecom_departments = []
result_data = self.sync_function_api("contacts", "DEPARTMENT_LIST", {})
if result_data["errcode"] == 0:
wecom_departments = result_data.get("department", [])
return True, wecom_departments
return False, result_data
def sync_checkin_data(self, data):
response = self.sync_function_api_base("attendance", "GET_CHECKIN_DATA", data)
result_data = json.loads(response.content)
if result_data["errcode"] == 0:
return True, response
return False, response
def sync_media_get(self, data):
response = self.sync_function_api_base("attendance", "MEDIA_GET", data)
result_data = json.loads(response.content)
if result_data["errcode"] == 0:
return True, response
return False, response
def download_agent_media(self, media_id):
"""下载 AI 智能体(agent 类别)的临时素材,返回 (bytes, mimetype)。
企业微信 media/get 是 GET 请求,返回原始二进制,不走 JSON API 封装。
如果 token 过期会自动刷新重试一次。
:param media_id: 企业微信临时素材 media_id
:return: (bytes, str) 或 (None, str错误原因)
"""
import requests as _requests
wecom_app_cfg = self.sudo().env['wecom.apps.config'].search([
('wecom_apps_type', '=', 'ai_callback'),
('wecom_apps_category', '=', 'agent'),
], limit=1)
if not wecom_app_cfg or not wecom_app_cfg.wecom_apps_id:
return None, '未找到 AI 智能体企业微信应用配置'
wecom_app = wecom_app_cfg.wecom_apps_id
token = wecom_app.access_token
if not token or token == '1':
wecom_app.sync_access_token()
token = wecom_app.access_token
url = f'https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}'
def _do_download():
resp = _requests.get(url, timeout=30)
content_type = resp.headers.get('Content-Type', '')
# 如果返回 JSON,说明出错了
if 'application/json' in content_type or resp.text.startswith('{'):
try:
err = json.loads(resp.text)
return None, err.get('errmsg', f'下载失败 errcode={err.get("errcode")}')
except Exception:
return None, f'下载返回异常: {resp.text[:200]}'
# 成功:返回二进制
return resp.content, content_type
content, error = _do_download()
if content is None and error:
err_lower = error.lower()
# token 过期
if '40014' in err_lower or '42001' in err_lower or 'access_token' in err_lower:
_logger.info('[WECOM-MEDIA] token 过期,刷新后重试')
wecom_app.sync_access_token()
token = wecom_app.access_token
url = f'https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}'
content, error = _do_download()
return content, error or ''
def sync_user_list(self, department_id):
user_list = []
data = {
"department_id": department_id
}
result_data = self.sync_function_api("contacts", "USER_LIST", data)
if result_data["errcode"] == 0:
user_list = result_data.get("userlist", [])
return True, user_list
return False, result_data
def sync_send_message(self, category='default', user_id=None, message=None):
wecom_app = self.sudo().env["wecom.apps.config"].search([("wecom_apps_type", "=", "message_send"), ("wecom_apps_category", "=", category)], limit=1).wecom_apps_id
data = {
'touser': user_id,
'msgtype': 'text',
'agentid': wecom_app.agent_id,
'text': {
'content': message
}
}
result_data = self.sync_function_api("message_send", "MESSAGE_SEND", data, category)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data
def back_message(self, msg_id='', category="default"):
token = ''
if category == 'task':
token = self.env['wecom.apps'].search([('name', '=', '日程消息通知')])['access_token']
if category == 'notice':
token = self.env['wecom.apps'].search([('name', '=', '通知公告消息通知')])['access_token']
if category == 'approval':
token = self.env['wecom.apps'].search([('name', '=', '审批消息通知')])['access_token']
data = {'msgid': msg_id}
url = f'https://qyapi.weixin.qq.com/cgi-bin/message/recall?access_token={token}'
_logger.info(f"------------------------获取msg_id:{msg_id}tokenID:{token}当前url{url}-------------------------------")
requests.post(url=url, data=json.dumps(data))
def sync_send_message_textcard(self, content_item='', touser='', category='default', user_id=None, title="", description="", url="", btntxt="详情", exception_alerts = False):
"""
touser 否 成员ID列表(消息接收者,多个接收者用‘|’分隔,最多支持1000个)。特殊情况:指定为@all,则向关注该企业应用的全部成员发送
toparty 否 部门ID列表,多个接收者用‘|’分隔,最多支持100个。当touser为@all时忽略本参数
totag 否 标签ID列表,多个接收者用‘|’分隔,最多支持100个。当touser为@all时忽略本参数
msgtype 是 消息类型,此时固定为:textcard
agentid 是 企业应用的id,整型。企业内部开发,可在应用的设置页面查看;第三方服务商,可通过接口 获取企业授权信息 获取该参数值
title 是 标题,不超过128个字符,超过会自动截断(支持id转译)
description 是 描述,不超过512个字符,超过会自动截断(支持id转译)
url 是 点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https)
btntxt 否 按钮文字。 默认为“详情”, 不超过4个文字,超过自动截断。
enable_id_trans 否 表示是否开启id转译,0表示否,1表示是,默认0
enable_duplicate_check 否 表示是否开启重复消息检查,0表示否,1表示是,默认0
duplicate_check_interval 否 表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时
{
"touser" : "UserID1|UserID2|UserID3",
"toparty" : "PartyID1 | PartyID2",
"totag" : "TagID1 | TagID2",
"msgtype" : "textcard",
"agentid" : 1,
"textcard" : {
"title" : "领奖通知",
"description" : "<div class=\"gray\">2016年9月26日</div> <div class=\"normal\">恭喜你抽中iPhone 7一台,领奖码:xxxx</div><div class=\"highlight\">请于2016年10月10日前联系行政同事领取</div>",
"url" : "URL",
"btntxt":"更多"
},
"enable_id_trans": 0,
"enable_duplicate_check": 0,
"duplicate_check_interval": 1800
}
:param category:
:param user_id:
:param title:
:param description:
:param url:
:param btntxt:
:return:
"""
wecom_app = self.sudo().env["wecom.apps.config"].search([("wecom_apps_type", "=", "message_send"), ("wecom_apps_category", "=", category)], limit=1).wecom_apps_id
if category == 'oa':
data = {
'touser': user_id,
'msgtype': 'textcard',
'agentid': wecom_app.agent_id,
"textcard": {
"title": title,
"description": description,
"url": url,
"btntxt": btntxt
},
}
elif category in ['task', 'att_ok', 'salary', 'can']:
data = {
'touser': user_id,
'msgtype': 'textcard',
'agentid': wecom_app.agent_id,
"textcard": {
"title": title,
"description": description,
"url": url,
"btntxt": btntxt
},
}
elif category == 'att':
if exception_alerts == True:
data = {
'touser': user_id,
'msgtype': 'textcard',
'agentid': wecom_app.agent_id,
"textcard": {
"title": title,
"description": description,
"url": url,
"btntxt": btntxt
},
}
else:
data = {
"touser": user_id,
"msgtype": "miniprogram_notice",
"miniprogram_notice": {
"appid": "wxda530cb253dec90d",
"page": "pages/clock_in/clock_in",
"title": "打卡提醒",
"description": description,
"content_item": content_item
},
}
else:
# 默认分支:优先用 user_id,其次用 touser
target_user = user_id or touser
data = {
'touser': target_user,
'msgtype': 'textcard',
'agentid': wecom_app.agent_id,
"textcard": {
"title": title,
"description": description,
"url": url,
"btntxt": btntxt
},
}
service_address = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
_logger.info(f"系统当前地址{service_address}-------------------------------")
if 'oa' in str(service_address):
_logger.info(f"----------地址识别成功,已经发送消息通知---------------------")
_logger.info(f"----------地址识别成功,已经发送消息通知---------------------")
result_data = self.sync_function_api("message_send", "MESSAGE_SEND", data, category)
_logger.info(f"------------------------接口收到响应返回值{result_data}-------------------------------")
_logger.info(f"------------------------{service_address}-------------------------------")
if result_data.get("errcode", None) == 0:
# 发送成功:写消息提醒日志,记录 msg_id 和 category 用于撤回
employee_id = self.sudo().env["res.users"].search([("login", "=", user_id)], limit=1).employee_ids
notice_type = 'attendance' if '记得打卡' in description else 'process'
self.env["attendance.message.log"].create({
"employee_id": employee_id.id,
"content": description,
"notice_type": notice_type,
"service_address": service_address,
'update_date': fields.Datetime.now(),
"message_url": url,
"msg_id": result_data.get('msgid'),
"category": category,
})
return True, result_data
# 卡片发送失败时,把返回值当文本推送给用户方便排查
if touser:
self.sync_send_message(category=category, user_id=touser, message=f"卡片推送失败: {result_data}")
elif user_id:
self.sync_send_message(category=category, user_id=user_id, message=f"卡片推送失败: {result_data}")
return False, result_data
return False, {"error": "service_address 不包含 oa"}
def sync_user_update(self, user_id=None, name=None, mobile=None, biz_mail=None, department=None):
data = {}
if user_id:
data.update({'userid': user_id})
if name:
data.update({'name': name})
if mobile:
data.update({'mobile': mobile})
if biz_mail:
data.update({'biz_mail': biz_mail})
if department:
data.update({'department': [department]})
result_data = self.sync_function_api("user_create", "USER_UPDATE", data)
if result_data.get("errcode", None) == 0:
return True, result_data.get("errmsg", "")
if result_data.get("errcode", None) in [60111]:
"""
{'errcode': 60111, 'errmsg': 'invalid string value `test111`. userid not found, hint: [1720622364419201522972678], from ip: 82.156.218.102, more info at https://open.work.weixin.qq.com/devtool/query?e=60111'}
"""
result_data = self.sync_function_api("user_create", "USER_CREATE", data)
if result_data.get("errcode", None) == 0:
return True, result_data.get("errmsg", "")
return False, result_data.get("errmsg", "")
def sync_department_update(self, wecom_department_id=None, name=None, name_en=None, parentid=None):
data = {}
if wecom_department_id:
data.update({'id': wecom_department_id})
if name:
data.update({'name': name})
if name_en:
data.update({'name_en': name_en})
if parentid:
data.update({'parentid': parentid})
if wecom_department_id:
result_data = self.sync_function_api("user_create", "DEPARTMENT_UPDATE", data)
else:
result_data = self.sync_function_api("user_create", "DEPARTMENT_CREATE", data)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data.get("errmsg", "")
def sync_meeting_create(self, admin_userid, title, meeting_start, meeting_duration, *args, **kwargs):
data = {
"admin_userid": admin_userid,
"title": title,
"meeting_start": meeting_start,
"meeting_duration": meeting_duration,
}
data.update(kwargs)
result_data = self.sync_function_api("meeting", "MEETING_CREATE", data)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data.get("errmsg", "")
def sync_meeting_update(self, meetingid, *args, **kwargs):
data = {
"admin_userid": meetingid,
}
data.update(kwargs)
result_data = self.sync_function_api("meeting", "MEETING_UPDATE", data)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data.get("errmsg", "")
def sync_meeting_cancel(self, meetingid, sub_meetingid=None):
data = {
"meetingid": meetingid,
}
if sub_meetingid:
data.update({'sub_meetingid': sub_meetingid})
result_data = self.sync_function_api("meeting", "MEETING_CANCEL", data)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data.get("errmsg", "")
def sync_meeting_get_info(self, meetingid=None, meeting_code=None):
data = {}
if meetingid:
data.update({'meetingid': meetingid})
if meeting_code:
data.update({'meeting_code': meeting_code})
result_data = self.sync_function_api("meeting", "MEETING_GET_INFO", data)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data.get("errmsg", "")
def sync_get_realtime_attendee_list(self, meetingid, sub_meetingid=None, cursor=None, limit=None):
data = {
"meetingid": meetingid
}
if sub_meetingid:
data.update({'sub_meetingid': sub_meetingid})
if cursor:
data.update({'cursor': cursor})
if limit:
data.update({'limit': limit})
result_data = self.sync_function_api("meeting", "MEETING_GET_REALTIME_ATTENDEE_LIST", data)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data.get("errmsg", "")
def sync_get_user_meetingid(self, userid, cursor=None, limit=None, begin_time=None, end_time=None):
data = {
"userid": userid
}
if cursor:
data.update({'cursor': cursor})
if limit:
data.update({'limit': limit})
if begin_time:
data.update({'begin_time': begin_time})
if end_time:
data.update({'end_time': end_time})
result_data = self.sync_function_api("meeting", "MEETING_GET_USER_MEETINGID", data)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data.get("errmsg", "")
def sync_get_attendee_list(self, meetingid, *args, **kwargs):
data = {
"meetingid": meetingid
}
data.update(kwargs)
result_data = self.sync_function_api("meeting", "GET_ATTENDEE_LIST", data)
if result_data.get("errcode", None) == 0:
return True, result_data
return False, result_data.get("errmsg", "")
# @api.model
# def generate_oauth_login_url(self, target_url, category='default'):
# """
# 生成企业微信OAuth登录链接,用于在企业微信消息中发送
#
# 参数:
# target_url: 登录成功后要跳转的目标URL(完整URL或相对路径)
#
# 返回:
# OAuth登录链接URL
# """
# base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
# # 如果target_url不是完整URL,补全为完整URL
# if not target_url.startswith('http'):
# if target_url.startswith('/'):
# target_url = f"{base_url}{target_url}"
# else:
# target_url = f"{base_url}/{target_url}"
# # 编码目标URL作为state参数
# encoded_target_url = quote(target_url, safe='')
# # 编码回调URI
# redirect_uri = quote(f'{base_url}/wechat/login_by_code', safe='')
#
# # 企业微信OAuth参数
# if category == 'oa':
# agent_id = self.env['wecom.apps'].search([('name', '=', '审批消息通知')])['agent_id']
# appid = self.env['wecom.apps'].search([('name', '=', '审批消息通知')])['corpid']
# else:
# appid = 'wwc955c117b738b052'
# agent_id = '1000120'
#
# # 生成OAuth登录链接
# oauth_url = (
# f'https://open.weixin.qq.com/connect/oauth2/authorize?'
# f'appid={appid}&'
# f'redirect_uri={redirect_uri}&'
# f'response_type=code&'
# f'scope=snsapi_base&'
# f'state={encoded_target_url}&'
# f'agentid={agent_id}#wechat_redirect'
# )
#
# return oauth_url
@api.model
def generate_oauth_login_url(self, target_url, user_id=None):
"""
user_id = record.employee_id.user_id.id if record.employee_id.user_id else None
url = self.sudo().env["wecom.apps"].generate_oauth_login_url(target_url, user_id=user_id)
生成企业微信免登录链接,用于在企业微信消息中发送
与 /wechat/login_by_user_id 接口联动
参数:
target_url: 登录成功后要跳转的目标URL(完整URL或相对路径)
user_id: 用户ID (res.users的id),必需参数
返回:
免登录链接URL,格式: /wechat/login_by_user_id?user_id=xxx&redirect_url=xxx
"""
if not user_id:
_logger.warning("generate_oauth_login_url: user_id 参数未提供,无法生成免登录链接")
return target_url # 如果未提供user_id,返回原始URL
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
# 如果target_url不是完整URL,补全为完整URL
if not target_url.startswith('http'):
if target_url.startswith('/'):
target_url = f"{base_url}{target_url}"
else:
target_url = f"{base_url}/{target_url}"
# 编码目标URL作为redirect_url参数
encoded_target_url = quote(target_url, safe='')
# 生成免登录链接,使用 /wechat/login_by_user_id 接口
login_url = (
f'{base_url}/wechat/login_by_user_id?'
f'user_id={user_id}&'
f'redirect_url={encoded_target_url}'
)
return login_url