commit ae6491c81af396d1172711ed96751d9af223180c Author: H Vs Date: Fri Mar 21 17:29:00 2025 +0800 初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2732b7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +.DS_Store +.idea +.vscode +.venv +.vs +.wechaty/ +__pycache__/ +venv* +*.pyc +config.json +QR.png +nohup.out +tmp +plugins.json +itchat.pkl +*.log +user_datas.pkl +chatgpt_tool_hub/ +plugins/**/ +!plugins/bdunit +!plugins/dungeon +!plugins/finish +!plugins/godcmd +!plugins/tool +!plugins/banwords +!plugins/banwords/**/ +plugins/banwords/__pycache__ +plugins/banwords/lib/__pycache__ +!plugins/hello +!plugins/role +!plugins/keyword +!plugins/linkai +!plugins/healthai +client_config.json +!config.json +!plugins.json +tmp/ +logs/ +cmd.txt \ No newline at end of file diff --git a/ai-ops-wx_run.sh b/ai-ops-wx_run.sh new file mode 100644 index 0000000..4521a95 --- /dev/null +++ b/ai-ops-wx_run.sh @@ -0,0 +1,29 @@ +#!/bin/bash +environment=$1 +version=$2 +echo "环境变量为${environment},版本为$version!" +if [[ ${environment} == 'production' ]]; then + echo "开始远程构建容器" + docker stop ai-ops-wx || true + docker rm ai-ops-wx || true + docker rmi -f $(docker images | grep registry.cn-shanghai.aliyuncs.com/gps_card/ai-ops-wechat | awk '{print $3}') + #docker login --username=telpo_linwl@1111649216405698 --password=telpo#1234 registry.cn-shanghai.aliyuncs.com + docker login --username=rzl_wangjx@1111649216405698 --password=telpo.123 registry.cn-shanghai.aliyuncs.com + docker pull registry.cn-shanghai.aliyuncs.com/gps_card/ai-ops-wechat:$version + docker run -p 6560:5000 -d -e environment=production -v /home/data/ai-ops-wx/logs:/app/logs -v /home/data/ai-ops-wx/tmp:/app/tmp --restart=always --name ai-ops-wx registry.cn-shanghai.aliyuncs.com/gps_card/ai-ops-wechat:$version; + #删除产生的None镜像 + docker rmi -f $(docker images | grep none | awk '{print $3}') + docker ps -a + +elif [[ ${environment} == 'test' || ${environment} == 'presure' ]]; then + echo "开始在测试环境远程构建容器" + docker stop ai-ops-wx || true + docker rm ai-ops-wx || true + docker rmi -f $(docker images | grep 139.224.254.18:5000/ssjl/ai-ops-wechat | awk '{print $3}') + docker pull 139.224.254.18:5000/ssjl/ai-ops-wechat:$version + docker run -p 6560:5000 -d -e environment=test -v /home/data/ai-ops-wx/logs:/app/logs -v /home/data/ai-ops-wx/tmp:/app/tmp --restart=always --name ai-ops-wx 139.224.254.18:5000/ssjl/ai-ops-wechat:$version; + #删除产生的None镜像 + docker rmi -f $(docker images | grep none | awk '{print $3}') + docker ps -a + +fi \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/celery_app.py b/app/celery_app.py new file mode 100644 index 0000000..ea7b020 --- /dev/null +++ b/app/celery_app.py @@ -0,0 +1,54 @@ +# from celery import Celery + +# # 创建 Celery 应用 +# celery_app = Celery( +# 'ai_ops_wechat_app', +# broker='redis://:telpo%231234@192.168.2.121:8090/3', +# backend='redis://:telpo%231234@192.168.2.121:8090/3', +# ) + +# # 配置 Celery +# celery_app.conf.update( +# task_serializer='json', +# accept_content=['json'], +# result_serializer='json', +# timezone='Asia/Shanghai', +# enable_utc=True, +# ) + +# #celery_app.autodiscover_tasks(['app.tasks']) + +# from celery import Celery + +# def make_celery(app): +# celery = Celery( +# app.import_name, +# backend=app.config['CELERY_RESULT_BACKEND'], +# broker=app.config['CELERY_BROKER_URL'] +# ) +# celery.conf.update(app.config) +# # 自动发现任务 +# celery.autodiscover_tasks(['app.tasks']) +# return celery + +# # 初始化 Flask +# app = Flask(__name__) +# app.config.update( +# CELERY_BROKER_URL='redis://:telpo%231234@192.168.2.121:8090/3', +# CELERY_RESULT_BACKEND='redis://:telpo%231234@192.168.2.121:8090/3' +# ) + +# celery = make_celery(app) + +from celery import Celery + +# 配置 Celery +celery = Celery( + "worker", + broker="redis://:telpo%231234@192.168.2.121:8090/3", + backend="redis://:telpo%231234@192.168.2.121:8090/3", + include=['app.tasks'] +) + +# 自动发现任务 +celery.autodiscover_tasks(['app.tasks']) diff --git a/app/endpoints/__init__.py b/app/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/endpoints/agent_endpoint.py b/app/endpoints/agent_endpoint.py new file mode 100644 index 0000000..66443ce --- /dev/null +++ b/app/endpoints/agent_endpoint.py @@ -0,0 +1,200 @@ +from fastapi import APIRouter,Request,FastAPI +from pydantic import BaseModel +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ValidationError +from common.log import logger + +from model.models import AgentConfig,validate_wxid + + +from services.gewe_service import GeWeService,get_gewe_service +from services.redis_service import RedisService +from model.models import AgentConfig,validate_wxid +from common.utils import * +import time,asyncio + +agent_router = APIRouter(prefix="/api/agent") + +class GetAgentLoginRequest(BaseModel): + tel: str + + +class GetWxQRCodeRequest(BaseModel): + tel: str + tokenId:str + regionId:str + agentTokenId:str + + +class LogincCaptchCode(BaseModel): + tokenId: str + captchCode:str + +@agent_router.post("/getlogin", response_model=None) +async def get_login(request: Request, body: GetAgentLoginRequest, ): + tel = body.tel + return await request.app.state.gewe_service.get_login_info_from_cache_async(tel) + +@agent_router.post("/getwxqrcode", response_model=None) +async def get_wx_qrcode(request: Request, body: GetWxQRCodeRequest, ): + tel = body.tel + token_id =body.tokenId + region_id= body.regionId + agent_token_id= body.agentTokenId + loginfo=await request.app.state.gewe_service.get_login_info_from_cache_async(tel) + status=loginfo.get('status','0') + + if status=='1': + msg=f'手机号{tel},wx_token{token_id} 已经微信登录,终止登录流程' + logger.info(msg) + + return {'code': 501, 'message': msg} + now=time.time() + expried_time=int(now)+150 + flag=await request.app.state.gewe_service.acquire_login_lock_async(token_id,150) + if not flag: + msg=f'手机号{tel}, wx_token{token_id} 登录进行中,稍后再试' + logger.info(msg) + return {'code': 501, 'message': msg} + + app_id=loginfo.get('appId','') + qr_code = await request.app.state.gewe_service.get_login_qr_code_async(token_id, app_id,region_id) + + if not qr_code: + msg=f"获取二维码失败,qr_code: {qr_code}" + await request.app.state.gewe_service.release_login_lock_async(token_id) + logger.info(msg) + return {'code': 501, 'message': msg} + + uuid = qr_code.get('uuid',None) + if not uuid: + msg=f"uuid获取二维码失败,uuid: {uuid}" + await request.app.state.gewe_service.release_login_lock_async(token_id) + logger.info(msg) + return {'code': 501, 'message': msg} + + + + app_id = app_id or qr_code.get('appId') + + base64_string = qr_code.get('qrImgBase64',None) + await request.app.state.gewe_service.qrCallback(uuid,base64_string) + hash_key = f"__AI_OPS_WX__:LOGININFO:{tel}" + print(hash_key) + # thread = threading.Thread(target=waitting_login_result, args=(gewe_chat.wxchat,token_id, app_id,region_id, agent_token_id,hash_key, uuid,now)) + # thread.daemon = True + # thread.start() + + loop = asyncio.get_event_loop() + future = asyncio.run_coroutine_threadsafe( + waitting_login_result(request,token_id, app_id,region_id, agent_token_id,hash_key, uuid,now), + loop + ) + + return { + "tokenId": token_id, + "tel": tel, + "base64Img": base64_string, + "expiredTime": expried_time, + } + +async def waitting_login_result(request: Request, token_id, app_id,region_id, agent_token_id,hash_key, uuid,start_time): + agent_tel=hash_key.split(":")[-1] + try: + while True: + now = time.time() + if now - start_time > 150: + logger.info(f'{token_id} 使用 {app_id} 扫二维码登录超时') + break + + logger.info(f"{token_id} 使用 {app_id},等待扫码登录,二维码有效时间 {150 - int(now - start_time)} 秒") + captch_code = await request.app.state.gewe_service.get_login_wx_captch_code_from_cache_async(token_id) + captch_code= captch_code if captch_code else '' + logger.info(f"{token_id} 使用 {app_id} 的验证码 {captch_code}") + ret,msg,res = await request.app.state.gewe_service.check_login_async(token_id, app_id, uuid,captch_code) + + if ret == 200: + flag = res.get('status') + if flag == 2: + logger.info(f"登录成功: {res}") + head_img_url=res.get('headImgUrl','') + login_info = res.get('loginInfo', {}) + + wxid=login_info.get('wxid',agent_tel) + + cache_login_info=await request.app.state.gewe_service.get_login_info_from_cache_async(agent_tel) + cache_wxid=cache_login_info.get('wxid','') + + if not cache_login_info and cache_wxid!=wxid and cache_wxid!='': + logger.warning(f"agent_tel {agent_tel} , wxid {wxid} 与 cache_wxid {cache_wxid} 不匹配,登录失败") + await request.app.state.gewe_service.logout_async(token_id,app_id) + # k_message=utils.login_result_message(token_id,agent_tel,region_id,agent_token_id,'') + # kafka_helper.kafka_client.produce_message(k_message) + break + + login_info.update({'appId': app_id, 'uuid': uuid, 'tokenId': token_id,'status': 1,'headImgUrl':head_img_url,'regionId':region_id}) + cache_login_info=await request.app.state.redis_service.get_hash(hash_key) + + if 'appId' not in cache_login_info: + login_info.update({"create_at":int(time.time()),"modify_at":int(time.time())}) + # 默认配置 + config=AgentConfig.model_validate({ + "chatroomIdWhiteList": [], + "agentTokenId": agent_token_id, + "agentEnabled": False, + "addContactsFromChatroomIdWhiteList": [], + "chatWaitingMsgEnabled": True + }) + else: + login_info.update({"modify_at":int(time.time())}) + # 已有配置 + config_cache=await request.app.state.gewe_service.get_wxchat_config_from_cache_async(wxid) + config=AgentConfig.model_validate(config_cache) + + cleaned_login_info = {k: (v if v is not None else '') for k, v in login_info.items()} + #wxid=cleaned_login_info.get('wxid',agent_tel) + # 保存配置信息 + config_dict=config.model_dump() + await request.app.state.gewe_service.save_wxchat_config_async(wxid,config_dict) + + # 保存登录信息 + await request.app.state.redis_service.set_hash(hash_key, cleaned_login_info) + await request.app.state.gewe_service.release_login_lock_async(token_id) + + # 登录结果推送到kafka + k_message=login_result_message(token_id,agent_tel,region_id,agent_token_id,wxid) + await request.app.state.kafka_service.send_message_async(k_message) + + + # 同步联系人列表 + ret,msg,contacts_list=await request.app.state.gewe_service.fetch_contacts_list_async(token_id,app_id) + if ret!=200: + logger.warning(f"同步联系人列表失败: {ret}-{msg}") + break + friend_wxids = [c for c in contacts_list['friends'] if c not in ['fmessage', 'medianote','weixin','weixingongzhong']] # 可以调整截取范围 + data=await request.app.state.gewe_service.save_contacts_brief_to_cache_async(token_id, app_id, wxid, friend_wxids) + chatrooms=contacts_list['chatrooms'] + # 同步群列表 + logger.info(f'{wxid} 的群数量 {len(chatrooms)}') + logger.info(f'{wxid} 同步群列表') + await request.app.state.gewe_service.save_groups_info_to_cache_async(token_id, app_id, wxid, chatrooms) + logger.info(f'{wxid} 同步群成员') + # 同步群成员 + await request.app.state.gewe_service.save_groups_members_to_cache_async(token_id, app_id, wxid, chatrooms) + logger.info(f'{wxid} 好友信息推送到kafka') + # 联系人推送到kafka + k_message=wx_all_contacts(wxid,data) + await request.app.state.kafka_service.send_message_async(k_message) + break + else: + logger.info(f"登录检查中: {ret}-{msg}-{res}") + await asyncio.sleep(5) + finally: + await request.app.state.gewe_service.release_login_lock_async(token_id) + +@agent_router.post("/logincaptchcode", response_model=None) +async def login_captch_code(request: Request, body: LogincCaptchCode, ): + token_id = body.tokenId + captch_code=body.captchCode + res=await request.app.state.gewe_service.save_login_wx_captch_code_to_cache_async(token_id,captch_code) + return {'message': '操作成功'} \ No newline at end of file diff --git a/app/endpoints/config_endpoint.py b/app/endpoints/config_endpoint.py new file mode 100644 index 0000000..affb6b2 --- /dev/null +++ b/app/endpoints/config_endpoint.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter,Request +from pydantic import BaseModel +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ValidationError + + +from services.gewe_service import GeWeService,get_gewe_service +from services.redis_service import RedisService +from model.models import AgentConfig,validate_wxid + + +config_router = APIRouter(prefix="/api/wxchat") + +# 定义请求体的 Pydantic 模型 +class GetConfigRequest(BaseModel): + wxid: str + + +class SaveConfigRequest(BaseModel): + wxid: str + + +@config_router.post("/getconfig",response_model=None) +@validate_wxid +async def get_config(request: Request, body: GetConfigRequest): + wxid = body.wxid + + # k,loginfo=await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + # if not k: + # return {"code":404,"message":f"{wxid} 没有对应的登录信息"} + config=await request.app.state.gewe_service.get_wxchat_config_from_cache_async(wxid) + + return config + + + +@config_router.post("/saveconfig",response_model=None) +@validate_wxid +async def save_config(request: Request, body: SaveConfigRequest): + wxid = body.get("wxid") + data = body.get("data") + # k,loginfo=await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + # if not k: + # return {"code":404,"message":f"{wxid} 没有对应的登录信息"} + try: + # 使用 Pydantic 严格校验数据类型和结构 + validated_config = AgentConfig.model_validate(data) + except ValidationError as e: + return {'code': 407, 'message': e.errors().__str__()} + + await request.app.state.gewe_service.save_wxchat_config_async(wxid, data) + return {'wxid': wxid, 'config': data} + \ No newline at end of file diff --git a/app/endpoints/contacts_endpoint.py b/app/endpoints/contacts_endpoint.py new file mode 100644 index 0000000..1cc3262 --- /dev/null +++ b/app/endpoints/contacts_endpoint.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter,Request +from pydantic import BaseModel +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ValidationError +from model.models import AgentConfig,validate_wxid + + + +contacts_router = APIRouter(prefix="/api/contacts") + +class GetContactsRequest(BaseModel): + wxid: str + cache: bool = True # 默认为 True + +class DeleteContactsRequest(BaseModel): + wxid: str + friendWxid:str + +@contacts_router.post("/getfriends",response_model=None) +async def get_friends(request: Request, body: GetContactsRequest): + wxid = body.wxid + cache=body.cache + k,loginfo=await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + print(k,loginfo) + if not k: + return {"code":404,"message":f"{wxid} 没有对应的登录信息"} + + + + if cache: + return await request.app.state.gewe_service.get_contacts_brief_from_cache_async(wxid) + else: + token_id=loginfo.get('tokenId','') + app_id=loginfo.get('appId','') + ret,msg,contacts_list=await request.app.state.gewe_service.fetch_contacts_list_async(token_id,app_id) + if ret!=200: + return {'code':ret,'message':msg} + friend_wxids = [c for c in contacts_list['friends'] if c not in ['fmessage', 'medianote','weixin','weixingongzhong']] # 可以调整截取范围 + data=await request.app.state.gewe_service.save_contacts_brief_to_cache_async(token_id, app_id, wxid, friend_wxids) + print(f'{wxid}获取实时好友信息') + return data + + + +@contacts_router.post("/deletefriend",response_model=None) +async def delete_friends(request: Request, body: DeleteContactsRequest): + wxid = body.wxid + friend_wxid = body.friendWxid + k,loginfo=await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + if not k: + return {"code":404,"message":f"{wxid} 没有用户信息"} + token_id=loginfo.get('tokenId','') + app_id=loginfo.get('appId','') + + ret, msg, data = await request.app.state.gewe_service.wxchat.delete_friend_async(token_id, app_id, friend_wxid) + if ret !=200: + return { + 'code': ret, + 'message': "删除好友失败" + } + + ret, msg, contacts_list = await request.app.state.gewe_service.fetch_contacts_list_async(token_id, app_id) + if ret !=200: + + return { + 'code': ret, + 'message': '获取联系人列表失败' + } + + friend_wxids = [c for c in contacts_list['friends'] if c not in ['fmessage', 'medianote','weixin','weixingongzhong']] # 可以调整截取范围 + data=await request.app.state.gewe_service.save_contacts_brief_to_cache_async(token_id, app_id, wxid, friend_wxids) + return data \ No newline at end of file diff --git a/app/endpoints/groups_endpoint.py b/app/endpoints/groups_endpoint.py new file mode 100644 index 0000000..fd5a615 --- /dev/null +++ b/app/endpoints/groups_endpoint.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter,Request +from pydantic import BaseModel +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ValidationError +from model.models import AgentConfig,validate_wxid,auth_required_time +import threading +import asyncio + + +groups_router = APIRouter(prefix="/api/groups") + +class GetChatroomInfoRequest(BaseModel): + wxid: str + +class GetChatroomMenberListRequest(BaseModel): + wxid: str + chatroomId:str + +@groups_router.post("/getchatroominfo",response_model=None) +async def get_chatroominfo(request: Request, body: GetChatroomInfoRequest): + wxid = body.wxid + groups= await request.app.state.gewe_service.get_groups_info_from_cache_async(wxid) + return groups + + + +@groups_router.post("/getmenberlist",response_model=None) +async def get_menberlist(request: Request, body: GetChatroomMenberListRequest): + wxid = body.wxid + chatroom_id=body.chatroomId + _,loginfo=await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + token_id=loginfo.get('tokenId') + app_id=loginfo.get('appId') + + ret, msg, data = await request.app.state.gewe_service.get_group_memberlist_async(token_id, app_id, chatroom_id) + if ret != 200: + return { + 'code': ret, + 'message': msg + } + loop = asyncio.get_event_loop() + future = asyncio.run_coroutine_threadsafe( + request.app.state.gewe_service.save_groups_members_to_cache_async(token_id, app_id, wxid, [chatroom_id]), + loop + ) + # Optionally, you can wait for the future to complete if needed + #future.result() + return data \ No newline at end of file diff --git a/app/endpoints/pipeline_endpoint.py b/app/endpoints/pipeline_endpoint.py new file mode 100644 index 0000000..c806d26 --- /dev/null +++ b/app/endpoints/pipeline_endpoint.py @@ -0,0 +1,905 @@ +from voice.ali.ali_voice import AliVoice +from common.log import logger +import xml.etree.ElementTree as ET +import os,json,asyncio,aiohttp + +from voice import audio_convert + +from fastapi import APIRouter,Request +from pydantic import BaseModel +from fastapi import APIRouter, Depends +from typing import Dict, Any +from model.models import AgentConfig,OperationType +from common.utils import * +from common.memory import * + +timeout_duration = 2.0 + +messages_router = APIRouter() + + + +WX_BACKLIST=['fmessage', 'medianote','weixin','weixingongzhong','tmessage'] +@messages_router.post("/messages",response_model=None) +async def get_chatroominfo(request: Request, body: Dict[str, Any]): + msg = body + logger.info(f"收到微信回调消息: {json.dumps(msg, separators=(',', ':'),ensure_ascii=False)}") + type_name =msg.get("TypeName") + app_id = msg.get("Appid") + k, loginfo = await request.app.state.gewe_service.get_login_info_by_app_id_async(app_id) + if not k: + logger.warning('找不到登录信息,不处理') + return {"message": "收到微信回调消息"} + + token_id=loginfo.get('tokenId','') + + wxid = msg.get("Wxid",'') + + if type_name == 'AddMsg': + await handle_self_cmd_async(request,wxid,msg) + msg_data = msg.get("Data") + from_wxid = msg_data["FromUserName"]["string"] + config=await request.app.state.redis_service.get_hash(f"__AI_OPS_WX__:WXCHAT_CONFIG") + wxids=config.keys() + WX_BACKLIST.extend(wxids) + if from_wxid in WX_BACKLIST: + logger.warning(f'微信ID {wxid} 在黑名单,不处理') + return {"message": "收到微信回调消息"} + + await handle_messages_async(request,token_id,msg) + + return {"message": "收到微信回调消息"} + + +async def handle_self_cmd_async(request: Request,wxid,msg): + ''' + 个人微信命令处理 + 如果是个人微信的指令,wxid == from_wxid + commands = { + '启用托管': True, + '关闭托管': False + } + ''' + msg_data=msg.get("Data") + from_wxid=msg_data["FromUserName"]["string"] + msg_content=msg_data["Content"]["string"] + if wxid == from_wxid: + commands = { + '启用托管': True, + '关闭托管': False + } + if msg_content in commands: + c_dict = await request.app.state.gewe_service.get_wxchat_config_from_cache_async(wxid) + if c_dict: + agent_config=AgentConfig.model_validate(c_dict) + agent_config.agentEnabled=commands[msg_content] + + await request.app.state.gewe_service.wxchat.save_wxchat_config_async(wxid, agent_config.model_dump()) + logger.info(f'{wxid} {"启动" if commands[msg_content] else "关闭"}托管') + +async def handle_messages_async(request: Request,token_id,msg): + msg_data=msg.get("Data") + type_name =msg.get("TypeName") + + app_id = msg.get("Appid") + from_wxid=msg_data["FromUserName"]["string"] + msg_content=msg_data["Content"]["string"] + wxid = msg.get("Wxid",'') + + match type_name: + case 'AddMsg': + await handle_add_messages_async(request,token_id,msg,wxid) + case 'ModContacts': + await handle_mod_contacts_async(request,token_id,msg,wxid) + case 'DelContacts': + await handle_del_contacts_async(request,token_id,msg,wxid) + case 'Offline': + await handle_offline_async(request,token_id,msg,wxid) + case _: + logger.warning(f'未知消息类型:{type_name}') + +async def gpt_client_async(request,messages: list, wixd: str, friend_wxid: str): + c = await request.app.state.gewe_service.get_wxchat_config_from_cache_async(wixd) + api_key = c.get('agentTokenId', "sk-jr69ONIehfGKe9JFphuNk4DU5Y5wooHKHhQv7oSnFzVbwCnW65fXO9kvH") + print(f'流程key:{api_key}\n') + + #api_key="sk-jr69ONIehfGKe9JFphuNk4DU5Y5wooHKHhQv7oSnFzVbwCnW65fXO9kvH" #测试 + #api_key="sk-uJDBdKmJVb2cmfldGOvlIY6Qx0AzqWMPD3lS1IzgQYzHNOXv9SKNI" #开发2 + + api_url = "http://106.15.182.218:3000/api/v1/chat/completions" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + + session_id = f'{wixd}-{friend_wxid}' + data = { + "model": "", + "messages": messages, + "chatId": session_id, + "detail": True + } + + logger.info("[CHATGPT] 请求={}".format(json.dumps(data, ensure_ascii=False))) + + async with aiohttp.ClientSession() as session: + try: + async with session.post(url=api_url, headers=headers, data=json.dumps(data), timeout=600) as response: + response.raise_for_status() + response_data = await response.json() + logger.info("[CHATGPT] 响应={}".format(json.dumps(response_data, separators=(',', ':'), ensure_ascii=False))) + return response_data + except aiohttp.ClientError as e: + logger.error(f"请求失败: {e}") + raise + +async def handle_add_messages_async(request: Request,token_id,msg,wxid): + + wx_config =await request.app.state.gewe_service.get_wxchat_config_from_cache_async(wxid) + + if not bool(wx_config.get("agentEnabled",False)): + logger.info(f'微信ID {wxid} 未托管,不处理') + return + app_id = msg.get("Appid") + msg_data = msg.get("Data") + msg_type = msg_data.get("MsgType",None) + from_wxid = msg_data["FromUserName"]["string"] + to_wxid = msg_data["ToUserName"]["string"] + msg_push_content=msg_data.get("PushContent") + + handlers = { + 1: handle_text_async, + 3: handle_image_async, + 34: handle_voice_async, + 42: handle_name_card_async, + 49: handle_xml_async, + 37: handle_add_friend_notice_async, + 10002: handle_10002_msg + } + + # (扫码进群情况)判断受否是群聊,并添加到通信录 + if check_chatroom(from_wxid) or check_chatroom(to_wxid): + logger.info('群信息') + chatroom_id=from_wxid + ret,msg,data=await request.app.state.gewe_service.save_contract_list_async(token_id,app_id,chatroom_id,3) + logger.info(f'保存到通讯录 chatroom_id {chatroom_id} {msg}') + await request.app.state.gewe_service.update_group_info_to_cache_async(token_id,app_id,wxid,chatroom_id) + await request.app.state.gewe_service.update_group_members_to_cache_async(token_id,app_id,wxid,chatroom_id) + handlers[1]=handle_text_group_async + handlers[3]=handle_image_group_async + handlers[34]=handle_voice_group_async + + handler = handlers.get(msg_type) + if handler: + return await handler(request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid) + else: + logger.warning(f"微信回调消息类型 {msg_type} 未处理") + +async def handle_mod_contacts_async(request: Request,token_id,msg,wxid): + ''' + 好友通过验证及好友资料变更的通知消息 + ''' + wxid = msg.get("Wxid") + msg_data = msg.get("Data") + app_id = msg.get("Appid") + # + #handle_mod_contacts(token_id,app_id,wxid,msg_data) + + # + loop = asyncio.get_event_loop() + future = asyncio.run_coroutine_threadsafe( + handle_mod_contacts_worker_async(request,token_id,app_id,wxid,msg_data), + loop + ) + + contact_wxid = msg_data["UserName"]["string"] + nickname=msg_data["NickName"]["string"] + city=msg_data.get("City","") + signature=msg_data.get("Signature","") + province=msg_data.get("Province","") + bigHeadImgUrl=msg_data["SnsUserInfo"]["SnsBgimgId"] + country=msg_data.get("Country","") + sex=msg_data.get("Sex",None) + pyInitial=msg_data["PyInitial"]["string"] + quanPin=msg_data["QuanPin"]["string"] + remark=msg_data.get("Remark").get("string","") + remarkPyInitial=msg_data.get("RemarkPyInitial").get("string","") + remarkQuanPin=msg_data.get("RemarkQuanPin").get("string","") + smallHeadImgUrl=msg_data.get("smallHeadImgUrl","") + + # data=gewe_chat.wxchat.get_brief_info(token_id,app_id,[contact_wxid]) + # contact=data[0] + # alias=contact.get("alias") + #推动到kafka + contact_data = { + "alias": None, + "bigHeadImgUrl": bigHeadImgUrl, + "cardImgUrl": None, + "city": city, + "country": country, + "description": None, + "labelList": None, + "nickName": nickname, + "phoneNumList": None, + "province": province, + "pyInitial": pyInitial, + "quanPin": quanPin, + "remark": remark, + "remarkPyInitial": remarkPyInitial, + "remarkQuanPin": remarkQuanPin, + "sex": sex, + "signature": signature, + "smallHeadImgUrl": smallHeadImgUrl, + "snsBgImg": None, + "userName": contact_wxid + } + input_message=wx_mod_contact_message(wxid,contact_data) + await request.app.state.kafka_service.send_message_async(input_message) + +async def handle_del_contacts_async(request: Request,token_id,msg,wxid): + ''' + 删除好友通知/退出群聊 + ''' + msg_data = msg.get("Data") + username=msg_data["UserName"]["string"] + if check_chatroom(username): + logger.info('退出群聊') + wxid = msg.get("Wxid") + chatroom_id=username + await request.app.state.redis_service.delete_hash_field(f'__AI_OPS_WX__:GROUPS_INFO:{wxid}',chatroom_id) + logger.info(f'清除 chatroom_id{chatroom_id} 数据') + else: + logger.info('删除好友通知') + # 推送到kafka + input_message=wx_del_contact_message(wxid,username) + await request.app.state.kafka_service.send_message_async(input_message) + +async def handle_offline_async(request: Request,token_id,msg,wxid): + ''' + 已经离线 + ''' + wxid = msg.get("Wxid") + app_id = msg.get("Appid") + logger.warning(f'微信ID {wxid}在设备{app_id}已经离线') + k,r=await request.app.state.gewe_service.get_login_info_by_app_id_async(app_id) + print(k) + await request.app.state.redis_service.update_hash_field(k,'status',0) + await request.app.state.redis_service.update_hash_field(k,'modify_at',int(time.time())) + + # 推送到kafka + input_message=wx_offline_message(app_id,wxid) + await request.app.state.kafka_service.send_message_async(input_message) + +async def handle_mod_contacts_worker_async(request:Request,token_id,app_id,wxid,msg_data): + ''' + 好友通过验证及好友资料变更的通知消息 + ''' + logger.info('好友通过验证及好友资料变更的通知消息') + if not check_chatroom(msg_data["UserName"]["string"]): + contact_wxid = msg_data["UserName"]["string"] + + # 更新好友信息 + # 检查好友关系,不是好友则删除 + # ret,msg,check_relation=gewe_chat.wxchat.check_relation(token_id, app_id,[contact_wxid]) + # first_item = check_relation[0] + # check_relation_status=first_item.get('relation') + # logger.info(f'{wxid} 好友 {contact_wxid} 关系检查:{check_relation_status}') + # if check_relation_status != 0: + # gewe_chat.wxchat.delete_contacts_brief_from_cache(wxid, [contact_wxid]) + # logger.info(f'好友关系异常:{check_relation_status},删除好友 {contact_wxid} 信息') + # else: + # gewe_chat.wxchat.save_contacts_brief_to_cache(token_id, app_id, wxid, [contact_wxid]) + + + ret,msg,contacts_list = await request.app.state.gewe_service.fetch_contacts_list_async(token_id, app_id) + # friend_wxids = contacts_list['friends'][3:] # 可以调整截取范围 + # print(friend_wxids) + + #friend_wxids.remove('fmessage') + #friend_wxids.remove('weixin') + + friend_wxids = [c for c in contacts_list['friends'] if c not in ['fmessage', 'medianote','weixin','weixingongzhong','tmessage']] # 可以调整截取范围 + print(f'{wxid}的好友数量 {len(friend_wxids)}') + await request.app.state.gewe_service.save_contacts_brief_to_cache_async(token_id, app_id, wxid, friend_wxids) + + else: + logger.info('群聊好友通过验证及好友资料变更的通知消息') + +async def handle_text_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + ''' + 私聊文本消息 + ''' + msg_content=msg_data["Content"]["string"] + if wxid == from_wxid: #手动发送消息 + logger.info("Active message sending detected") + + await request.app.state.gewe_service.save_contacts_brief_to_cache_async(token_id,app_id,wxid,[to_wxid]) + callback_to_user=msg_data["ToUserName"]["string"] + + input_wx_content_dialogue_message=[{"type": "text", "text": msg_content}] + input_message=dialogue_message(from_wxid,to_wxid,input_wx_content_dialogue_message) + await request.app.state.kafaka_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + else: + callback_to_user=msg_data["FromUserName"]["string"] + + # 创建并启动任务协程,将参数传递给 ai_chat_text 函数 + task = asyncio.create_task( + ai_chat_text_async(request,token_id, app_id, wxid, msg_data, msg_content) + ) + + # 设置定时器,1秒后检查任务是否超时。这里需要使用 lambda 来传递参数 + timeout_timer = asyncio.create_task( + check_timeout_async(task, request,token_id, wxid, app_id, callback_to_user) + ) + + # 等待任务协程完成 + await task + # 取消定时器 + timeout_timer.cancel() + +async def check_timeout_async(task: asyncio.Task, request: Request,token_id, wxid, app_id, callback_to_user): + await asyncio.sleep(timeout_duration) # 等待超时时间 + if not task.done(): + print(f"任务运行时间超过{timeout_duration}秒,token_id={token_id}, app_id={app_id}, callback_to_user={callback_to_user}") + wx_config = await request.app.state.gewe_service.get_wxchat_config_from_cache_async(wxid) + if bool(wx_config.get("chatWaitingMsgEnabled", True)): + await request.app.state.gewe_service.post_text_async(token_id, app_id, callback_to_user, "亲,我正在组织回复的信息,请稍等一会") + +async def ai_chat_text_async(request: Request,token_id, app_id, wxid, msg_data, msg_content): + start_time = time.time() # 记录任务开始时间 + callback_to_user = msg_data["FromUserName"]["string"] + hash_key = f'__AI_OPS_WX__:MESSAGES:{wxid}:{callback_to_user}' + prompt = {"role": "user", "content": [{ + "type": "text", + "text": msg_content + }]} + messages_to_send = await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, prompt) + # 收到的对话 + input_wx_content_dialogue_message = [{"type": "text", "text": msg_content}] + input_message = dialogue_message(callback_to_user, wxid, input_wx_content_dialogue_message) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s", input_message) + + cache_data = USER_INTERACTIVE_CACHE.get(wxid) + if cache_data and cache_data.get('interactive'): + o = get_first_char_if_digit(msg_content) + if o is not None: + userSelectOptions = cache_data.get('options') + if o < len(userSelectOptions): + o = o - 1 + msg_content = userSelectOptions[o].get("value") + messages_to_send = [{"role": "user", "content": msg_content}] + else: + messages_to_send = [{"role": "user", "content": msg_content}] + else: + messages_to_send = [{"role": "user", "content": msg_content}] + res = await gpt_client_async(request,messages_to_send, wxid, callback_to_user) + reply_content = remove_markdown_symbol(res["choices"][0]["message"]["content"]) + description = '' + userSelectOptions = [] + if isinstance(reply_content, list) and any(item.get("type") == "interactive" for item in reply_content): + for item in reply_content: + if item["type"] == "interactive" and item["interactive"]["type"] == "userSelect": + params = item["interactive"]["params"] + description = params.get("description") + userSelectOptions = params.get("userSelectOptions", []) + values_string = "\n".join(option["value"] for option in userSelectOptions) + + if description is not None: + USER_INTERACTIVE_CACHE[wxid] = { + "interactive": True, + "options": userSelectOptions, + } + reply_content = description + '------------------------------\n' + values_string + + elif isinstance(reply_content, list) and any(item.get("type") == "text" for item in reply_content): + USER_INTERACTIVE_CACHE[wxid] = { + "interactive": False + } + text = '' + for item in reply_content: + if item["type"] == "text": + text = item["text"]["content"] + if text == '': + # 去除上次上一轮对话再次请求 + cache_messages_str = await request.app.state.redis_service.get_hash_field(hash_key, "data") + cache_messages = json.loads(cache_messages_str) if cache_messages_str else [] + + if len(cache_messages) >= 3: + cache_messages = cache_messages[:-3] + await request.app.state.redis_service.update_hash_field(hash_key, "data", json.dumps(cache_messages, ensure_ascii=False)) + messages_to_send = await request.app.state.redis_service.save_session_messages_to_cache_async(hash_key, prompt) + res = await gpt_client_async(request,messages_to_send, wxid, callback_to_user) + reply_content = remove_markdown_symbol(res["choices"][0]["message"]["content"]) + + if isinstance(reply_content, list): + reply_content = remove_markdown_symbol(reply_content[0].get('text').get("content")) + else: + reply_content = text + else: + USER_INTERACTIVE_CACHE[wxid] = { + "interactive": False + } + reply_content = remove_markdown_symbol(res["choices"][0]["message"]["content"]) + + await request.app.state.gewe_service.post_text_async(token_id, app_id, callback_to_user, reply_content) + await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, {"role": "assistant", "content": reply_content}) + # 回复的对话 + input_wx_content_dialogue_message = [{"type": "text", "text": reply_content}] + input_message = dialogue_message(wxid, callback_to_user, input_wx_content_dialogue_message, True) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s", input_message) + + end_time = time.time() # 记录任务结束时间 + execution_time = end_time - start_time # 计算执行时间 + logger.info(f"AI回答任务完成,耗时 {execution_time:.2f} 秒") + +async def handle_text_group_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + ''' + 群聊文本消息 + ''' + msg_content=msg_data["Content"]["string"] + msg_push_content=msg_data.get("PushContent") + k,login_info=await request.app.state.gewe_service.get_login_info_by_app_id_async(app_id) + nickname=login_info.get("nickName") + + if wxid == from_wxid: #手动发送消息 + logger.info("Active message sending detected") + + await request.app.state.gewe_service.save_contacts_brief_to_cache_async(token_id,app_id,wxid,[to_wxid]) + callback_to_user=msg_data["ToUserName"]["string"] + + input_wx_content_dialogue_message=[{"type": "text", "text": msg_content}] + input_message=dialogue_message(from_wxid,to_wxid,input_wx_content_dialogue_message) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + else: + c = await request.app.state.gewe_service.get_wxchat_config_from_cache_async(wxid) + chatroom_id_white_list = c.get("chatroomIdWhiteList", []) + + if not chatroom_id_white_list: + logger.info('白名单为空或未定义,不处理') + return + + if from_wxid not in chatroom_id_white_list: + logger.info(f'群ID {from_wxid} 不在白名单中,不处理') + return + + if '在群聊中@了你' in msg_push_content or '@'+nickname in msg_push_content: + + callback_to_user=msg_data["FromUserName"]["string"] + hash_key = f'__AI_OPS_WX__:MESSAGES:{wxid}:{callback_to_user}' + + prompt={"role": "user", "content": [{ + "type": "text", + "text": msg_content + }]} + messages_to_send=await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, prompt) + # 收到的对话 + input_wx_content_dialogue_message=[{"type": "text", "text": msg_content}] + input_message=dialogue_message(callback_to_user,wxid,input_wx_content_dialogue_message) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + + cache_data = USER_INTERACTIVE_CACHE.get(wxid) + if cache_data and cache_data.get('interactive') : + o=get_first_char_if_digit(msg_content) + if o is not None: + userSelectOptions=cache_data.get('options') + if o < len(userSelectOptions): + o=o-1 + msg_content=userSelectOptions[o].get("value") + messages_to_send=[{"role": "user", "content": msg_content}] + else: + messages_to_send=[{"role": "user", "content": msg_content}] + else: + messages_to_send=[{"role": "user", "content": msg_content}] + + res=await gpt_client_async(request,messages_to_send,wxid,callback_to_user) + reply_content=remove_markdown_symbol(res["choices"][0]["message"]["content"]) + + description = '' + userSelectOptions = [] + + if isinstance(reply_content, list) and any(item.get("type") == "interactive" for item in reply_content): + for item in reply_content: + if item["type"] == "interactive" and item["interactive"]["type"] == "userSelect": + params = item["interactive"]["params"] + description = params.get("description") + userSelectOptions = params.get("userSelectOptions", []) + values_string = "\n".join(option["value"] for option in userSelectOptions) + + if description is not None: + USER_INTERACTIVE_CACHE[wxid] = { + "interactive":True, + "options": userSelectOptions, + } + reply_content=description + '------------------------------\n'+values_string + + elif isinstance(reply_content, list) and any(item.get("type") == "text" for item in reply_content): + USER_INTERACTIVE_CACHE[wxid] = { + "interactive":False + } + text='' + for item in reply_content: + if item["type"] == "text": + text=item["text"]["content"] + if text=='': + # 去除上次上一轮对话再次请求 + cache_messages_str=await request.app.state.redis_service.get_hash_field(hash_key,"data") + cache_messages = json.loads(cache_messages_str) if cache_messages_str else [] + + if len(cache_messages) >= 3: + cache_messages = cache_messages[:-3] + + await request.app.state.redis_service.update_hash_field(hash_key,"data",json.dumps(cache_messages,ensure_ascii=False)) + messages_to_send=await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, prompt) + res=await gpt_client_async(request,messages_to_send,wxid,callback_to_user) + reply_content=remove_markdown_symbol(res["choices"][0]["message"]["content"]) + else: + reply_content=text + else: + USER_INTERACTIVE_CACHE[wxid] = { + "interactive":False + } + reply_content=res["choices"][0]["message"]["content"] + + + reply_content='@'+extract_nickname(msg_push_content) + reply_content + + await request.app.state.gewe_service.post_text_async(token_id,app_id,callback_to_user,reply_content) + await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, {"role": "assistant", "content": reply_content}) + # 回复的对话 + input_wx_content_dialogue_message=[{"type": "text", "text": reply_content}] + input_message=dialogue_message(wxid,callback_to_user,input_wx_content_dialogue_message,True) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + else: + logger.info('群聊公开消息') + callback_to_user=msg_data["FromUserName"]["string"] + group_dialogue_message=[{"type": "text", "text": msg_content}] + input_message=dialogue_message(callback_to_user,wxid,group_dialogue_message) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + return + +async def handle_image_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + ''' + 私聊图片消息 + ''' + msg_content=msg_data["Content"]["string"] + + callback_to_user=from_wxid + hash_key = f'__AI_OPS_WX__:MESSAGES:{wxid}:{callback_to_user}' + wx_img_url= await request.app.state.gewe_service.download_image_msg_async(token_id,app_id,msg_content) + + oss_access_key_id="LTAI5tRTG6pLhTpKACJYoPR5" + oss_access_key_secret="E7dMzeeMxq4VQvLg7Tq7uKf3XWpYfN" + oss_endpoint="http://oss-cn-shanghai.aliyuncs.com" + oss_bucket_name="cow-agent" + oss_prefix="cow" + + img_url=upload_oss(oss_access_key_id, oss_access_key_secret, oss_endpoint, oss_bucket_name, wx_img_url, oss_prefix) + + prompt={ + "role": "user", + "content": [{ + "type": "image_url", + "image_url": {"url": img_url} + }] + } + + await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, prompt) + await request.app.state.gewe_service.post_text_async(token_id,app_id,callback_to_user,'已经上传了图片,有什么可以为您服务') + logger.info(f"上传图片 URL: {img_url}") + + wx_content_dialogue_message=[{"type": "image_url", "image_url": {"url": img_url}}] + input_message=dialogue_message(wxid,callback_to_user,wx_content_dialogue_message) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + +async def handle_image_group_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + logger.info('群聊图片消息') + +async def handle_voice_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + ''' + 私聊语音消息 + ''' + callback_to_user=from_wxid + msg_content=msg_data["Content"]["string"] + msg_id=msg_data["MsgId"] + + + file_url=await request.app.state.gewe_service.download_audio_msg_async(token_id,app_id,msg_id,msg_content) + react_silk_path=await save_to_local_from_url_async(file_url) + react_wav_path = os.path.splitext(react_silk_path)[0] + ".wav" + audio_convert.any_to_wav(react_silk_path,react_wav_path) + react_voice_text=AliVoice().voiceToText(react_wav_path) + + os.remove(react_silk_path) + os.remove(react_wav_path) + + hash_key = f'__AI_OPS_WX__:MESSAGES:{wxid}:{callback_to_user}' + messages=await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, {"role": "user", "content": react_voice_text}) + ai_res=await gpt_client_async(request,messages,wxid,callback_to_user) + ai_res_content=remove_markdown_symbol(ai_res["choices"][0]["message"]["content"]) + has_url=contains_url(ai_res_content) + if not has_url: + voice_during,voice_url=wx_voice(ai_res_content) + + if voice_during < 60 * 1000: + ret,ret_msg,res=await request.app.state.gewe_service.post_voice_async(token_id,app_id,callback_to_user,voice_url,voice_during) + + else: + ret,ret_msg,res=await request.app.state.gewe_service.post_text_async(token_id,app_id,callback_to_user,ai_res_content) + logger.warning(f'回应语音消息长度 {voice_during/1000}秒,超过60秒,转为文本回复') + if ret==200: + logger.info((f'{wxid} 向 {callback_to_user} 发送语音文本【{ai_res_content}】{ret_msg}')) + + else: + logger.warning((f'{wxid} 向 {callback_to_user} 发送语音文本【{ai_res_content}】{ret_msg}')) + ret,ret_msg,res==await request.app.state.gewe_service.post_text_async(token_id,app_id,callback_to_user,ai_res_content) + logger.info((f'{wxid} 向 {callback_to_user} 发送文本【{ai_res_content}】{ret_msg}')) + + else: + logger.info(f"回复内容包含网址,不发送语音,回复文字内容:{ai_res_content}") + ret,ret_msg,res=await request.app.state.gewe_service.post_text_async(token_id,app_id,callback_to_user,ai_res_content) + + + + + await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, {"role": "assistant", "content": ai_res_content}) + # 构造对话消息并发送到 Kafka + input_wx_content_dialogue_message = [{"type": "text", "text": ai_res_content}] + input_message = dialogue_message(wxid, callback_to_user, input_wx_content_dialogue_message,True) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s", input_message) + +async def handle_voice_group_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + logger.info('群聊语音消息') + +async def handle_name_card_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + logger.info('名片消息') + + try: + msg_content_xml=msg_data["Content"]["string"] + # 解析XML字符串 + root = ET.fromstring(msg_content_xml) + + # 提取alias属性 + alias_value = root.get("alias") + # 加好友资料 + scene = int(root.get("scene")) + v3 = root.get("username") + v4 = root.get("antispamticket") + + logger.info(f"alias_value: {alias_value}, scene: {scene}, v3: {v3}, v4: {v4}") + # 判断appid 是否已经创建3天 + k, login_info = await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + creation_timestamp=int(login_info.get('create_at',time.time())) + current_timestamp = time.time() + three_days_seconds = 3 * 24 * 60 * 60 # 三天的秒数 + diff_flag=(current_timestamp - creation_timestamp) >= three_days_seconds + if not diff_flag: + log_content=f'名片添加好友功能,{wxid} 用户创建不够三天,不能使用该功能' + logger.warning(log_content) + return + + # 将加好友资料添加到待加好友队列 + #gewe_chat.wxchat.enqueue_to_add_contacts(wxid,scene,v3,v4) + _,loginfo=await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + + nickname=loginfo.get('nickName') + add_contact_content=f'您好,我是{nickname}' + #gewe_chat.wxchat.add_contacts(token_id,app_id,scene,Models.OperationType.ADD_FRIEND,v3,v4,add_contact_content) + await request.app.state.gewe_service.add_contacts_async(token_id,app_id,scene,OperationType.ADD_FRIEND.value,v3,v4,add_contact_content) + + except ET.ParseError as e: + logger.error(f"XML解析错误: {e}") + except KeyError as e: + logger.error(f"字典键错误: {e}") + except Exception as e: + logger.error(f"未知错误: {e}") + +async def handle_xml_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + ''' + 处理xml + ''' + try: + msg_content_xml=msg_data["Content"]["string"] + root = ET.fromstring(msg_content_xml) + type_value = int(root.find(".//appmsg/type").text) + handlers = { + 57: handle_xml_reference_async, + 5: handle_xml_invite_group_async + } + handler = handlers.get(type_value) + if handler: + return await handler(request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid) + # elif "邀请你加入了群聊" in msg_content_xml: # 邀请加入群聊 + # logger.warning(f"xml消息 {type_value} 邀请你加入了群聊.todo") + else: + print(f"xml消息 {type_value} 未解析") + except ET.ParseError as e: + logger.error(f"解析XML失败: {e}") + except Exception as e: + logger.error(f"未知错误: {e}") + return + +async def handle_xml_reference_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + ''' + 引用消息 + 判断此类消息的逻辑:$.Data.MsgType=49 并且 解析$.Data.Content.string中的xml msg.appmsg.type=57 + ''' + callback_to_user=from_wxid + hash_key = f'__AI_OPS_WX__:MESSAGES:{wxid}:{callback_to_user}' + + msg_content= msg_data["PushContent"] + + prompt={"role": "user", "content": [{ + "type": "text", + "text": msg_content + }]} + + # 收到的对话 + messages_to_send=await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, prompt) + input_wx_content_dialogue_message=[{"type": "text", "text": msg_content}] + input_message=dialogue_message(callback_to_user,wxid,input_wx_content_dialogue_message) + await request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + + # 回复的对话 + res=await gpt_client_async(request,messages_to_send,wxid,callback_to_user) + reply_content=remove_markdown_symbol(res["choices"][0]["message"]["content"]) + input_wx_content_dialogue_message=[{"type": "text", "text": reply_content}] + input_message=dialogue_message(wxid,callback_to_user,input_wx_content_dialogue_message,True) + await request.app.state.kafka_service.kafka_client.produce_message(input_message) + logger.info("发送对话 %s",input_message) + await request.app.state.kafka_service.save_session_messages_to_cache_async(hash_key, {"role": "assistant", "content": reply_content}) + await request.app.state.kafka_service.post_text_async(token_id,app_id,callback_to_user,reply_content) + +async def handle_xml_invite_group_async (request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + ''' + 群聊邀请 + 判断此类消息的逻辑:$.Data.MsgType=49 + 并且 解析$.Data.Content.string中的xml msg.appmsg.title=邀请你加入群聊(根据手机设置的系统语言title会有调整,不同语言关键字不同) + ''' + logger.info(f'{wxid} 群聊邀请') + msg_content_xml=msg_data["Content"]["string"] + root = ET.fromstring(msg_content_xml) + title_value = root.find(".//appmsg/title").text + + if '邀请你加入群聊' in title_value: + invite_url = root.find('.//url').text + ret,msg,data=await request.app.state.gewe_service.agree_join_room_async(token_id,app_id,invite_url) + if ret==200: + logger.info(f'群聊邀请,同意加入群聊 {msg} {data}') + chatroom_id=data.get('chatroomId','') + # if not chatroom_id: + # logger.warning(f'群聊邀请,同意加入群聊失败 {msg} {data}') + # return + ret,msg,data=await request.app.state.gewe_service.save_contract_list_async(token_id,app_id,chatroom_id,3) + logger.info(f'群聊邀请,保存到通讯录 chatroom_id {chatroom_id} {msg}') + await request.app.state.gewe_service.update_group_info_to_cache_async(token_id,app_id,wxid,chatroom_id) + await request.app.state.gewe_service.update_group_members_to_cache_async(token_id,app_id,wxid,chatroom_id) + else: + logger.warning(f'群聊邀请,同意加入群聊失败 {msg} {data}') + +async def handle_add_friend_notice_async(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + ''' + 好友添加请求通知 + ''' + logger.info('好友添加请求通知') + try: + msg_content_xml=msg_data["Content"]["string"] + + root = ET.fromstring(msg_content_xml) + + msg_content = root.attrib.get('content', None) + v3= root.attrib.get('encryptusername', None) + v4= root.attrib.get('ticket', None) + scene=root.attrib.get('scene', None) + + + to_contact_wxid=root.attrib.get('fromusername', None) + wxid=msg_data["ToUserName"]["string"] + + # 自动同意好友 + # print(v3) + # print(v4) + # print(scene) + # print(msg_content) + # 操作类型,2添加好友 3同意好友 4拒绝好友 + #option=2 + option=3 + reply_add_contact_contact="亲,我是你的好友" + ret,ret_msg=await request.app.state.gewe_service.add_contacts_async(token_id,app_id,scene,option,v3,v4,reply_add_contact_contact) + if ret==200: + logger.info('自动添加好友成功') + + # 好友发送的文字 + hash_key = f'__AI_OPS_WX__:MESSAGES:{wxid}:{to_contact_wxid}' + prompt={"role": "user", "content": [{"type": "text","text": msg_content}]} + messages_to_send=await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, prompt) + + input_wx_content_dialogue_message=[{"type": "text", "text": msg_content}] + input_message=dialogue_message(to_contact_wxid,wxid,input_wx_content_dialogue_message) + await request.app.state.gewe_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + + callback_to_user=to_contact_wxid + res=await gpt_client_async(messages_to_send,wxid,callback_to_user) + reply_content=remove_markdown_symbol(res["choices"][0]["message"]["content"]) + + #保存好友信息 + await request.app.state.gewe_service.save_contacts_brief_to_cache_async(token_id,app_id, wxid,[to_contact_wxid]) + + # 保存到缓存 + await request.app.state.gewe_service.save_session_messages_to_cache_async(hash_key, {"role": "assistant", "content": reply_content}) + + # 发送信息 + await request.app.state.gewe_service.post_text_async(token_id,app_id, to_contact_wxid,reply_content) + + # 发送到kafka + input_wx_content_dialogue_message=[{"type": "text", "text": reply_content}] + input_message=dialogue_message(wxid,to_contact_wxid,input_wx_content_dialogue_message,True) + request.app.state.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s",input_message) + + else: + logger.warning("添加好友失败") + except ET.ParseError as e: + logger.error(f"解析XML失败: {e}") + except Exception as e: + logger.error(f"未知错误: {e}") + return + +async def handle_10002_msg(request: Request,token_id,app_id, wxid,msg_data,from_wxid, to_wxid): + + ''' + 群聊邀请 + 撤回消息 + 拍一拍消息 + 地理位置 + 踢出群聊通知 + 解散群聊通知 + 发布群公告 + ''' + + try: + msg_content_xml=msg_data["Content"]["string"] + # 群聊邀请 + if '邀请你加入了群聊' in msg_content_xml and check_chatroom(msg_data["FromUserName"]["string"]): + chatroom_id=msg_data["FromUserName"]["string"] + ret,msg,data=await request.app.state.gewe_service.save_contract_list_async(token_id,app_id,chatroom_id,3) + logger.info(f'群聊邀请,保存到通讯录 chatroom_id {chatroom_id} {msg}') + await request.app.state.gewe_service.update_group_info_to_cache_async(token_id,app_id,wxid,chatroom_id) + await request.app.state.gewe_service.update_group_members_to_cache_async(token_id,app_id,wxid,chatroom_id) + + if '移出了群聊' in msg_content_xml and 'sysmsgtemplate' in msg_content_xml : + chatroom_id=msg_data["FromUserName"]["string"] + ret,msg,data=await request.app.state.gewe_service.save_contract_list_async(token_id,app_id,chatroom_id,2) + logger.info(f'踢出群聊,移除从通讯录 chatroom_id {chatroom_id} {msg}') + await request.app.state.redis_service.delete_hash_field(f'__AI_OPS_WX__:GROUPS_INFO:{wxid}',chatroom_id) + logger.info(f'清除 chatroom_id{chatroom_id} 数据') + + if '已解散该群聊' in msg_content_xml and 'sysmsgtemplate' in msg_content_xml : + chatroom_id=msg_data["FromUserName"]["string"] + ret,msg,data=await request.app.state.gewe_service.save_contract_list_async(token_id,app_id,chatroom_id,2) + logger.info(f'解散群聊,移除从通讯录 chatroom_id {chatroom_id} {msg}') + await request.app.state.redis_service.delete_hash_field(f'__AI_OPS_WX__:GROUPS_INFO:{wxid}',chatroom_id) + logger.info(f'清除 chatroom_id{chatroom_id} 数据') + + print('撤回消息,拍一拍消息,地理位置') + except ET.ParseError as e: + logger.error(f"解析XML失败: {e}") + except Exception as e: + logger.error(f"未知错误: {e}") + return + + + + + + diff --git a/app/endpoints/sns_endpoint.py b/app/endpoints/sns_endpoint.py new file mode 100644 index 0000000..c66ef75 --- /dev/null +++ b/app/endpoints/sns_endpoint.py @@ -0,0 +1,126 @@ +from fastapi import APIRouter,Request,HTTPException +from pydantic import BaseModel +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ValidationError +from model.models import AgentConfig,validate_wxid,auth_required_time +import threading +import asyncio +import time +from typing import Dict, Tuple, Any + + +sns_router = APIRouter(prefix="/api/sns") + +class SendTextRequest(BaseModel): + wxid: str + content:str + +class SendImagesRequest(BaseModel): + wxid: str + content:str + imageUrls:list + +class SendVideoRequest(BaseModel): + wxid: str + content:str + videoUrl:str + videoThumbUrl:str + +async def auth_required_time(request: Request,wxid:str): + if not wxid: + return {"code": 400, "message": "wxid 不能为空"} + + # 模拟获取登录信息 + k, loginfo = await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + if not loginfo: + return {"code": 404, "message": f"{wxid} 微信信息不存在"} + + login_status = loginfo.get('status', '0') + if login_status != '1': + return {"code": 401, "message": f"{wxid} 已经离线"} + + creation_timestamp = int(loginfo.get('create_at', time.time())) + current_timestamp = time.time() + three_days_seconds = 3 * 24 * 60 * 60 # 三天的秒数 + diff_flag = (current_timestamp - creation_timestamp) >= three_days_seconds + if not diff_flag: + return {'code': 401, 'message': '用户创建不够三天,不能使用该功能'} + return k, loginfo + +@sns_router.post("/sendtext", response_model=None) +async def send_text(request: Request, body: SendTextRequest, ): + wxid = body.wxid + content = body.content + auth = await auth_required_time(request, wxid) + if isinstance(auth, Dict): + return auth + k, loginfo = auth + token_id = loginfo.get('tokenId') + app_id = loginfo.get('appId') + + + ret, msg, data = await request.app.state.gewe_service.send_text_sns_async(token_id, app_id, content) + if ret != 200: + return { + 'code': ret, + 'message': msg + } + return data + +@sns_router.post("/sendimages", response_model=None) +async def send_text(request: Request, body: SendImagesRequest, ): + + wxid = body.wxid + content = body.content + image_urls=body.imageUrls + + auth = await auth_required_time(request, wxid) + if isinstance(auth, Dict): + return auth + k, loginfo = auth + token_id = loginfo.get('tokenId') + app_id = loginfo.get('appId') + + ret, msg, data =await request.app.state.gewe_service.upload_sns_image_async(token_id, app_id, image_urls) + if ret != 200: + return { + 'code': ret, + 'message': msg + } + + ret, msg, data = await request.app.state.gewe_service.send_image_sns_async(token_id, app_id, content,data) + if ret != 200: + return { + 'code': ret, + 'message': msg + } + return data + +@sns_router.post("/sendvideo", response_model=None) +async def send_video(request: Request, body: SendVideoRequest, ): + wxid = body.wxid + content = body.content + video_url=body.videoUrl + video_thumb_url=body.videoThumbUrl + + auth = await auth_required_time(request, wxid) + if isinstance(auth, Dict): + return auth + k, loginfo = auth + token_id = loginfo.get('tokenId') + app_id = loginfo.get('appId') + ret, msg, data = await request.app.state.gewe_service.upload_sns_video_async(token_id, app_id, video_url,video_thumb_url) + if ret != 200: + return { + 'code': ret, + 'message': msg + } + + + ret, msg, data = await request.app.state.gewe_service.send_video_sns_async(token_id, app_id, content,data) + if ret != 200: + return { + 'code': ret, + 'message': msg + } + return data \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..25c62b6 --- /dev/null +++ b/app/main.py @@ -0,0 +1,233 @@ +from fastapi import FastAPI,Request +from pydantic import BaseModel +from contextlib import asynccontextmanager + +from celery import Celery +# from aiokafka import AIOKafkaConsumer +import asyncio +import json +import time +import uvicorn + +import logging +from logging.handlers import TimedRotatingFileHandler,RotatingFileHandler +from starlette.middleware.base import BaseHTTPMiddleware +from services.gewe_service import GeWeService # 导入 GeWeChatCom +from common.log import logger +from app.endpoints.config_endpoint import config_router +from app.endpoints.contacts_endpoint import contacts_router +from app.endpoints.groups_endpoint import groups_router +from app.endpoints.sns_endpoint import sns_router +from app.endpoints.agent_endpoint import agent_router +from app.endpoints.pipeline_endpoint import messages_router + + + +from services.redis_service import RedisService +from services.kafka_service import KafkaService +from services.biz_service import BizService + +from app.middleware import http_context + + +from celery.result import AsyncResult +from app.tasks import add_task,sync_contacts_task +from config import load_config +from config import conf +from common.utils import * + + + +load_config() + +# Kafka 配置 +#KAFKA_BOOTSTRAP_SERVERS = '192.168.2.121:9092' +KAFKA_BOOTSTRAP_SERVERS = conf().get("kafka_bootstrap_servers") +KAFKA_TOPIC = 'topic.ai.ops.wx' +KAFKA_GROUP_ID = 'ai-ops-wx' + +# 用于存储后台任务的全局变量 +background_tasks = set() + +async def kafka_consumer(): + while True: + # 这里模拟 Kafka 消费者的逻辑 + # print("Kafka consumer is running...") + await asyncio.sleep(1) + +async def background_worker(redis_service:RedisService,kafka_service:KafkaService,gewe_service:GeWeService): + lock_name = "background_wxchat_thread_lock" + lock_identifier = str(time.time()) # 使用时间戳作为唯一标识 + + while True: + # 尝试获取分布式锁 + if await redis_service.acquire_lock(lock_name, timeout=10): + try: + logger.info("分布式锁已成功获取") + # 启动任务 + print('启动任务') + # 启动后台任务 + + await startup_sync_data_task_async(redis_service, kafka_service, gewe_service) # 确保传递了正确的参数 + print('启动任务完成') + # 保持锁的续期 + while True: + await asyncio.sleep(30) # 每30秒检查一次锁的状态 + if not await redis_service.renew_lock(lock_name, lock_identifier, timeout=60): + break # 如果无法续期锁,退出循环 + finally: + # 释放锁 + await redis_service.release_lock(lock_name, lock_identifier) + break # 任务完成后退出循环 + else: + # 如果获取锁失败,等待一段时间后重试 + logger.info("获取分布式锁失败,等待10秒后重试...") + await asyncio.sleep(10) + +async def startup_sync_data_task_async(redis_service: RedisService, kafka_service: KafkaService, gewe_service: GeWeService): + try: + login_keys = [] + async for key in redis_service.client.scan_iter(match='__AI_OPS_WX__:LOGININFO:*'): # 使用 async for 遍历异步生成器 + login_keys.append(key) + for k in login_keys: + r = await redis_service.get_hash(k) + app_id = r.get("appId") + token_id = r.get("tokenId") + wxid = r.get("wxid") + status = r.get('status') + if status == '0': + continue + # 同步联系人列表 + ret, msg, contacts_list = await gewe_service.fetch_contacts_list_async(token_id, app_id) + if ret != 200: + logger.warning(f"同步联系人列表失败: {ret}-{msg}") + continue + friend_wxids = [c for c in contacts_list['friends'] if c not in ['fmessage', 'medianote', 'weixin', 'weixingongzhong']] # 可以调整截取范围 + data = await gewe_service.save_contacts_brief_to_cache_async(token_id, app_id, wxid, friend_wxids) + chatrooms = contacts_list['chatrooms'] + # 同步群列表 + logger.info(f'{wxid} 的群数量 {len(chatrooms)}') + logger.info(f'{wxid} 同步群列表') + await gewe_service.save_groups_info_to_cache_async(token_id, app_id, wxid, chatrooms) + logger.info(f'{wxid} 同步群成员') + # 同步群成员 + await gewe_service.save_groups_members_to_cache_async(token_id, app_id, wxid, chatrooms) + logger.info(f'{wxid} 好友信息推送到kafka') + # 联系人推送到kafka + k_message = wx_all_contacts(wxid, data) + await kafka_service.send_message_async(k_message) + except Exception as e: + logger.error(f"任务执行过程中发生异常: {e}") + +@asynccontextmanager +async def lifespan(app: FastAPI): + + #app.state.redis_helper = RedisHelper(host='192.168.2.121',password='telpo#1234', port=8090, db=3) + + + # 初始化 RedisHelper + redis_service = RedisService() + redis_host=conf().get("redis_host") + redis_port=conf().get("redis_port") + redis_password=conf().get("redis_password") + redis_db=conf().get("redis_db") + await redis_service.init(host=redis_host,port=redis_port, password=redis_password, db=redis_db) + app.state.redis_service = redis_service + + + # 初始化 KafkaService + kafka_service= KafkaService(KAFKA_BOOTSTRAP_SERVERS, KAFKA_TOPIC, KAFKA_TOPIC,KAFKA_GROUP_ID) + await kafka_service.start() + app.state.kafka_service = kafka_service + # redis_service_instance=app.state.redis_service + + # 初始化 GeWeChatCom + app.state.gewe_service = await GeWeService.get_instance(app,"http://api.geweapi.com/gewe") + gewe_service=app.state.gewe_service + # # 初始化 GeWeChatCom + #app.state.gwechat_service = GeWeService(app) + + # 初始化业务服务 + biz_service = BizService(app) + app.state.biz_service = biz_service + biz_service.setup_handlers() + # 在应用程序启动时启动 Kafka 消费者任务 + # try: + # yield # 应用程序运行期间 + # finally: + # # 在应用程序关闭时取消所有后台任务 + # await kafka_service.stop() + + #task = asyncio.create_task(kafka_consumer()) + task=asyncio.create_task(background_worker(redis_service,kafka_service,gewe_service)) + background_tasks.add(task) + try: + yield # 应用程序运行期间 + finally: + # # 在应用程序关闭时取消所有后台任务 + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + background_tasks.clear() + # 关闭 KafkaService + print('应用关闭') + await kafka_service.stop() + +app = FastAPI(lifespan=lifespan) + +# 配置日志:输出到文件,文件最大 10MB,保留 5 个备份文件 +# log_handler = RotatingFileHandler( +# "app.log", # 日志文件名 +# maxBytes=10 * 1024 * 1024, # 文件大小限制:10MB +# backupCount=5, # 保留 5 个备份文件 +# encoding="utf-8" # 日志文件的编码 +# ) + +# # 设置日志格式 +# log_handler.setFormatter( +# logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +# ) + +# 获取根日志记录器,并设置日志级别为 INFO +# logging.basicConfig( +# level=logging.INFO, # 设置日志记录级别 +# handlers=[log_handler] # 配置文件日志处理器 +# ) + + + + +app.add_middleware(BaseHTTPMiddleware, dispatch=http_context) + +app.include_router(config_router) +app.include_router(contacts_router) +app.include_router(groups_router) +app.include_router(sns_router) +app.include_router(agent_router) +app.include_router(messages_router) + +@app.get("/") +async def root(): + logger.info("Root route is called") + return {"message": "Kafka consumer is running in the background"} + +class AddRequest(BaseModel): + x: int + y: int + +@app.post("/add") +async def add_numbers(request: AddRequest): + task = add_task.delay(request.x, request.y) + return {"task_id": task.id} + +@app.get("/task/{task_id}") +async def get_task_status(task_id: str): + task_result = AsyncResult(task_id) + return { + "task_id": task_id, + "task_status": task_result.status, + "task_result": task_result.result + } + diff --git a/app/middleware.py b/app/middleware.py new file mode 100644 index 0000000..89afc6f --- /dev/null +++ b/app/middleware.py @@ -0,0 +1,180 @@ +import json +import time +from fastapi import FastAPI, Request,HTTPException +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from pydantic import BaseModel +from datetime import datetime +import logging + + +from common.log import logger + + + + +class Result(BaseModel): + code: int + message: str + status: str + +class ResponseData(BaseModel): + data: dict|list|None + result: Result + timestamp: str + + + + +async def http_context(request: Request, call_next): + # 记录请求信息 + request_info = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "method": request.method, + "url": str(request.url), + "body": await request.body() if request.method in ["POST", "PUT", "PATCH"] else None, + } + logger.info(f"请求: {json.dumps(request_info, separators=(',', ':'), default=str, ensure_ascii=False)}") + + # 调用下一个中间件或路由处理程序 + response = await call_next(request) + + # 如果响应状态码为 200,则格式化响应为统一格式 + if response.status_code == 200: + try: + response_body = b"" + async for chunk in response.body_iterator: + response_body += chunk + response_body_str = response_body.decode("utf-8") + business_data = json.loads(response_body_str) + except Exception as e: + business_data = {"error": f"Unable to decode response body: {str(e)}"} + + if "code" in business_data: + message=business_data.get("message","请求失败!") + result = ResponseData( + data=None, + result=Result(code=business_data.get("code",500), message=message, status="failed"), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ) + else: + # 构造统一格式的响应 + result = ResponseData( + data=business_data, + result=Result(code=200, message="请求成功!", status="succeed"), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ) + response_info = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "status_code": response.status_code, + "headers": dict(response.headers), + "body": result.dict(), + } + logger.info(f"响应: {json.dumps(response_info, separators=(',', ':'), default=str, ensure_ascii=False)}") + + # 返回修改后的响应 + return JSONResponse(content=result.model_dump()) + + else: + print(response) + print('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~') + message = "请求失败!" + # 如果响应状态码不为 200,则记录响应信息 + try: + response_body = b"" + async for chunk in response.body_iterator: + response_body += chunk + response_body_str = response_body.decode("utf-8") + business_data = json.loads(response_body_str) + except Exception as e: + business_data = {"error": f"Unable to decode response body: {str(e)}"} + # 根据不同状态码定制 message 字段 + + if response.status_code == 404: + message = e.detail + elif response.status_code == 400: + message = "请求参数错误" + elif response.status_code == 500: + message = "服务器内部错误" + + + # 你可以根据不同的状态码设置更详细的错误消息 + + # 构造统一格式的响应 + result = ResponseData( + data={}, # 返回空数据 + result=Result( + code=response.status_code, + message=message, # 根据状态码返回详细信息 + status="failed" + ), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ) + response_info = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "status_code": response.status_code, + "headers": dict(response.headers), + "body": result.dict(), + } + logger.info(f"响应: {json.dumps(response_info, separators=(',', ':'), default=str, ensure_ascii=False)}") + + # 返回修改后的响应 + return JSONResponse(content=result.model_dump(), status_code=response.status_code) + +async def http_context_2(request: Request, call_next): + # 记录请求信息 + request_body = None + if request.method in ["POST", "PUT", "PATCH"]: + try: + request_body = await request.json() # 使用 .json(),避免影响 FastAPI 解析 + except Exception: + request_body = "无法解析 JSON" + + request_info = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "method": request.method, + "url": str(request.url), + "body": request_body, + } + logger.info(f"请求: {json.dumps(request_info, separators=(',', ':'), ensure_ascii=False)}") + + # 继续处理请求 + response = await call_next(request) + + # 如果是 422 错误,直接返回,避免 Pydantic 解析错误 + if response.status_code == 422: + return response + + # 处理正常请求 + try: + response_body = b"" + async for chunk in response.body_iterator: + response_body += chunk + response_body_str = response_body.decode("utf-8") + business_data = json.loads(response_body_str) + except Exception as e: + business_data = {"error": f"无法解析响应体: {str(e)}"} + + if "code" in business_data: + message = business_data.get("message", "请求失败!") + result = ResponseData( + data=None, + result=Result(code=business_data.get("code", 500), message=message, status="failed"), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ) + else: + result = ResponseData( + data=business_data, + result=Result(code=200, message="请求成功!", status="succeed"), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ) + + response_info = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "status_code": response.status_code, + "headers": dict(response.headers), + "body": result.dict(), + } + logger.info(f"响应: {json.dumps(response_info, separators=(',', ':'), ensure_ascii=False)}") + + return JSONResponse(content=result.model_dump()) diff --git a/app/tasks.py b/app/tasks.py new file mode 100644 index 0000000..5f1f369 --- /dev/null +++ b/app/tasks.py @@ -0,0 +1,28 @@ +from app.celery_app import celery +from fastapi import Request,FastAPI +import time + +@celery.task(name='app.tasks.add_task', bind=True, acks_late=True) +def add_task(self, x, y): + time.sleep(5) # 模拟长时间计算 + return x + y + + +@celery.task(name='app.tasks.mul_task', bind=True, acks_late=True) +def mul_task(self, x, y): + time.sleep(5) # 模拟长时间计算 + return x * y + + +# @celery.task(name='app.tasks.sync_contacts', bind=True, acks_late=True) +# async def sync_contacts_task(self,app): +# login_keys = list(await app.state.redis_service.client.scan_iter(match='__AI_OPS_WX__:LOGININFO:*')) +# return login_keys +# # for k in login_keys: +# # print(k) + +@celery.task(name='app.tasks.sync_contacts', bind=True, acks_late=True) +async def sync_contacts_task(self, redis_service): + # Use the redis_service passed as an argument + login_keys = list(await redis_service.client.scan_iter(match='__AI_OPS_WX__:LOGININFO:*')) + return login_keys \ No newline at end of file diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/expired_dict.py b/common/expired_dict.py new file mode 100644 index 0000000..7d97062 --- /dev/null +++ b/common/expired_dict.py @@ -0,0 +1,43 @@ +from datetime import datetime, timedelta + + +class ExpiredDict(dict): + def __init__(self, expires_in_seconds): + super().__init__() + self.expires_in_seconds = expires_in_seconds + + def __getitem__(self, key): + value, expiry_time = super().__getitem__(key) + if datetime.now() > expiry_time: + del self[key] + raise KeyError("expired {}".format(key)) + self.__setitem__(key, value) + return value + + def __setitem__(self, key, value): + expiry_time = datetime.now() + timedelta(seconds=self.expires_in_seconds) + # print(f'{key} 缓存过期时间:{expiry_time}') + super().__setitem__(key, (value, expiry_time)) + + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + def __contains__(self, key): + try: + self[key] + return True + except KeyError: + return False + + def keys(self): + keys = list(super().keys()) + return [key for key in keys if key in self] + + def items(self): + return [(key, self[key]) for key in self.keys()] + + def __iter__(self): + return self.keys().__iter__() diff --git a/common/log.py b/common/log.py new file mode 100644 index 0000000..583d1b8 --- /dev/null +++ b/common/log.py @@ -0,0 +1,176 @@ +# import logging +# import sys +# import os +# from datetime import datetime, timedelta + +# LOG_DIR = "./logs" # 日志文件目录 +# LOG_RETENTION_DAYS = 7 # 日志保留天数 + +# def _remove_old_logs(log_dir, retention_days): +# """删除超过保留天数的日志文件""" +# if not os.path.exists(log_dir): +# os.makedirs(log_dir) +# now = datetime.now() +# for filename in os.listdir(log_dir): +# file_path = os.path.join(log_dir, filename) +# if os.path.isfile(file_path) and filename.startswith("run_") and filename.endswith(".log"): +# # 提取文件日期 +# try: +# log_date_str = filename[4:14] +# log_date = datetime.strptime(log_date_str, "%Y-%m-%d") +# if now - log_date > timedelta(days=retention_days): +# os.remove(file_path) +# print(f"删除旧日志: {filename}") +# except ValueError: +# continue + +# def _reset_logger(log): +# """重置日志配置,移除旧的 Handler 并添加新的 Handler""" +# for handler in log.handlers: +# handler.close() +# log.removeHandler(handler) +# del handler +# log.handlers.clear() +# log.propagate = False + +# # 控制台输出的日志处理器 +# console_handle = logging.StreamHandler(sys.stdout) +# console_handle.setFormatter( +# logging.Formatter( +# "[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s", +# datefmt="%Y-%m-%d %H:%M:%S", +# ) +# ) + +# # 生成带有当前日期的日志文件路径 +# date_str = datetime.now().strftime("%Y-%m-%d") +# log_file_path = os.path.join(LOG_DIR, f"run_{date_str}.log") + +# # 文件日志处理器 +# file_handle = logging.FileHandler(log_file_path, encoding="utf-8") +# file_handle.setFormatter( +# logging.Formatter( +# "[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s", +# datefmt="%Y-%m-%d %H:%M:%S", +# ) +# ) + +# # 将处理器添加到日志 +# log.addHandler(file_handle) +# log.addHandler(console_handle) + +# # 删除旧的日志文件 +# _remove_old_logs(LOG_DIR, LOG_RETENTION_DAYS) + +# def setup_logging(): +# """设置日志配置""" +# log = logging.getLogger("log") +# _reset_logger(log) +# log.setLevel(logging.INFO) # 日志级别 +# return log + +# def setup_logging(): +# """设置日志配置""" +# log = logging.getLogger() # 获取 Flask 默认的日志记录器 +# _reset_logger(log) +# log.setLevel(logging.INFO) # 设置日志级别为 INFO +# return log + +# # 创建日志实例 +# logger = setup_logging() + +# def log_exception(sender, exception, **extra): +# """记录异常日志""" +# sender.logger.debug('处理过程发生异常: %s', exception) + + + +import logging +import sys +import os +from datetime import datetime, timedelta + +# 日志文件目录 +LOG_DIR = "./logs" +# 日志保留天数 +LOG_RETENTION_DAYS = 7 + +def _remove_old_logs(log_dir, retention_days): + """删除超过保留天数的日志文件""" + if not os.path.exists(log_dir): + os.makedirs(log_dir) + now = datetime.now() + for filename in os.listdir(log_dir): + file_path = os.path.join(log_dir, filename) + if os.path.isfile(file_path) and filename.startswith("run_") and filename.endswith(".log"): + # 提取文件日期 + try: + log_date_str = filename[4:14] + log_date = datetime.strptime(log_date_str, "%Y-%m-%d") + if now - log_date > timedelta(days=retention_days): + os.remove(file_path) + print(f"删除旧日志: {filename}") + except ValueError: + continue + +def _reset_logger(log): + """重置日志配置,移除旧的 Handler 并添加新的 Handler""" + for handler in log.handlers: + handler.close() + log.removeHandler(handler) + del handler + log.handlers.clear() + log.propagate = False + + # 控制台输出的日志处理器 + console_handle = logging.StreamHandler(sys.stdout) + console_handle.setFormatter( + logging.Formatter( + "[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + + # 生成带有当前日期的日志文件路径 + date_str = datetime.now().strftime("%Y-%m-%d") + log_file_path = os.path.join(LOG_DIR, f"run_{date_str}.log") + + # 文件日志处理器 + file_handle = logging.FileHandler(log_file_path, encoding="utf-8") + file_handle.setFormatter( + logging.Formatter( + "[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + + # 将处理器添加到日志 + log.addHandler(file_handle) + log.addHandler(console_handle) + + # 删除旧的日志文件 + _remove_old_logs(LOG_DIR, LOG_RETENTION_DAYS) + +def setup_logging(): + """设置日志配置""" + log = logging.getLogger() # 获取默认的日志记录器 + _reset_logger(log) + log.setLevel(logging.INFO) # 设置日志级别为 INFO + return log + +# 创建日志实例 +logger = setup_logging() + +def log_exception(sender, exception, **extra): + """记录异常日志""" + logger.error(f"处理过程发生异常: {exception}", exc_info=True) + +# FastAPI 日志配置示例 +def configure_fastapi_logging(): + """配置 FastAPI 的日志记录""" + fastapi_logger = logging.getLogger("uvicorn") + _reset_logger(fastapi_logger) + fastapi_logger.setLevel(logging.INFO) + +# 配置 FastAPI 日志 +configure_fastapi_logging() diff --git a/common/memory.py b/common/memory.py new file mode 100644 index 0000000..8cdb194 --- /dev/null +++ b/common/memory.py @@ -0,0 +1,5 @@ +from common.expired_dict import ExpiredDict + +USER_IMAGE_CACHE = ExpiredDict(60 * 3) +USER_INTERACTIVE_CACHE=ExpiredDict(60 * 1) +USER_LOGIN_QRCODE=ExpiredDict(80) \ No newline at end of file diff --git a/common/singleton.py b/common/singleton.py new file mode 100644 index 0000000..b46095c --- /dev/null +++ b/common/singleton.py @@ -0,0 +1,9 @@ +def singleton(cls): + instances = {} + + def get_instance(*args, **kwargs): + if cls not in instances: + instances[cls] = cls(*args, **kwargs) + return instances[cls] + + return get_instance diff --git a/common/tmp_dir.py b/common/tmp_dir.py new file mode 100644 index 0000000..2d35701 --- /dev/null +++ b/common/tmp_dir.py @@ -0,0 +1,17 @@ +import os +import pathlib + + + +class TmpDir(object): + """A temporary directory that is deleted when the object is destroyed.""" + + tmpFilePath = pathlib.Path("./tmp/") + + def __init__(self): + pathExists = os.path.exists(self.tmpFilePath) + if not pathExists: + os.makedirs(self.tmpFilePath) + + def path(self): + return str(self.tmpFilePath) + "/" diff --git a/common/utils.py b/common/utils.py new file mode 100644 index 0000000..65fa1e7 --- /dev/null +++ b/common/utils.py @@ -0,0 +1,409 @@ +import io +import os +import uuid +import requests +from urllib.parse import urlparse +from PIL import Image +from common.log import logger +import oss2,time,json +from urllib.parse import urlparse, unquote +from voice.ali.ali_voice import AliVoice +from voice import audio_convert + +import aiohttp,aiofiles +import cv2,re +import os +import tempfile +from moviepy.editor import VideoFileClip + +from datetime import datetime + + +def clean_json_string(json_str): + + # 删除所有控制字符(非打印字符),包括换行符、回车符等 + return re.sub(r'[\x00-\x1f\x7f]', '', json_str) + +def dialogue_message(wxid_from:str,wxid_to:str,wx_content:list,is_ai:bool=False): + """ + 构造消息的 JSON 数据 + :param contents: list,包含多个消息内容,每个内容为字典,如: + [{"type": "text", "text": "AAAAAAA"}, + {"type": "image_url", "image_url": {"url": "https://AAAAA.jpg"}}, + {"type":"file","file_url":{"url":"https://AAAAA.pdf"}} + ] + :return: JSON 字符串 + """ + + # 获取当前时间戳,精确到毫秒 + current_timestamp = int(time.time() * 1000) + + # 获取当前时间,格式化为 "YYYY-MM-DD HH:MM:SS" + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + # 构造 JSON 数据 + data = { + "message_id": str(current_timestamp), + "topic": "topic.ai.ops.wx", + "time": current_time, + "data": { + "msg_type": "dialogue", + "is_ai":is_ai, + "content": { + "wxid_from": wxid_from, + "wxid_to": wxid_to, + "wx_content":wx_content + } + } + } + + return json.dumps(data, separators=(',', ':'), ensure_ascii=False) + +def kafka_base_message(msg_type:str,content: dict|list)->dict: + """ + 构造消息的 JSON 数据 + :param wxid: 微信ID + :param data: 一个包含了所有联系人的数据,格式为list, + 每个元素为字典,包含wxid、alias、remark、sex、city、province、country, + headimgurl、signature、skey、uin、nickname这10个字段 + :return: JSON 字符串 + """ + # 获取当前时间戳,精确到毫秒 + current_timestamp = int(time.time() * 1000) + + # 获取当前时间,格式化为 "YYYY-MM-DD HH:MM:SS" + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + # 构造 JSON 数据 + data = { + "message_id": str(current_timestamp), + "topic": "topic.ai.ops.wx", + "time": current_time, + "data": { + #"msg_type": "login-qrcode", + "msg_type": msg_type, + "content": content + } + + } + return data + +def wx_offline_message(appid:str,wxid:str)->str: + content = {"appid": appid,"wxid":wxid} + data=kafka_base_message("wx-offline",content) + return json.dumps(data, separators=(',', ':'), ensure_ascii=False) + +def wx_del_contact_message(wxid:str,contact_wixd:str)->str: + content = {"wxid": wxid,"contact_wixd":contact_wixd} + data=kafka_base_message("del-contact",content) + return json.dumps(data, separators=(',', ':'), ensure_ascii=False) + +def wx_mod_contact_message(wxid:str,contact_data:dict)->str: + content = {"wxid": wxid,"contact_data":contact_data} + data=kafka_base_message("mod-contact",content) + return json.dumps(data, separators=(',', ':'), ensure_ascii=False) + +def wx_all_contacts(wxid:str,data:dict|list)->str: + content = {"wxid": wxid,"contacts_data":data} + data=kafka_base_message("all-contacts",content) + return json.dumps(data, separators=(',', ':'), ensure_ascii=False) + +def login_qrcode_message(token_id: str,agent_tel:str,qr_code_img_base64:str,qr_code_url:list)->str: + """ + 构造消息的 JSON 数据 + :param contents: list,包含多个消息内容,每个内容为字典,如: + { + "tel":"18029274615", + "token_id":"f828cb3c-1039-489f-b9ae-7494d1778a15", + "qr_code_urls":["url1","url2","url3","url4",], + "qr_code_img_base64":"aaaaaaaaaaaaaa" + } + :return: JSON 字符串 + """ + content = { + "tel":agent_tel, + "token_id":token_id, + "qr_code_urls":qr_code_url, + "qr_code_img_base64":qr_code_img_base64 + } + data=kafka_base_message("login-qrcode",content) + return json.dumps(data, separators=(',', ':'), ensure_ascii=False) + +def login_result_message(token_id: str,agent_tel:str,region_id:str,agent_token_id:str,wxid:str)->str: + + content = { + "tel":agent_tel, + "token_id":token_id, + "region_id":region_id, + "agent_token_id":agent_token_id, + "wxid":wxid + } + data=kafka_base_message("login-result",content) + return json.dumps(data, separators=(',', ':'), ensure_ascii=False) + +def wx_voice(text: str): + try: + # 将文本转换为语音 + reply_text_voice = AliVoice().textToVoice(text) + reply_text_voice_path = os.path.join(os.getcwd(), reply_text_voice) + + # 转换为 Silk 格式 + reply_silk_path = os.path.splitext(reply_text_voice_path)[0] + ".silk" + reply_silk_during = audio_convert.any_to_sil(reply_text_voice_path, reply_silk_path) + + # OSS 配置(建议将凭证存储在安全的地方) + oss_access_key_id="LTAI5tRTG6pLhTpKACJYoPR5" + oss_access_key_secret="E7dMzeeMxq4VQvLg7Tq7uKf3XWpYfN" + oss_endpoint="http://oss-cn-shanghai.aliyuncs.com" + oss_bucket_name="cow-agent" + oss_prefix="cow" + + # 上传文件到 OSS + file_path = reply_silk_path + file_url = upload_oss(oss_access_key_id, oss_access_key_secret, oss_endpoint, oss_bucket_name, file_path, oss_prefix) + + # 删除临时文件 + try: + os.remove(reply_text_voice_path) + except FileNotFoundError: + pass # 如果文件未找到,跳过删除 + try: + os.remove(reply_silk_path) + except FileNotFoundError: + pass # 如果文件未找到,跳过删除 + + return int(reply_silk_during), file_url + except Exception as e: + print(f"发生错误:{e}") + return None, None # 发生错误时返回 None + +def upload_oss( + access_key_id, + access_key_secret, + endpoint, + bucket_name, + file_source, + prefix, + expiration_days=7 +): + """ + 上传文件到阿里云OSS并设置生命周期规则,同时返回文件的公共访问地址。 + + :param access_key_id: 阿里云AccessKey ID + :param access_key_secret: 阿里云AccessKey Secret + :param endpoint: OSS区域对应的Endpoint + :param bucket_name: OSS中的Bucket名称 + :param file_source: 本地文件路径或HTTP链接 + :param prefix: 设置规则应用的前缀为文件所在目录 + :param expiration_days: 文件保存天数,默认7天后删除 + :return: 文件的公共访问地址 + """ + + # 创建Bucket实例 + auth = oss2.Auth(access_key_id, access_key_secret) + bucket = oss2.Bucket(auth, endpoint, bucket_name) + + ### 1. 设置生命周期规则 ### + rule_id = f'delete_after_{expiration_days}_days' # 规则ID + # prefix = oss_file_name.split('/')[0] + '/' # 设置规则应用的前缀为文件所在目录 + + + # 定义生命周期规则 + rule = oss2.models.LifecycleRule(rule_id, prefix, status=oss2.models.LifecycleRule.ENABLED, + expiration=oss2.models.LifecycleExpiration(days=expiration_days)) + + # 设置Bucket的生命周期 + lifecycle = oss2.models.BucketLifecycle([rule]) + bucket.put_bucket_lifecycle(lifecycle) + + print(f"已设置生命周期规则:文件将在{expiration_days}天后自动删除") + + ### 2. 判断文件来源并上传到OSS ### + if file_source.startswith('http://') or file_source.startswith('https://'): + # HTTP 链接,先下载文件 + try: + response = requests.get(file_source, stream=True) + response.raise_for_status() + parsed_url = urlparse(file_source) + # 提取路径部分并解码 + path = unquote(parsed_url.path) + # 获取路径的最后一部分作为文件名 + filename = path.split('/')[-1] + oss_file_name=prefix+'/'+ filename + bucket.put_object(oss_file_name, response.content) + print(f"文件从 HTTP 链接上传成功:{file_source}") + except requests.exceptions.RequestException as e: + print(f"从 HTTP 链接下载文件失败: {e}") + return None + else: + # 本地文件路径 + try: + filename=os.path.basename(file_source) + oss_file_name=prefix+'/'+ filename + bucket.put_object_from_file(oss_file_name, file_source) + print(f"文件从本地路径上传成功:{file_source}") + except oss2.exceptions.OssError as e: + print(f"从本地路径上传文件失败: {e}") + return None + + ### 3. 构建公共访问URL ### + file_url = f"http://{bucket_name}.{endpoint.replace('http://', '')}/{oss_file_name}" + + print(f"文件上传成功,公共访问地址:{file_url}") + + return file_url + + +def download_video_and_get_thumbnail(url, thumbnail_path): + """ + 从指定URL下载MP4视频,提取首帧作为缩略图,并返回缩略图路径及视频时长。 + + 参数: + url (str): 视频的URL地址。 + thumbnail_path (str): 缩略图的保存路径。 + + 返回: + tuple: (缩略图路径, 视频时长(秒)) + + 异常: + 可能抛出requests.exceptions.RequestException,cv2.error,IOError等异常。 + """ + logger.info("处理视频开始") + # 创建临时目录以下载视频 + with tempfile.TemporaryDirectory() as tmp_dir: + # 下载视频到临时文件 + video_path = os.path.join(tmp_dir, 'temp_video.mp4') + response = requests.get(url, stream=True) + response.raise_for_status() # 确保请求成功 + + with open(video_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: # 过滤掉保持连接的空白块 + f.write(chunk) + + # 提取视频首帧作为缩略图 + vidcap = cv2.VideoCapture(video_path) + success, image = vidcap.read() + vidcap.release() + if not success: + raise RuntimeError("无法读取视频的首帧,请检查视频文件是否有效。") + + # 确保缩略图的目录存在 + thumbnail_dir = os.path.dirname(thumbnail_path) + if thumbnail_dir: + os.makedirs(thumbnail_dir, exist_ok=True) + + # 保存缩略图 + cv2.imwrite(thumbnail_path, image) + + # 使用moviepy计算视频时长 + clip = VideoFileClip(video_path) + duration = clip.duration + clip.close() + logger.info("处理视频完成") + # OSS 配置(建议将凭证存储在安全的地方) + oss_access_key_id="LTAI5tRTG6pLhTpKACJYoPR5" + oss_access_key_secret="E7dMzeeMxq4VQvLg7Tq7uKf3XWpYfN" + oss_endpoint="http://oss-cn-shanghai.aliyuncs.com" + oss_bucket_name="cow-agent" + oss_prefix="cow" + + # 上传文件到 OSS + file_path = thumbnail_path + file_url = upload_oss(oss_access_key_id, oss_access_key_secret, oss_endpoint, oss_bucket_name, file_path, oss_prefix) + logger.info("上传缩略图") + # 删除临时文件 + try: + os.remove(thumbnail_path) + except FileNotFoundError: + pass # 如果文件未找到,跳过删除 + + return file_url, duration + +def contains_url(text): + # 定义检测网址的正则表达式 + url_pattern = re.compile( + r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+' + ) + # 检查字符串是否包含网址 + return bool(url_pattern.search(text)) + +def get_first_char_if_digit(s): + if s and s[0].isdigit(): # 判断字符串是否非空且首字符为数字 + return int(s[0]) # 返回数字形式 + return None # 如果不是数字则返回 None + +def remove_at_mention_regex(text): + # 使用正则表达式去掉“在群聊中@了你” + return re.sub(r"在群聊中@了你", "", text) + +def extract_nickname(text)->str: + if "在群聊中@了你" in text: + # 如果包含 "在群聊中@了你",提取其前面的名字 + match = re.search(r"^(.*?)在群聊中@了你", text) + if match: + return match.group(1).strip() + elif ": @" in text: + # 如果包含 ": @",提取其前面的名字 + return text.split(": @")[0].strip() + return '' + +def check_chatroom(userName): + pattern = r'^\d+@chatroom$' + if re.match(pattern, userName): + return True + return False + +def remove_markdown_symbol(text: str): + # 移除markdown格式,目前先移除** + if not text or not isinstance(text, str): + return text + # 去除加粗、斜体等格式 + #text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text) # 去除加粗 + text=re.sub(r'\*\*(.*?)\*\*', r'\1', text) + text = re.sub(r'\*([^*]+)\*', r'\1', text) # 去除斜体 + text = re.sub(r'__([^_]+)__', r'\1', text) # 去除加粗(下划线) + text = re.sub(r'_(.*?)_', r'\1', text) # 去除斜体(下划线) + + # 去除行内代码块 + text = re.sub(r'`([^`]+)`', r'\1', text) + + # 去除换行符\n,或者多余的空格 + #text = re.sub(r'\n+', ' ', text) + + # 去除列表编号等 + #text = re.sub(r'^\d+\.\s*', '', text, flags=re.MULTILINE) + + + #text = re.sub('[\\\`\*\_\[\]\#\+\-\!\>]', '', text) + text = re.sub('[\\\`\*\_\[\]\#\+\!\>]', '', text) + print(text) + return text + + +async def save_to_local_from_url_async(url): + ''' + 从url保存到本地tmp目录 + ''' + parsed_url = urlparse(url) + # 从 URL 提取文件名 + filename = os.path.basename(parsed_url.path) + # 拼接完整路径 + tmp_file_path = os.path.join(os.getcwd(), 'tmp', filename) + + # 检查是否存在同名文件 + if os.path.exists(tmp_file_path): + logger.info(f"文件已存在,将覆盖:{tmp_file_path}") + + # 异步下载文件并保存到临时目录 + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 200: + async with aiofiles.open(tmp_file_path, 'wb') as f: + async for chunk in response.content.iter_chunked(1024): + await f.write(chunk) + else: + logger.error(f"无法下载文件,HTTP状态码:{response.status}") + return None + + return tmp_file_path \ No newline at end of file diff --git a/config-dev.json b/config-dev.json new file mode 100644 index 0000000..d5a45ad --- /dev/null +++ b/config-dev.json @@ -0,0 +1,9 @@ +{ + "debug": false, + "redis_host":"192.168.2.121", + "redis_port":8090, + "redis_password":"telpo#1234", + "redis_db":3, + "kafka_bootstrap_servers":"192.168.2.121:9092", + "aiops_api":"https://id.ssjlai.com/aiopsadmin" +} diff --git a/config-production.json b/config-production.json new file mode 100644 index 0000000..a6b5ec5 --- /dev/null +++ b/config-production.json @@ -0,0 +1,9 @@ +{ + "debug": false, + "redis_host":"172.19.42.40", + "redis_port":8090, + "redis_password":"telpo#1234", + "redis_db":3, + "kafka_bootstrap_servers":"172.19.42.40:9092,172.19.42.41:9092,172.19.42.48:9092", + "aiops_api":"https://ai.ssjlai.com/aiopsadmin" +} diff --git a/config-template.json b/config-template.json new file mode 100644 index 0000000..d0268d3 --- /dev/null +++ b/config-template.json @@ -0,0 +1,37 @@ +{ + "channel_type": "wx", + "model": "", + "open_ai_api_key": "YOUR API KEY", + "claude_api_key": "YOUR API KEY", + "text_to_image": "dall-e-2", + "voice_to_text": "openai", + "text_to_voice": "openai", + "proxy": "", + "hot_reload": false, + "single_chat_prefix": [ + "bot", + "@bot" + ], + "single_chat_reply_prefix": "[bot] ", + "group_chat_prefix": [ + "@bot" + ], + "group_name_white_list": [ + "ChatGPT测试群", + "ChatGPT测试群2" + ], + "image_create_prefix": [ + "画" + ], + "speech_recognition": true, + "group_speech_recognition": false, + "voice_reply_voice": false, + "conversation_max_tokens": 2500, + "expires_in_seconds": 3600, + "character_desc": "你是基于大语言模型的AI智能助手,旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", + "temperature": 0.7, + "subscribe_msg": "感谢您的关注!\n这里是AI智能助手,可以自由对话。\n支持语音对话。\n支持图片输入。\n支持图片输出,画字开头的消息将按要求创作图片。\n支持tool、角色扮演和文字冒险等丰富的插件。\n输入{trigger_prefix}#help 查看详细指令。", + "use_linkai": false, + "linkai_api_key": "", + "linkai_app_code": "" +} diff --git a/config-test.json b/config-test.json new file mode 100644 index 0000000..153f956 --- /dev/null +++ b/config-test.json @@ -0,0 +1,9 @@ +{ + "debug": false, + "redis_host":"47.116.142.20", + "redis_port":8090, + "redis_password":"telpo#1234", + "redis_db":3, + "kafka_bootstrap_servers":"172.19.42.53:9092", + "aiops_api":"https://id.ssjlai.com/aiopsadmin" +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..d5a45ad --- /dev/null +++ b/config.json @@ -0,0 +1,9 @@ +{ + "debug": false, + "redis_host":"192.168.2.121", + "redis_port":8090, + "redis_password":"telpo#1234", + "redis_db":3, + "kafka_bootstrap_servers":"192.168.2.121:9092", + "aiops_api":"https://id.ssjlai.com/aiopsadmin" +} diff --git a/config.py b/config.py new file mode 100644 index 0000000..b3d906c --- /dev/null +++ b/config.py @@ -0,0 +1,179 @@ +import json +import logging +import os +import pickle +import copy + +from common.log import logger + +# from common.log import logger + +# 示例配置文件 +DEBUG = True + +available_setting = { + + "qwen_access_key_id": "", + "qwen_access_key_secret": "", + + "debug": False, + #redis 配置 + "redis_host":"", + "redis_port":0, + "redis_password":"", + "redis_db":0, + # kafka配置 + "kafka_bootstrap_servers":"", + # aiops平台 + "aiops_api":"" +} + +class Config(dict): + def __init__(self, d=None): + super().__init__() + if d is None: + d = {} + for k, v in d.items(): + self[k] = v + # user_datas: 用户数据,key为用户名,value为用户数据,也是dict + self.user_datas = {} + + def __getitem__(self, key): + if key not in available_setting: + raise Exception("key {} not in available_setting".format(key)) + return super().__getitem__(key) + + def __setitem__(self, key, value): + if key not in available_setting: + raise Exception("key {} not in available_setting".format(key)) + return super().__setitem__(key, value) + + def get(self, key, default=None): + try: + return self[key] + except KeyError as e: + return default + except Exception as e: + raise e + + +config = Config() + + +def drag_sensitive(config): + try: + if isinstance(config, str): + conf_dict: dict = json.loads(config) + conf_dict_copy = copy.deepcopy(conf_dict) + for key in conf_dict_copy: + if "key" in key or "secret" in key: + if isinstance(conf_dict_copy[key], str): + conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:] + return json.dumps(conf_dict_copy, indent=4) + + elif isinstance(config, dict): + config_copy = copy.deepcopy(config) + for key in config: + if "key" in key or "secret" in key: + if isinstance(config_copy[key], str): + config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:] + return config_copy + except Exception as e: + logger.exception(e) + return config + return config + + +def load_config(): + global config + # config_path = "./config.json" + # if not os.path.exists(config_path): + # logger.info("配置文件不存在,将使用config-template.json模板") + # config_path = "./config-template.json" + + # 默认加载 config.json 或者 config-template.json + environment = os.environ.get('environment', 'default') # 默认是生产环境 + logger.info(f"当前环境: {environment}") + + if environment == "test": + config_path = "./config-test.json" + elif environment == "production": + config_path = "./config-production.json" + elif environment == "dev": + config_path = "./config-dev.json" + elif environment == "default": + config_path = "./config.json" + else: + logger.error("无效的环境配置,使用默认的 config-template.json") + config_path = "./config-template.json" + + # 加载配置文件 + if not os.path.exists(config_path): + logger.info(f"配置文件 {config_path} 不存在,将使用 config-template.json 模板") + config_path = "./config-template.json" + + config_str = read_file(config_path) + logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str))) + + # 将json字符串反序列化为dict类型 + config = Config(json.loads(config_str)) + + # override config with environment variables. + # Some online deployment platforms (e.g. Railway) deploy project from github directly. So you shouldn't put your secrets like api key in a config file, instead use environment variables to override the default config. + for name, value in os.environ.items(): + name = name.lower() + if name in available_setting: + logger.info("[INIT] override config by environ args: {}={}".format(name, value)) + try: + config[name] = eval(value) + except: + if value == "false": + config[name] = False + elif value == "true": + config[name] = True + else: + config[name] = value + + if config.get("debug", False): + logger.setLevel(logging.DEBUG) + logger.debug("[INIT] set log level to DEBUG") + + logger.info("[INIT] load config: {}".format(drag_sensitive(config))) + +def get_root(): + return os.path.dirname(os.path.abspath(__file__)) + + +def read_file(path): + with open(path, mode="r", encoding="utf-8") as f: + return f.read() + + +def conf(): + return config + +# # global plugin config +# plugin_config = {} + + +# def write_plugin_config(pconf: dict): +# """ +# 写入插件全局配置 +# :param pconf: 全量插件配置 +# """ +# global plugin_config +# for k in pconf: +# plugin_config[k.lower()] = pconf[k] + + +# def pconf(plugin_name: str) -> dict: +# """ +# 根据插件名称获取配置 +# :param plugin_name: 插件名称 +# :return: 该插件的配置项 +# """ +# return plugin_config.get(plugin_name.lower()) + + +# # 全局配置,用于存放全局生效的状态 +# global_config = {"admin_users": []} diff --git a/docker/Dockerfile.latest b/docker/Dockerfile.latest new file mode 100644 index 0000000..fa43eb6 --- /dev/null +++ b/docker/Dockerfile.latest @@ -0,0 +1,42 @@ +FROM python:3.10-slim-bullseye + +LABEL maintainer="foo@bar.com" +ARG TZ='Asia/Shanghai' + +# RUN echo /etc/apt/sources.list +# RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list +RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list + + +# Set the timezone and configure tzdata +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata \ + && ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \ + && dpkg-reconfigure --frontend noninteractive tzdata \ + && apt-get clean + + +ENV BUILD_PREFIX=/app + +ADD . ${BUILD_PREFIX} + +RUN apt-get update \ + &&apt-get install -y --no-install-recommends bash ffmpeg espeak libavcodec-extra\ + && cd ${BUILD_PREFIX} \ + && cp config-template.json config.json \ + && /usr/local/bin/python -m pip install --no-cache --upgrade pip \ + && pip install --no-cache -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ + +WORKDIR ${BUILD_PREFIX} + +ADD docker/entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh \ + && mkdir -p /home/noroot \ + && groupadd -r noroot \ + && useradd -r -g noroot -s /bin/bash -d /home/noroot noroot \ + && chown -R noroot:noroot /home/noroot ${BUILD_PREFIX} /usr/local/lib + +USER noroot + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/build.latest.sh b/docker/build.latest.sh new file mode 100644 index 0000000..5162216 --- /dev/null +++ b/docker/build.latest.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +unset KUBECONFIG + +cd .. && docker build -f docker/Dockerfile.latest \ + -t 139.224.254.18:5000/ssjl/ai-ops-wechat . + +docker tag 139.224.254.18:5000/ssjl/ai-ops-wechat 139.224.254.18:5000/ssjl/ai-ops-wechat:$(date +%y%m%d) \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..3756c55 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e + +# build prefix +AI_OPS_WECHAT_PREFIX=${AI_OPS_WECHAT_PREFIX:-""} +# path to config.json +AI_OPS_WECHAT_CONFIG_PATH=${AI_OPS_WECHAT_CONFIG_PATH:-""} +# execution command line +AI_OPS_WECHAT_EXEC=${AI_OPS_WECHAT_EXEC:-""} + +# Determine the environment and set the config file accordingly +if [ "$environment" == "test" ]; then + AI_OPS_WECHAT_CONFIG_PATH=${AI_OPS_WECHAT_CONFIG_PATH:-$AI_OPS_WECHAT_PREFIX/config-test.json} +elif [ "$environment" == "production" ]; then + AI_OPS_WECHAT_CONFIG_PATH=${AI_OPS_WECHAT_CONFIG_PATH:-$AI_OPS_WECHAT_PREFIX/config-production.json} +elif [ "$environment" == "dev" ]; then + AI_OPS_WECHAT_CONFIG_PATH=${AI_OPS_WECHAT_CONFIG_PATH:-$AI_OPS_WECHAT_PREFIX/config-dev.json} +else + echo "Invalid environment specified. Please set environment to 'test' or 'prod' or 'dev'." + exit 1 +fi + +# AI_OPS_WECHAT_PREFIX is empty, use /app +if [ "$AI_OPS_WECHAT_PREFIX" == "" ]; then + AI_OPS_WECHAT_PREFIX=/app +fi + +# AI_OPS_WECHAT_EXEC is empty, use ‘python app.py’ +if [ "$AI_OPS_WECHAT_EXEC" == "" ]; then + AI_OPS_WECHAT_EXEC="python run.py" +fi + +# go to prefix dir +cd $AI_OPS_WECHAT_PREFIX + +# # execute +# $AI_OPS_WECHAT_EXEC + +if [ "$environment" == "default" ]; then + $AI_OPS_WECHAT_EXEC +else + #uvicorn app.main:app --host 0.0.0.0 --port 5000 + #$AI_OPS_WECHAT_EXEC + $AI_OPS_WECHAT_EXEC +fi diff --git a/model/models.py b/model/models.py new file mode 100644 index 0000000..d1cf0a8 --- /dev/null +++ b/model/models.py @@ -0,0 +1,171 @@ +from pydantic import BaseModel, ValidationError +from dataclasses import dataclass, asdict +from typing import List +from enum import Enum, unique +from fastapi import HTTPException + +from functools import wraps +from fastapi import Request +import time + +@dataclass +class AgentConfig(BaseModel): + chatroomIdWhiteList: List[str] = [] + agentTokenId: str + agentEnabled: bool + addContactsFromChatroomIdWhiteList: List[str] = [] + chatWaitingMsgEnabled: bool + + +@dataclass +class AddGroupContactsHistory(BaseModel): + chatroomId:str + wxid:str + contactWixd:str + addTime:int + + + +@unique +class OperationType(Enum): + ADD_FRIEND = 2 + ACCEPT_FRIEND = 3 + REJECT_FRIEND = 4 + + +def validate_wxid(func): + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + # 从 kwargs 中获取 wxid,如果不存在,则从请求体中获取 + wxid = kwargs.get("wxid") + if wxid is None: + # 异步获取请求体 + body = await request.json() + wxid = body.get("wxid") + + # 如果 wxid 仍然为空,返回错误 + if not wxid: + return {"code": 400, "message": "wxid 不能为空"} + + # 验证 wxid 是否存在 + k, loginfo = await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + if not k: + return {"code": 404, "message": f"{wxid} 没有对应的登录信息"} + + login_status=loginfo.get('status','0') + + if login_status != '1': + return {"code": 401, "message": f"{wxid} 已经离线"} + + # 将 k 和 loginfo 注入到路由函数的参数中 + # kwargs["loginfo_key"] = k + # kwargs["loginfo"] = loginfo + # 如果验证通过,继续执行原始函数 + return await func(request, *args, **kwargs) + return wrapper + + +def auth_required_time(f): + @wraps(f) + async def decorated_function(request: Request, *args, **kwargs): + try: + body = await request.json() + wxid = body.get("wxid") + if not wxid: + return {"code": 400, "message": "wxid 不能为空"} + + # 模拟获取登录信息 + loginfo = {"status": "1", "create_at": time.time() - 2 * 24 * 60 * 60, "tokenId": "token123", "appId": "app123"} + login_status = loginfo.get('status', '0') + if login_status != '1': + return {"code": 401, "message": f"{wxid} 已经离线"} + + creation_timestamp = int(loginfo.get('create_at', time.time())) + current_timestamp = time.time() + three_days_seconds = 3 * 24 * 60 * 60 # 三天的秒数 + diff_flag = (current_timestamp - creation_timestamp) >= three_days_seconds + if not diff_flag: + return {'code': 401, 'message': '用户创建不够三天,不能使用该功能'} + + kwargs['token_id'] = loginfo.get('tokenId') + kwargs['app_id'] = loginfo.get('appId') + return await f(*args, **kwargs) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + return decorated_function + + +# def auth_required_time(f): +# @wraps(f) +# async def decorated_function(request: Request, *args, **kwargs): +# try: +# body = await request.json() +# print("Received body:", body) # 打印请求体 +# wxid = body.get("wxid") +# if not wxid: +# return {"code": 400, "message": "wxid 不能为空"} + +# # 模拟获取登录信息 +# loginfo = {"status": "1", "create_at": time.time() - 2 * 24 * 60 * 60, "tokenId": "token123", "appId": "app123"} +# login_status = loginfo.get('status', '0') +# if login_status != '1': +# return {"code": 401, "message": f"{wxid} 已经离线"} + +# creation_timestamp = int(loginfo.get('create_at', time.time())) +# current_timestamp = time.time() +# three_days_seconds = 3 * 24 * 60 * 60 # 三天的秒数 +# diff_flag = (current_timestamp - creation_timestamp) >= three_days_seconds +# if not diff_flag: +# return {'code': 401, 'message': '用户创建不够三天,不能使用该功能'} + +# kwargs['token_id'] = loginfo.get('tokenId') +# kwargs['app_id'] = loginfo.get('appId') +# return await f(*args, **kwargs) +# except Exception as e: +# raise HTTPException(status_code=422, detail=f"请求体解析失败: {str(e)}") +# return decorated_function + + +from functools import wraps +import time +from fastapi import Request, HTTPException + +def auth_required_time(f): + @wraps(f) + async def decorated_function(request: Request, *args, **kwargs): + try: + # 解析 JSON 只调用一次 + body = await request.json() + wxid = body.get("wxid") + + if not wxid: + raise HTTPException(status_code=400, detail="wxid 不能为空") + + # 调用异步方法获取登录信息 + k, loginfo = await request.app.state.gewe_service.get_login_info_by_wxid_async(wxid) + if not k: + raise HTTPException(status_code=404, detail=f"{wxid} 没有对应的登录信息") + + login_status = loginfo.get('status', '0') + if login_status != '1': + raise HTTPException(status_code=401, detail=f"{wxid} 已经离线") + + # 计算创建时间差 + creation_timestamp = int(loginfo.get('create_at', time.time())) + current_timestamp = time.time() + three_days_seconds = 3 * 24 * 60 * 60 # 三天的秒数 + + if (current_timestamp - creation_timestamp) < three_days_seconds: + raise HTTPException(status_code=401, detail="用户创建不够三天,不能使用该功能") + + # 注入 token_id 和 app_id + kwargs['token_id'] = loginfo.get('tokenId') + kwargs['app_id'] = loginfo.get('appId') + + # 需要 `await` 调用被装饰的异步函数 + return await f(request, *args, **kwargs) + + except HTTPException as e: + return {"code": e.status_code, "message": e.detail} + + return decorated_function diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dbc722f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,58 @@ +#voice +pydub>=0.25.1 # need ffmpeg +SpeechRecognition # google speech to text +gTTS>=2.3.1 # google text to speech +pyttsx3>=2.90 # pytsx text to speech +baidu_aip>=4.16.10 # baidu voice +azure-cognitiveservices-speech # azure voice +edge-tts # edge-tts +numpy<=1.24.2 +langid # language detect +elevenlabs==1.0.3 # elevenlabs TTS + + +tiktoken>=0.3.2 # openai calculate token + + + +openai==0.27.8 +HTMLParser>=0.0.2 +PyQRCode>=1.2.1 +qrcode>=7.4.2 +requests>=2.28.2 +chardet>=5.1.0 +Pillow +pre-commit +web.py +linkai>=0.0.6.0 +pypng +pypinyin +redis +flask +flask_restful +confluent_kafka +av +#pilk + +# silk-python +# pysilk + + +pysilk-mod +#pip3 install pysilk-mod + +oss2 +gunicorn + + +opencv-python +moviepy + +fastapi +uvicorn +celery +pydantic +aioredis>=2.0.0 +requests +aiokafka +aiofiles \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..b24222c --- /dev/null +++ b/run.py @@ -0,0 +1,27 @@ +import subprocess +import sys +import os + +def start_fastapi(): + environment = os.environ.get('environment', 'default') + if environment == 'default': + process = subprocess.Popen(["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]) + else: + process = subprocess.Popen(["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]) + return process + +def start_celery(): + if sys.platform == "win32": + process = subprocess.Popen(["celery", "-A", "app.celery_app", "worker", "--loglevel=info", "-P", "solo"]) + else: + process = subprocess.Popen(["celery", "-A", "app.celery_app", "worker", "--loglevel=info"]) + return process + +if __name__ == "__main__": + # 启动 FastAPI 和 Celery + fastapi_process = start_fastapi() + # celery_process = start_celery() # 如果需要启动 Celery,取消注释 + + # 等待子进程完成 + fastapi_process.wait() + # celery_process.wait() # 如果需要等待 Celery,取消注释 \ No newline at end of file diff --git a/services/biz_service.py b/services/biz_service.py new file mode 100644 index 0000000..9801f70 --- /dev/null +++ b/services/biz_service.py @@ -0,0 +1,263 @@ + + +import aiohttp +import asyncio +import json +import base64 +import io +import json +import os +import threading +import time +import uuid,random +from fastapi import FastAPI, Depends +from common.log import logger +from common.singleton import singleton +from services.kafka_service import KafkaService +from fastapi import Request +from common.utils import * + +@singleton +class BizService(): + + def __init__(self,app:FastAPI): + if not hasattr(self, 'initialized'): + #self.kafka_service =kafka_service # 获取 KafkaService 单例 + self.kafka_service =app.state.kafka_service + self.wxchat=app.state.gewe_service + self.redis_service=app.state.redis_service + self.initialized = True + + def setup_handlers(self): + """设置默认的消息处理器""" + # 这里可以添加业务逻辑 + + # 注册默认处理器 + self.kafka_service.add_handler( + self.kafka_service.consumer_topic, + self.ops_messages_process_handler + ) + + async def ops_messages_process_handler(self, message: str): + """消息处理器""" + #print(f"BizService handling message: {message}") + try: + msg_content = message + cleaned_content = clean_json_string(msg_content) + content = json.loads(cleaned_content) + data = content.get("data", {}) + msg_type_data = data.get("msg_type", None) + content_data = data.get("content", {}) + if msg_type_data=="login": + await self.login_handler_async(content_data) + elif msg_type_data == 'group-sending': + print(f'处理消息类型group-sending') + await self.group_sending_handler_async(content_data) + elif msg_type_data == 'login_wx_captch_code': + pass + else: + print(f'kakfa 未处理息类型 {msg_type_data}') + + except Exception as e: + print(f"处理消息时发生错误: {e}, 消息内容: {message}") + + async def login_handler_async(self, content_data: dict): + tel=content_data.get('tel', '18733438393') + token_id=content_data.get('token_id', 'c50b7d57-2efa-4a53-8c11-104a06d1e1fa') + region_id=content_data.get('region_id', '440000') + agent_token_id=content_data.get('agent_token_id', 'sk-fAOIdANeGXjWKW5mFybnsNZZGYU2lFLmqVY9rVFaFmjiOaWt3tcWMi') + loginfo= await self.wxchat.get_login_info_from_cache_async(tel,token_id,region_id,agent_token_id) + print(loginfo) + status=loginfo.get('status','0') + + if status=='1': + logger.info(f'手机号{tel},wx_token{token_id} 已经微信登录,终止登录流程') + return + + async def group_sending_handler_async(self,content_data: dict): + agent_tel=content_data.get('agent_tel', '18733438393') + hash_key = f"__AI_OPS_WX__:LOGININFO:{agent_tel}" + logininfo = await self.redis_service.get_hash(hash_key) + + if not logininfo: + logger.warning(f"未找到 {agent_tel} 的登录信息") + return + + token_id = logininfo.get('tokenId') + app_id = logininfo.get('appId') + agent_wxid = logininfo.get('wxid') + + # 获取联系人列表并计算交集 + hash_key = f"__AI_OPS_WX__:CONTACTS_BRIEF:{agent_wxid}" + cache_friend_wxids_str=await self.redis_service.get_hash_field(hash_key,"data") + cache_friend_wxids_list=json.loads(cache_friend_wxids_str) if cache_friend_wxids_str else [] + cache_friend_wxids=[f["userName"] for f in cache_friend_wxids_list] + + + # 获取群交集 + hash_key = f"__AI_OPS_WX__:GROUPS_INFO:{agent_wxid}" + cache_chatrooms = await self.redis_service.get_hash(hash_key) + cache_chatroom_ids=cache_chatrooms.keys() + + + + wxid_contact_list_content_data = [c['wxid'] for c in content_data.get("contact_list", [])] + intersection_friend_wxids = list(set(cache_friend_wxids) & set(wxid_contact_list_content_data)) + intersection_chatroom_ids = list(set(cache_chatroom_ids) & set(wxid_contact_list_content_data)) + + intersection_wxids=intersection_friend_wxids+intersection_chatroom_ids + + # 发送消息 + wx_content_list = content_data.get("wx_content", []) + + self.wxchat.forward_video_aeskey = '' + self.wxchat.forward_video_cdnvideourl = '' + self.wxchat.forward_video_length = 0 + + for intersection_wxid in intersection_wxids: + for wx_content in wx_content_list: + if wx_content["type"] == "text": + await self.send_text_message_async(token_id, app_id, agent_wxid, [intersection_wxid], wx_content["text"]) + elif wx_content["type"] == "image_url": + await self.send_image_messagae_sync(token_id, app_id, agent_wxid, [intersection_wxid], wx_content.get("image_url", {}).get("url")) + elif wx_content["type"] == "tts": + await self.send_tts_message(token_id, app_id, agent_wxid, [intersection_wxid], wx_content["text"]) + elif wx_content["type"] == "file": + await self.send_file_message(token_id, app_id, agent_wxid, [intersection_wxid], wx_content.get("file_url", {}).get("url")) + + + async def send_text_message_async(self, token_id, app_id, agent_wxid, intersection_wxids, text): + for t in intersection_wxids: + # 发送文本消息 + ret,ret_msg,res = await self.wxchat.post_text(token_id, app_id, t, text) + logger.info(f'{agent_wxid} 向 {t} 发送文字【{text}】') + + # 构造对话消息并发送到 Kafka + input_wx_content_dialogue_message = [{"type": "text", "text": text}] + input_message = dialogue_message(agent_wxid, t, input_wx_content_dialogue_message) + await self.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s", input_message) + + # 等待随机时间 + await asyncio.sleep(random.uniform(5, 15)) + + async def send_image_messagae_sync(self,token_id, app_id, agent_wxid, intersection_wxids, image_url): + aeskey, cdnthumburl, cdnthumblength, cdnthumbheight, cdnthumbwidth, length, md5 = "", "", 0, 0, 0, 0, "" + for t in intersection_wxids: + if t == intersection_wxids[0]: + # 发送图片 + ret,ret_msg,res = await self.wxchat.post_image(token_id, app_id, t, image_url) + if ret==200: + aeskey = res["aesKey"] + cdnthumburl = res["fileId"] + cdnthumblength = res["cdnThumbLength"] + cdnthumbheight = res["height"] + cdnthumbwidth = res["width"] + length = res["length"] + md5 = res["md5"] + logger.info(f'{agent_wxid} 向 {t} 发送图片【{image_url}】{ret_msg}') + else: + logger.warning(f'{agent_wxid} 向 {t} 发送图片【{image_url}】{ret_msg}') + + else: + if aeskey !="": + # 转发图片 + ret,ret_msg,res = await self.wxchat.forward_image(token_id, app_id, t, aeskey, cdnthumburl, cdnthumblength, cdnthumbheight, cdnthumbwidth, length, md5) + logger.info(f'{agent_wxid} 向 {t} 转发图片【{image_url}】{ret_msg}') + else: + # 发送图片 + ret,ret_msg,res = await self.wxchat.post_image(token_id, app_id, t, image_url) + if ret==200: + aeskey = res["aesKey"] + cdnthumburl = res["fileId"] + cdnthumblength = res["cdnThumbLength"] + cdnthumbheight = res["height"] + cdnthumbwidth = res["width"] + length = res["length"] + md5 = res["md5"] + logger.info(f'{agent_wxid} 向 {t} 发送图片【{image_url}】{ret_msg}') + else: + logger.warning(f'{agent_wxid} 向 {t} 发送图片【{image_url}】{ret_msg}') + + # 构造对话消息并发送到 Kafka + wx_content_dialogue_message = [{"type": "image_url", "image_url": {"url": image_url}}] + input_message = dialogue_message(agent_wxid, t, wx_content_dialogue_message) + await self.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s", input_message) + + # 等待随机时间 + await asyncio.sleep(random.uniform(5, 15)) + + async def send_tts_message(self, token_id, app_id, agent_wxid, intersection_wxids, text): + + voice_during,voice_url=wx_voice(text) + for t in intersection_wxids: + # 发送送语音消息 + if voice_url: + ret,ret_msg,res = await self.wxchat.post_voice(token_id, app_id, t, voice_url,voice_during) + if ret==200: + logger.info(f'{agent_wxid} 向 {t} 发送语音文本【{text}】{ret_msg}') + + # 构造对话消息并发送到 Kafka + input_wx_content_dialogue_message = [{"type": "text", "text": text}] + input_message = dialogue_message(agent_wxid, t, input_wx_content_dialogue_message) + await self.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s", input_message) + else: + logger.warning((f'{agent_wxid} 向 {t} 发送语音文本【{text}】{ret_msg}')) + else: + logger.warning((f'{agent_wxid} 向 {t} 发送语音文本【{text}】出错')) + + # 等待随机时间 + await asyncio.sleep(random.uniform(5, 15)) + + + async def send_file_message(self,token_id, app_id, agent_wxid, intersection_wxids, file_url): + parsed_url = urlparse(file_url) + path = parsed_url.path + # 从路径中提取文件名 + filename = path.split('/')[-1] + # 获取扩展名 + _, ext = os.path.splitext(filename) + + if ext == '.mp4': + await self.send_video_message(self, token_id, app_id, agent_wxid, intersection_wxids, file_url) + else: + await self.send_other_file_message(self, token_id, app_id, agent_wxid, intersection_wxids, file_url) + #time.sleep(random.uniform(5, 15)) + + + async def send_video_message(self, token_id, app_id, agent_wxid, intersection_wxids, file_url): + for t in intersection_wxids: + # 发送视频消息 + parsed_url = urlparse(file_url) + filename = os.path.basename(parsed_url.path) + tmp_file_path = os.path.join(os.getcwd(),'tmp', filename) # 拼接完整路径 + thumbnail_path=tmp_file_path.replace('.mp4','.jpg') + if self.wxchat.forward_video_aeskey == '': + video_thumb_url,video_duration =download_video_and_get_thumbnail(file_url,thumbnail_path) + print(f'视频缩略图 {video_thumb_url} 时长 {video_duration}') + + ret,ret_msg,res = await self.wxchat.post_video(token_id, app_id, t, file_url,video_thumb_url,video_duration) + if ret==200: + self.wxchat.forward_video_aeskey = res["aesKey"] + self.wxchat.forward_video_cdnvideourl = res["cdnThumbUrl"] + self.wxchat.forward_video_length = res["length"] + else: + ret,ret_msg,res = await self.wxchat.forward_video(token_id, app_id, t, self.wxchat.forward_video_aeskey, self.wxchat.forward_video_cdnvideourl, self.wxchat.forward_video_length) + print('转发视频') + if ret==200: + logger.info(f'{agent_wxid} 向 {t} 发送视频【{file_url}】{ret_msg}') + # 构造对话消息并发送到 Kafka + input_wx_content_dialogue_message = [{"type": "file", "file_url": {"url": file_url}}] + input_message = dialogue_message(agent_wxid, t, input_wx_content_dialogue_message) + await self.kafka_service.send_message_async(input_message) + logger.info("发送对话 %s", input_message) + else: + logger.warning((f'{agent_wxid} 向 {t} 发送视频【{file_url}】{ret_msg}')) + + # 等待随机时间 + await asyncio.sleep(random.uniform(5, 15)) + + async def send_other_file_message(self, token_id, app_id, agent_wxid, intersection_wxids, file_url): + print('send_otherfile_message') diff --git a/services/gewe_service.py b/services/gewe_service.py new file mode 100644 index 0000000..1965ba2 --- /dev/null +++ b/services/gewe_service.py @@ -0,0 +1,1080 @@ +import aiohttp +import asyncio +import json +import base64 +import io +import json +import os +import threading +import time +import uuid +from fastapi import FastAPI, Depends +from common.singleton import singleton +from common.log import logger +from model.models import AddGroupContactsHistory + +#@singleton +class GeWeService: + _instance = None + _lock = asyncio.Lock() # 异步锁,确保单例初始化线程安全 + + def __init__(self,app:FastAPI, base_url: str): + if GeWeService._instance is not None: + raise RuntimeError("请使用 get_instance() 获取单例!") + self.base_url = base_url + self.redis_service=app.state.redis_service + + @classmethod + async def get_instance(cls, app:FastAPI,base_url: str = "http://api.geweapi.com/gewe"): + """ + 获取 GeWeChatCom 单例,确保只初始化一次。 + """ + async with cls._lock: # 确保多个协程不会并发创建多个实例 + if cls._instance is None: + cls._instance = cls(app,base_url) + return cls._instance + + # _instance = None + # def __new__(cls,app:FastAPI,base_url="http://api.geweapi.com/gewe"): + # if not cls._instance: + # cls._instance = super(GeWeService, cls).__new__(cls) + # cls._instance.client = None + # cls._instance.lock_renewal_thread = None + # return cls._instance + + # def __init__(self,app:FastAPI,base_url="http://api.geweapi.com/gewe"): + # if not hasattr(self, 'initialized'): + # #self.kafka_service =kafka_service # 获取 KafkaService 单例 + # # self.kafka_service =app.state.kafka_service + # self.redis_service=app.state.redis_service + # self.base_url = base_url + # self.initialized = True + +############################### 登录模块 ############################### + async def check_login_async(self, token_id: str, app_id: str, uuid: str, captch_code: str = ""): + """ + 执行登录(步骤3) + 获取到登录二维码后需每间隔5s调用本接口来判断是否登录成功。 + """ + api_url = f"{self.base_url}/v2/api/login/checkLogin" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = {"appId": app_id, "uuid": uuid} + if captch_code: + data["captchCode"] = captch_code + + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + + logger.info(f'登录验证码-请求参数 {json.dumps(data)}') + logger.info(f'登录验证码-返回响应 {response_object}') + + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + + async def qrCallback(self, uuid: str, base64_string: str): + """ + 处理二维码回调,显示二维码图片,并提供多种二维码访问链接 + """ + try: + from PIL import Image + base64_string = base64_string.split(',')[1] + img_data = base64.b64decode(base64_string) + img = Image.open(io.BytesIO(img_data)) + _thread = threading.Thread(target=img.show, args=("QRCode",)) + _thread.setDaemon(True) + _thread.start() + except Exception as e: + logger.error(f"处理二维码回调时发生错误: {e}") + + url = f"http://weixin.qq.com/x/{uuid}" + qr_api1 = f"https://api.isoyu.com/qr/?m=1&e=L&p=20&url={url}" + qr_api2 = f"https://api.qrserver.com/v1/create-qr-code/?size=400×400&data={url}" + qr_api3 = f"https://api.pwmqr.com/qrcode/create/?url={url}" + qr_api4 = f"https://my.tv.sohu.com/user/a/wvideo/getQRCode.do?text={url}" + logger.info("您也可以通过以下网站扫描二维码:") + logger.info(f"{qr_api3}\n{qr_api4}\n{qr_api2}\n{qr_api1}") + return [qr_api1, qr_api2, qr_api3, qr_api4] + + async def get_login_qr_code_async(self, token_id: str, app_id: str = "", region_id: str = "440000"): + """ + 获取登录二维码(步骤2) + 第一次登录时传空,后续登录时需传appId。 + """ + api_url = f"{self.base_url}/v2/api/login/getLoginQrCode" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "regionId": region_id + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_data = await response.json() + logger.info(f'{token_id} 的登录APP信息:{json.dumps(response_data, separators=(",", ":"), ensure_ascii=False)}') + return response_data.get('data') + + ############################### 账号管理 ############################### + async def reconnection(self, token_id, app_id): + ''' + 断线重连 + 当系统返回账号已离线,但是手机顶部还显示ipad在线,可用此接口尝试重连,若返回错误/失败则必须重新调用步骤一登录 + 本接口非常用接口,可忽略 + ''' + api_url = f"{self.base_url}/v2/api/login/reconnection" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object + + async def logout_async(self, token_id, app_id): + ''' + 退出 + ''' + api_url = f"{self.base_url}/v2/api/login/logout" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_data = await response.json() + return response_data.get('data') + + async def check_online(self, token_id, app_id): + ''' + 检查是否在线 + 响应结果的data=true则是在线,反之为离线 + ''' + api_url = f"{self.base_url}/v2/api/login/checkOnline" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_data = await response.json() + return response_data.get('data') + + ############################### 联系人模块 ############################### + async def fetch_contacts_list_async(self, token_id, app_id): + ''' + 获取通讯录列表 + + 本接口为长耗时接口,耗时时间根据好友数量递增,若接口返回超时可通过获取通讯录列表缓存接口获取响应结果 + 本接口返回的群聊仅为保存到通讯录中的群聊,若想获取会话列表中的所有群聊,需要通过消息订阅做二次处理。 + 原因:当未获取的群有成员在群内发消息的话会有消息回调, 开发者此刻调用获取群详情接口查询群信息入库保存即可, + 比如说手机上三年前不说话的群,侧滑删除了,用户手机上也不会看到被删除的群聊的 ,但是有群成员说了话他会显示, + 原理就是各个终端(Android、IOS、桌面版微信)取得了消息回调,又去获取群详情信息,本地数据库缓存了下来,显示的手机群聊,让用户感知的。 + ''' + api_url = f"{self.base_url}/v2/api/contacts/fetchContactsList" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def fetch_contacts_list_cache_async(self, token_id, app_id): + ''' + 获取通讯录列表缓存 + 通讯录列表数据缓存10分钟,超时则需要重新调用获取通讯录列表接口 + ''' + api_url = f"{self.base_url}/v2/api/contacts/fetchContactsListCache" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_data = await response.json() + return response_data.get('data') + + async def get_brief_info_async(self, token_id, app_id, wxids: list): + ''' + 获取群/好友简要信息 + 1<= wxids <=100 + ''' + api_url = f"{self.base_url}/v2/api/contacts/getBriefInfo" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "wxids": wxids # list 1<= wxids <=100 + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_data = await response.json() + return response_data.get('data') + + async def get_detail_info(self, token_id, app_id, wxids): + ''' + 获取群/好友详细信息 + 1<= wxids <=20 + ''' + api_url = f"{self.base_url}/v2/api/contacts/getDetailInfo" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "wxids": wxids # list 1<= wxids <=20 + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_data = await response.json() + return response_data.get('data') + + async def delete_friend_async(self, token_id, app_id, friend_wxid): + ''' + 删除好友 + ''' + api_url = f"{self.base_url}/v2/api/contacts/deleteFriend" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "wxid": friend_wxid + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def set_friend_remark(self, token_id, app_id, friend_wxid, remark): + ''' + 设置好友备注 + ''' + api_url = f"{self.base_url}/v2/api/contacts/setFriendRemark" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "wxid": friend_wxid, + "remark": remark + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + ############################### 消息模块 ############################### + async def post_text_async(self, token_id, app_id, to_wxid, content): + api_url = f"{self.base_url}/v2/api/message/postText" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "toWxid": to_wxid, + "content": content + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def post_image_async(self, token_id, app_id, to_wxid, img_url): + api_url = f"{self.base_url}/v2/api/message/postImage" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "toWxid": to_wxid, + "imgUrl": img_url + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers= headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def post_voice_async(self, token_id, app_id, to_wxid, voice_url, voice_duration): + api_url = f"{self.base_url}/v2/api/message/postVoice" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "toWxid": to_wxid, + "voiceUrl": voice_url, + "voiceDuration": voice_duration + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def post_video_async(self, token_id, app_id, to_wxid, video_url, video_thumb_url, video_duration): + api_url = f"{self.base_url}/v2/api/message/postVideo" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "toWxid": to_wxid, + "videoUrl": video_url, + "videoDuration": video_duration, + "thumbUrl": video_thumb_url + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def forward_image_async(self, token_id, app_id, to_wxid, aeskey, cdnthumburl, cdnthumblength, cdnthumbheight, cdnthumbwidth, length, md5): + api_url = f"{self.base_url}/v2/api/message/forwardImage" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "toWxid": to_wxid, + "xml": f"\n\n\t\n\t\n\t\n" + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('data', None), response_object.get('ret', None), response_object.get('msg', None) + + async def forward_video_async(self, token_id, app_id, to_wxid, aeskey, cdnvideourl, length): + api_url = f"{self.base_url}/v2/api/message/forwardVideo" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "toWxid": to_wxid, + "xml": f"\n\n\t\n" + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def add_contacts_async(self, token_id: str, app_id: str, scene: int, option: int, v3: str, v4: str, content: str): + api_url = f"{self.base_url}/v2/api/contacts/addContacts" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "scene": scene, + "option": option, + "v3": v3, + "v4": v4, + "content": content + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None) + + async def check_relation(self, token_id, app_id, wxids: list): + ''' + 检查好友关系 + ''' + api_url = f"{self.base_url}/v2/api/contacts/checkRelation" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "wxids": wxids # list 1<= wxids <=20 + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + ############################### 下载模块 ############################### + async def download_audio_msg_async(self, token_id: str, app_id: str, msg_id: int, xml: str): + data = { + "appId": app_id, + "msgId": msg_id, + "xml": xml + } + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + url = 'http://api.geweapi.com/gewe/v2/api/message/downloadVoice' + async with aiohttp.ClientSession() as session: + async with session.post(url, json=data, headers=headers) as response: + if response.ok: + data = await response.json() + if data['ret'] == 200: + return data['data']['fileUrl'] + else: + return False + else: + return False + + async def download_image_msg_async(self, token_id: str, app_id: str, xml: str): + data = { + "appId": app_id, + "type": 2, + "xml": xml + } + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + async with aiohttp.ClientSession() as session: + async with session.post(f"{self.base_url}/v2/api/message/downloadImage", json=data, headers=headers) as response: + if response.ok: + data = await response.json() + if data['ret'] == 200: + return data['data']['fileUrl'] + else: + return False + else: + return False + + async def download_audio_file(self, fileUrl: str, file_name: str): + local_filename = f'./silk/{file_name}.silk' + async with aiohttp.ClientSession() as session: + async with session.get(fileUrl) as response: + if response.status == 200: + with open(local_filename, 'wb') as f: + while True: + chunk = await response.content.read(1024) + if not chunk: + break + f.write(chunk) + return local_filename + else: + return None + + ############################### 群模块 ############################### + async def get_chatroom_info_async(self, token_id, app_id, chatroom_id): + ''' + 获取群信息 + ''' + api_url = f"{self.base_url}/v2/api/group/getChatroomInfo" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "chatroomId": chatroom_id + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def add_group_member_as_friend(self, token_id, app_id, chatroom_id, member_wxid, content): + ''' + 添加群成员为好友 + ''' + api_url = f"{self.base_url}/v2/api/group/addGroupMemberAsFriend" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "chatroomId": chatroom_id, + "content": content, + "memberWxid": member_wxid, + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def save_contract_list_async(self, token_id, app_id, chatroom_id, oper_type): + ''' + 群保存到通讯录 + 操作类型 3保存到通讯录 2从通讯录移除 + ''' + api_url = f"{self.base_url}/v2/api/group/saveContractList" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "chatroomId": chatroom_id, + "operType": oper_type + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def get_group_memberlist_async(self, token_id, app_id, chatroom_id): + ''' + 获取群成员列表 + ''' + api_url = f"{self.base_url}/v2/api/group/getChatroomMemberList" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "chatroomId": chatroom_id, + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def agree_join_room_async(self, token_id, app_id, url): + ''' + 同意入群 + ''' + api_url = f"{self.base_url}/v2/api/group/agreeJoinRoom" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "url": url, + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + ############################### 朋友圈模块 ################################### + # 在新设备登录后的1-3天内,您将无法使用朋友圈发布、点赞、评论等功能。在此期间,如果尝试进行这些操作,您将收到来自微信团队的提醒。请注意遵守相关规定。 + + async def sns_visible_scope(self, token_id, app_id, option): + ''' + 朋友圈可见范围 option 可选项 + 1:全部 + 2:最近半年 + 3:最近一个月 + 4:最近三天 + ''' + api_url = f"{self.base_url}/v2/api/sns/snsVisibleScope" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "option": option, + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def stranger_visibility_enabled(self, token_id, app_id, enabled: bool): + ''' + 是否允许陌生人查看朋友圈 + ''' + api_url = f"{self.base_url}/v2/api/sns/strangerVisibilityEnabled" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "enabled": enabled + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def send_text_sns_async(self, token_id, app_id, content): + ''' + 发送文字朋友圈 + ''' + api_url = f"{self.base_url}/v2/api/sns/sendTextSns" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "content": content + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def send_image_sns_async(self, token_id, app_id, content, img_infos: list): + ''' + 发送图片朋友圈 + ''' + api_url = f"{self.base_url}/v2/api/sns/sendImgSns" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "allowWxIds": [], + "atWxIds": [], + "disableWxIds": [], + "content": content, + "imgInfos": img_infos, # 通过上传朋友圈图片接口获取 + "privacy": False + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def send_video_sns_async(self, token_id, app_id, content: str, video_info: dict): + ''' + 发送视频朋友圈 + ''' + api_url = f"{self.base_url}/v2/api/sns/sendVideoSns" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "content": content, + "allowWxIds": [], + "atWxIds": [], + "disableWxIds": [], + "videoInfo": video_info, + "privacy": False + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def upload_sns_image_async(self, token_id, app_id, img_urls: list): + ''' + 上传朋友圈图片 + ''' + api_url = f"{self.base_url}/v2/api/sns/uploadSnsImage" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "imgUrls": img_urls + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + async def upload_sns_video_async(self, token_id, app_id, video_url: str, video_thumb_url: str): + ''' + 上传朋友圈视频 + ''' + api_url = f"{self.base_url}/v2/api/sns/uploadSnsVideo" + headers = { + 'X-GEWE-TOKEN': token_id, + 'Content-Type': 'application/json' + } + data = { + "appId": app_id, + "thumbUrl": video_thumb_url, + "videoUrl": video_url, + } + async with aiohttp.ClientSession() as session: + async with session.post(api_url, headers=headers, json=data) as response: + response_object = await response.json() + return response_object.get('ret', None), response_object.get('msg', None), response_object.get('data', None) + + ############################### 其他 ############################### + + async def save_session_messages_to_cache_async(self, hash_key,item:object)->list: + ''' + 对话列表 + ''' + messages = await self.redis_service.get_hash(hash_key) + wxid=hash_key.split(':')[-1] + if not messages: + messages=[{"role": "system", "content": ""}] + messages.append(item) + await self.redis_service.set_hash(hash_key, {"data": json.dumps(messages, ensure_ascii=False)}, 600) + else: + messages_str = await self.redis_service.get_hash_field(hash_key, "data") + messages = json.loads(messages_str) if messages_str else [] + #判断是否含有图片 + last_message = messages[-1] + content = last_message.get("content", []) + if isinstance(content, list) and content: + last_content_type = content[-1].get("type") + if last_content_type == 'image_url': + content.append(item['content'][0]) + messages[-1]['content']=content + else: + messages.append(item) + else: + if last_message!= item: + messages.append(item) + await self.redis_service.set_hash(hash_key,{"data":json.dumps(messages,ensure_ascii=False)},600) + return messages + + async def get_contacts_brief_from_cache_async(self, wxid)->list: + """ + 获取联系人信息保存到 Redis 缓存。 + """ + hash_key = f"__AI_OPS_WX__:CONTACTS_BRIEF:{wxid}" + cache_str = await self.redis_service.get_hash_field(hash_key, "data") + return json.loads(cache_str) if cache_str else [] + + async def save_contacts_brief_to_cache_async(self, token_id, app_id, wxid, contacts_wxids: list)->list: + """ + 将联系人信息保存到 Redis 缓存。 + """ + # Redis 缓存的 key + hash_key = f"__AI_OPS_WX__:CONTACTS_BRIEF:{wxid}" + + # 获取缓存中的数据 + cache_str = await self.redis_service.get_hash_field(hash_key, "data") + cache = json.loads(cache_str) if cache_str else [] + # 回调处理 + if len(contacts_wxids) == 1: + cache_wxids = [f['userName'] for f in cache] + friends_brief = await self.get_brief_info_async(token_id, app_id, contacts_wxids) + if contacts_wxids[0] in cache_wxids: + # 替换已经存在的数据 + for i in range(len(cache)): + if cache[i]['userName'] == contacts_wxids[0]: + cache[i] = friends_brief[0] + else: + cache.extend(f for f in friends_brief if f["nickName"]) + friends_no_brief_wxid = [f['userName'] for f in friends_brief if not f["nickName"]] + if friends_no_brief_wxid: + detailed_info = self.get_detail_info(token_id, app_id, friends_no_brief_wxid) + cache.extend(detailed_info) + + # 分批处理 + else: + cache=[] + # 缓存为空,分批处理 contacts_wxids + batch_size = 100 + for i in range(0, len(contacts_wxids), batch_size): + batch = contacts_wxids[i:i + batch_size] + friends_brief = await self.get_brief_info_async(token_id, app_id, batch) + + cache.extend(f for f in friends_brief if f["nickName"]) + + friends_no_brief_wxid = [f['userName'] for f in friends_brief if not f["nickName"]] + if friends_no_brief_wxid: + detailed_info = self.get_detail_info(token_id, app_id, friends_no_brief_wxid) + cache.extend(detailed_info) + + # 更新缓存 + + await self.redis_service.update_hash_field(hash_key, "data", json.dumps(cache, ensure_ascii=False)) + return cache + + async def delete_contacts_brief_from_cache_async(self, wxid, contacts_wxids: list): + """ + 删除联系人信息保存到 Redis 缓存。 + """ + hash_key = f"__AI_OPS_WX__:CONTACTS_BRIEF:{wxid}" + cache_str = await self.redis_service.get_hash_field(hash_key, "data") + cache = json.loads(cache_str) if cache_str else [] + + + # 将 contacts_wxids 转换为集合,提高查找效率 + wxids_set = set(contacts_wxids) + + # 过滤 cache:保留 userName 不在集合中的对象 + filtered_cache_async = [contact for contact in cache if contact["userName"] not in wxids_set] + + # # 如果需要原地修改原 cache 列表: + # cache[:] = filtered_cache_async + await self.redis_service.update_hash_field(hash_key, "data", json.dumps(filtered_cache_async, ensure_ascii=False)) + + async def save_groups_info_to_cache_async(self, token_id, app_id, wxid, chatroom_ids: list): + """ + 将群信息保存到 Redis 缓存。 + """ + # Redis 缓存的 key + hash_key = f"__AI_OPS_WX__:GROUPS_INFO:{wxid}" + + # 获取当前缓存中所有的 chatroom_id + existing_chatrooms = await self.redis_service.get_hash(hash_key) + + # 找出需要删除的 chatroom_ids + chatrooms_to_delete = set(existing_chatrooms.keys()) - set(chatroom_ids) + + # 删除缓存中不再需要的 chatroom_id 数据 + for chatroom_id in chatrooms_to_delete: + await self.redis_service.delete_hash_field(hash_key, chatroom_id) + for chatroom_id in chatroom_ids: + # 获取群信息 + ret, msg, data =await self.get_chatroom_info_async(token_id, app_id, chatroom_id) + if ret != 200: + continue + + # 更新缓存 + await self.redis_service.update_hash_field(hash_key, chatroom_id, json.dumps(data, ensure_ascii=False)) + await asyncio.sleep(0.1) + + async def save_groups_members_to_cache_async(self, token_id, app_id, wxid, chatroom_ids: list): + """ + 将群成员保存到 Redis 缓存。 + """ + # Redis 缓存的 key + hash_key = f"__AI_OPS_WX__:GROUPS_MEMBERS:{wxid}" + + # 获取当前缓存中所有的 chatroom_id + existing_chatrooms = await self.redis_service.get_hash(hash_key) + + # 找出需要删除的 chatroom_ids + chatrooms_to_delete = set(existing_chatrooms.keys()) - set(chatroom_ids) + + # 删除缓存中不再需要的 chatroom_id 数据 + for chatroom_id in chatrooms_to_delete: + await self.redis_service.delete_hash_field(hash_key, chatroom_id) + + for chatroom_id in chatroom_ids: + # 获取群信息 + ret, msg, data = await self.get_group_memberlist_async(token_id, app_id, chatroom_id) + if ret != 200: + continue + # 更新缓存 + await self.redis_service.update_hash_field(hash_key, chatroom_id, json.dumps(data, ensure_ascii=False)) + await asyncio.sleep(0.1) + + + async def update_group_members_to_cache_async(self, token_id, app_id, wxid, chatroom_id: str): + """ + 更新将群信息保存到 Redis 缓存。 + """ + # Redis 缓存的 key + hash_key = f"__AI_OPS_WX__:GROUPS_MEMBERS:{wxid}" + + # 获取群信息 + ret, msg, data = await self.get_group_memberlist_async(token_id, app_id, chatroom_id) + await self.redis_service.update_hash_field(hash_key, chatroom_id, json.dumps(data, ensure_ascii=False)) + + async def get_group_members_from_cache_async(self, wxid,chatroom_id)->dict: + """ + 获取缓存中群成员。 + """ + hash_key = f"__AI_OPS_WX__:GROUPS_MEMBERS:{wxid}" + cache = await self.redis_service.get_hash_field(hash_key,chatroom_id) + groups=json.loads(cache) if cache else {} + return groups + + async def update_group_info_to_cache_async(self, token_id, app_id, wxid, chatroom_id: str): + """ + 更新将群信息保存到 Redis 缓存。 + """ + # Redis 缓存的 key + hash_key = f"__AI_OPS_WX__:GROUPS_INFO:{wxid}" + + # 获取群信息 + ret, msg, data =await self.get_chatroom_info_async(token_id, app_id, chatroom_id) + await self.redis_service.update_hash_field(hash_key, chatroom_id, json.dumps(data, ensure_ascii=False)) + + async def get_groups_info_from_cache_async(self, wxid)->list: + """ + 获取群信息保存到 Redis 缓存。 + """ + hash_key = f"__AI_OPS_WX__:GROUPS_INFO:{wxid}" + cache = await self.redis_service.get_hash(hash_key) + groups=[json.loads(v) for v in cache.values()] + return groups + + async def get_group_info_from_cache_async(self, wxid,chatroom_id)->dict: + """ + 获取群信息保存到 Redis 缓存。 + """ + hash_key = f"__AI_OPS_WX__:GROUPS_INFO:{wxid}" + cache = await self.redis_service.get_hash_field(hash_key,chatroom_id) + groups=json.loads(cache) if cache else {} + return groups + + async def get_wxchat_config_from_cache_async(self, wxid): + """ + 获取配置信息 + """ + hash_key = f"__AI_OPS_WX__:WXCHAT_CONFIG" + config = await self.redis_service.get_hash_field(hash_key, wxid) + return json.loads(config) if config else {} + + async def save_wxchat_config_async(self, wxid, config:dict): + """ + 保存配置信息 + """ + hash_key = f"__AI_OPS_WX__:WXCHAT_CONFIG" + await self.redis_service.update_hash_field(hash_key, wxid, json.dumps(config, ensure_ascii=False)) + + async def get_login_info_from_cache_async(self,tel): + hash_key = f"__AI_OPS_WX__:LOGININFO:{tel}" + cache = await self.redis_service.get_hash(hash_key) + return cache + + async def get_login_info_by_wxid_async(self,wxid:str)->tuple: + cursor = 0 + while True: + cursor, login_keys =await self.redis_service.client.scan(cursor, match='__AI_OPS_WX__:LOGININFO:*') + # 批量获取所有键的 hash 数据 + for k in login_keys: + r = await self.redis_service.get_hash(k) + if r.get("wxid") == wxid: + return k,r + + # 如果游标为 0,则表示扫描完成 + if cursor == 0: + break + + return None,None + + async def get_login_info_by_app_id_async(self,app_id:str)->tuple: + cursor = 0 + while True: + cursor, login_keys =await self.redis_service.client.scan(cursor, match='__AI_OPS_WX__:LOGININFO:*') + print(f'login_keys:{login_keys}') + # 批量获取所有键的 hash 数据 + for k in login_keys: + r = await self.redis_service.get_hash(k) + print(r) + if r.get("appId") == app_id: + return k,r + + # 如果游标为 0,则表示扫描完成 + if cursor == 0: + break + + return None,None + + async def get_token_id_by_app_id_async(self,app_id: str) -> str: + # 使用 SCAN 避免一次性返回所有的匹配键,逐步扫描 + cursor = 0 + while True: + cursor, login_keys =await self.redis_service.client.scan(cursor, match='__AI_OPS_WX__:LOGININFO:*') + + # 批量获取所有键的 hash 数据 + for k in login_keys: + r = await self.redis_service.get_hash(k) + if r.get("appId") == app_id: + return r.get("tokenId", "") + + # 如果游标为 0,则表示扫描完成 + if cursor == 0: + break + + return "" + + + async def save_login_wx_captch_code_to_cache_async(self,token_id,captch_code): + hash_key = f"__AI_OPS_WX__:WXCAPTCHCODE:{token_id}" + await self.redis_service.set_hash(hash_key,{"data":captch_code},30) + + async def get_login_wx_captch_code_from_cache_async(self,token_id)->str: + hash_key = f"__AI_OPS_WX__:WXCAPTCHCODE:{token_id}" + r=await self.redis_service.get_hash_field(hash_key,"data") + return r + + async def acquire_login_lock_async(self, token_id, expire_time=10): + hash_key = f"__AI_OPS_WX__:LOGINLOCK:{token_id}" + identifier=str(uuid.uuid4()) + if await self.redis_service.client.setnx(hash_key, identifier): + await self.redis_service.client.expire(hash_key, expire_time) + return True + return False + + async def release_login_lock_async(self, token_id): + hash_key = f"__AI_OPS_WX__:LOGINLOCK:{token_id}" + await self.redis_service.client.delete(hash_key) + + async def save_group_add_contacts_history_async(self,wxid,chatroom_id,contact_wxid,history:AddGroupContactsHistory): + ''' + 保存群加好友历史 + ''' + hash_key = f"__AI_OPS_WX__:GROUPS_ADD_CONTACT_HISTORY:{wxid}:{chatroom_id}" + data_str=await self.redis_service.get_hash_field(hash_key,contact_wxid) + data=json.loads(data_str) if data_str else [] + data.append(history.model_dump()) + await self.redis_service.update_hash_field(hash_key, contact_wxid, json.dumps(data, ensure_ascii=False)) + + async def get_group_add_contacts_history_async(self,wxid,chatroom_id,contact_wxid)->list[AddGroupContactsHistory]: + ''' + 获取群加好友历史 + ''' + hash_key = f"__AI_OPS_WX__:GROUPS_ADD_CONTACT_HISTORY:{wxid}:{chatroom_id}" + data_str=await self.redis_service.get_hash_field(hash_key,contact_wxid) + data=json.loads(data_str) if data_str else [] + #TypeAdapter.validate_python(List[Models.AddGroupContactsHistory], data) + return [AddGroupContactsHistory.model_validate(item) for item in data] + + async def check_wixd_group_add_contacts_history_async(self,wxid,chatroom_id): + ''' + 返回群发送好友达到2次的wxid + ''' + hash_key = f"__AI_OPS_WX__:GROUPS_ADD_CONTACT_HISTORY:{wxid}:{chatroom_id}" + cache = await self.redis_service.get_hash(hash_key) + wxids = [key for key, value in cache.items() if len(json.loads(value)) == 2] + return wxids + + async def enqueue_to_add_contacts_async(self,wxid,scene:int,v3,v4): + """ + 入列待添加好友 + """ + hash_key = f'__AI_OPS_WX__:TO_ADD_CONTACTS:{wxid}' + data_str=json.dumps({"wxid":wxid,"scene":scene,"v3":v3,"v4":v4},ensure_ascii=False) + await self.redis_service.enqueue(hash_key,data_str) + + async def dequeue_to_add_contacts_async(self,wxid)->dict: + """ + 出列待添加好友 + """ + hash_key = f'__AI_OPS_WX__:TO_ADD_CONTACTS:{wxid}' + data=await self.redis_service.dequeue(hash_key) + return json.loads(data) if data else {} + + + + +# 依赖项:获取 GeWeChatCom 单例 +async def get_gewe_service(app: FastAPI = Depends()) -> GeWeService: + return app.state.gewe_service \ No newline at end of file diff --git a/services/kafka_service.py b/services/kafka_service.py new file mode 100644 index 0000000..7846db8 --- /dev/null +++ b/services/kafka_service.py @@ -0,0 +1,166 @@ +import asyncio +from typing import Dict, Callable, Optional +from aiokafka import AIOKafkaProducer, AIOKafkaConsumer +from aiokafka.errors import KafkaError +from fastapi import FastAPI +import json +from common.log import logger + +class KafkaService: + _instance = None + + def __new__(cls, *args, **kwargs): + if not cls._instance: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + bootstrap_servers: str = "localhost:9092", + producer_topic: str = "default_topic", + consumer_topic: str = "default_topic", + group_id: str = "fastapi-group" + ): + if not hasattr(self, 'initialized'): + self.bootstrap_servers = bootstrap_servers + self.producer_topic = producer_topic + self.consumer_topic = consumer_topic + self.group_id = group_id + + self.producer: Optional[AIOKafkaProducer] = None + self.consumer: Optional[AIOKafkaConsumer] = None + self.consumer_task: Optional[asyncio.Task] = None + self.message_handlers: Dict[str, Callable] = {} + + self.initialized = True + + async def connect_producer(self): + """Initialize Kafka producer""" + try: + self.producer = AIOKafkaProducer( + bootstrap_servers=self.bootstrap_servers, + compression_type="gzip" + ) + await self.producer.start() + except KafkaError as e: + print(f"Producer connection failed: {e}") + raise + + async def connect_consumer(self): + """Initialize Kafka consumer""" + try: + self.consumer = AIOKafkaConsumer( + self.consumer_topic, + bootstrap_servers=self.bootstrap_servers, + group_id=self.group_id, + auto_offset_reset="earliest", + session_timeout_ms=30000, # 增加会话超时时间 + heartbeat_interval_ms=10000 # 增加心跳间隔时间 + ) + await self.consumer.start() + except KafkaError as e: + print(f"Consumer connection failed: {e}") + raise + + async def send_message_async(self, message: str, topic: str = None): + """Send message to Kafka topic""" + if not self.producer: + raise RuntimeError("Producer not initialized") + + target_topic = topic or self.producer_topic + print(f'生产者topic:{target_topic}') + logger.info(f"生产者topic:{target_topic}\n生产者消息:{json.dumps(json.loads(message), separators=(',', ':'), default=str, ensure_ascii=False)}") + try: + await self.producer.send_and_wait( + target_topic, + message.encode('utf-8') + ) + except KafkaError as e: + print(f"Error sending message: {e}") + raise + + # async def consume_messages(self): + # """Start consuming messages from Kafka""" + # if not self.consumer: + # raise RuntimeError("Consumer not initialized") + + # try: + # async for msg in self.consumer: + # #print(f"Received message: {msg.value.decode()}") + # logger.info(f"接收到kafka消息: {json.dumps(json.loads(msg.value.decode()), ensure_ascii=False)}") + # topic = msg.topic + # if topic in self.message_handlers: + # handler = self.message_handlers[topic] + # await handler(msg.value.decode()) + # except Exception as e: + # print(f"Consuming error: {e}") + # raise + # finally: + # await self.consumer.stop() + + + # async def consume_messages(self): + # """Start consuming messages from Kafka""" + # if not self.consumer: + # raise RuntimeError("Consumer not initialized") + # try: + # async for msg in self.consumer: + # try: + # logger.info(f"接收到kafka消息: {json.dumps(json.loads(msg.value.decode()), ensure_ascii=False)}") + # topic = msg.topic + # if topic in self.message_handlers: + # handler = self.message_handlers[topic] + # await handler(msg.value.decode()) + # else: + # logger.warning(f"未处理消息类型: {topic}") + # except Exception as e: + # logger.error(f"处理消息失败: {e}") + # except Exception as e: + # logger.error(f"消费消息异常: {e}") + # raise + # finally: + # await self.consumer.stop() + + async def consume_messages(self): + """Start consuming messages from Kafka""" + if not self.consumer: + raise RuntimeError("Consumer not initialized") + while True: + try: + async for msg in self.consumer: + try: + logger.info(f"接收到kafka消息: {json.dumps(json.loads(msg.value.decode()), ensure_ascii=False)}") + topic = msg.topic + if topic in self.message_handlers: + handler = self.message_handlers[topic] + await handler(msg.value.decode()) + else: + logger.warning(f"未处理消息类型: {topic}") + except Exception as e: + logger.error(f"处理消息失败: {e}") + except Exception as e: + logger.error(f"消费消息异常: {e}") + await asyncio.sleep(5) # 等待一段时间后重试 + + def add_handler(self, topic: str, handler: Callable): + """Add message handler for specific topic""" + self.message_handlers[topic] = handler + + async def start(self): + """Start both producer and consumer""" + await self.connect_producer() + await self.connect_consumer() + self.consumer_task = asyncio.create_task(self.consume_messages()) + + async def stop(self): + """Graceful shutdown""" + if self.producer: + await self.producer.stop() + if self.consumer: + await self.consumer.stop() + if self.consumer_task: + self.consumer_task.cancel() + try: + await self.consumer_task + except asyncio.CancelledError: + pass \ No newline at end of file diff --git a/services/redis_service.py b/services/redis_service.py new file mode 100644 index 0000000..8d7a9fd --- /dev/null +++ b/services/redis_service.py @@ -0,0 +1,198 @@ +import aioredis +import os +import uuid +import asyncio +import threading +from fastapi import FastAPI, Depends +from fastapi import Request +import time + +# 定义全局 redis_helper 作为单例 +class RedisService: + _instance = None + def __new__(cls, host='localhost', port=6379, password=None, db=0): + if not cls._instance: + cls._instance = super(RedisService, cls).__new__(cls) + cls._instance.client = None + cls._instance.lock_renewal_thread = None + return cls._instance + + async def init(self, host='localhost', port=6379, password=None, db=0): + """初始化 Redis 连接""" + #self.client = await aioredis.Redis(f'redis://{host}:{port}', password=password, db=db) + self.client = await aioredis.Redis(host=host, port=port, password=password, db=db) + + + # async def set_hash(self, hash_key, data, timeout=None): + # """添加或更新哈希,并设置有效期""" + # await self.client.hmset_dict(hash_key, data) + # if timeout: + # # 设置有效期(单位:秒) + # await self.client.expire(hash_key, timeout) + + async def set_hash(self, hash_key, data, timeout=None): + """添加或更新哈希,并设置有效期""" + # 使用 hmset 方法设置哈希表数据 + await self.client.hmset(hash_key, data) + if timeout: + # 设置有效期(单位:秒) + await self.client.expire(hash_key, timeout) + + async def get_hash(self, hash_key): + """获取整个哈希表数据""" + result = await self.client.hgetall(hash_key) + # 将字节数据解码成字符串格式返回 + return {k.decode('utf-8'): v.decode('utf-8') for k, v in result.items()} + + async def get_hash_field(self, hash_key, field): + """获取哈希表中的单个字段值""" + result = await self.client.hget(hash_key, field) + return result.decode('utf-8') if result else None + + async def delete_hash(self, hash_key): + """删除整个哈希表""" + await self.client.delete(hash_key) + + async def delete_hash_field(self, hash_key, field): + """删除哈希表中的某个字段""" + await self.client.hdel(hash_key, field) + + async def update_hash_field(self, hash_key, field, value): + """更新哈希表中的某个字段""" + await self.client.hset(hash_key, field, value) + + # async def acquire_lock(self, lock_name, timeout=60): + # """ + # 尝试获取分布式锁,成功返回 True,失败返回 False + # :param lock_name: 锁的名称 + # :param timeout: 锁的超时时间(秒) + # :return: bool + # """ + # # identifier = str(time.time()) # 使用时间戳作为唯一标识 + # # if await self.client.setnx(lock_name, identifier): + # # await self.client.expire(lock_name, timeout) + # # self.lock_renewal_thread = threading.Thread(target=self.renew_lock, args=(lock_name, identifier, timeout)) + # # self.lock_renewal_thread.start() + # # return True + # # return False + + # identifier = str(time.time()) # 使用 UUID 作为唯一标识 + # if await self.client.setnx(lock_name, identifier): + # await self.client.expire(lock_name, timeout) + # self.lock_renewal_thread = threading.Thread( + # target=self.run_renew_lock, + # args=(lock_name, identifier, timeout) + # ) + # self.lock_renewal_thread.start() + # return True + # return False + + + # def run_renew_lock(self, lock_name, identifier, timeout): + # """ + # 在单独的线程中运行异步的 renew_lock 方法 + # """ + # loop = asyncio.new_event_loop() + # asyncio.set_event_loop(loop) + # loop.run_until_complete(self.renew_lock(lock_name, identifier, timeout)) + # loop.close() + + + async def acquire_lock(self, lock_name, timeout=60): + """ + 尝试获取分布式锁,成功返回 True,失败返回 False + :param lock_name: 锁的名称 + :param timeout: 锁的超时时间(秒) + :return: bool + """ + identifier = str(uuid.uuid4()) # 使用 UUID 作为唯一标识 + if await self.client.setnx(lock_name, identifier): + await self.client.expire(lock_name, timeout) + # 启动异步任务来续期锁 + self.lock_renewal_task = asyncio.create_task( + self.renew_lock(lock_name, identifier, timeout) + ) + return True + return False + + async def renew_lock(self, lock_name, identifier, timeout): + """ + 锁的自动续期 + :param lock_name: 锁的名称 + :param identifier: 锁的唯一标识 + :param timeout: 锁的超时时间(秒) + """ + while True: + await asyncio.sleep(timeout / 2) + if await self.client.get(lock_name) == identifier.encode(): + await self.client.expire(lock_name, timeout) + else: + break + + async def release_lock(self, lock_name, identifier): + """ + 释放分布式锁 + :param lock_name: 锁的名称 + :param identifier: 锁的唯一标识 + """ + if await self.client.get(lock_name) == identifier.encode(): + await self.client.delete(lock_name) + if self.lock_renewal_thread: + self.lock_renewal_thread.join() + + async def enqueue(self, queue_name, item): + """ + 将元素添加到队列的尾部(右侧) + :param queue_name: 队列名称 + :param item: 要添加到队列的元素 + """ + await self.client.rpush(queue_name, item) + print(f"Enqueued: {item} to queue {queue_name}") + + async def dequeue(self, queue_name): + """ + 从队列的头部(左侧)移除并返回元素 + :param queue_name: 队列名称 + :return: 移除的元素,如果队列为空则返回 None + """ + item = await self.client.lpop(queue_name) + if item: + print(f"Dequeued: {item.decode('utf-8')} from queue {queue_name}") + return item.decode('utf-8') + print(f"Queue {queue_name} is empty") + return None + + async def get_queue_length(self, queue_name): + """ + 获取队列的长度 + :param queue_name: 队列名称 + :return: 队列的长度 + """ + length = await self.client.llen(queue_name) + print(f"Queue {queue_name} length: {length}") + return length + + async def peek_queue(self, queue_name): + """ + 查看队列的头部元素,但不移除 + :param queue_name: 队列名称 + :return: 队列的头部元素,如果队列为空则返回 None + """ + item = await self.client.lrange(queue_name, 0, 0) + if item: + print(f"Peeked: {item[0].decode('utf-8')} from queue {queue_name}") + return item[0].decode('utf-8') + print(f"Queue {queue_name} is empty") + return None + + async def clear_queue(self, queue_name): + """ + 清空队列 + :param queue_name: 队列名称 + """ + await self.client.delete(queue_name) + print(f"Cleared queue {queue_name}") + +# Dependency injection helper function +async def get_redis_service(request: Request) -> RedisService: + return request.app.state.redis_serive \ No newline at end of file diff --git a/setup_production.sh b/setup_production.sh new file mode 100644 index 0000000..f7bb3ae --- /dev/null +++ b/setup_production.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +image_version=$version +# 删除镜像 +docker rmi -f $( + docker images | grep registry.cn-shanghai.aliyuncs.com/gps_card/ai-ops-wechat | awk '{print $3}' +) +# 构建telpo/mrp:$image_version镜像 +docker build -f ./docker/Dockerfile.latest . -t ssjl/ai-ops-wechat:$image_version + +#TODO:推送镜像到阿里仓库 +echo '=================开始推送镜像=======================' +#docker login --username=telpo_linwl@1111649216405698 --password=telpo#1234 registry.cn-shanghai.aliyuncs.com +docker login --username=rzl_wangjx@1111649216405698 --password=telpo.123 registry.cn-shanghai.aliyuncs.com +docker tag ssjl/ai-ops-wechat:$image_version registry.cn-shanghai.aliyuncs.com/gps_card/ai-ops-wechat:$image_version +docker push registry.cn-shanghai.aliyuncs.com/gps_card/ai-ops-wechat:$image_version +echo '=================推送镜像完成=======================' +#删除产生的None镜像 +docker rmi -f $(docker images | grep none | awk '{print $3}') +# 查看镜像列表 +docker images diff --git a/setup_test.sh b/setup_test.sh new file mode 100644 index 0000000..8c41c91 --- /dev/null +++ b/setup_test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +image_version=$version +# 删除镜像 +docker rmi -f $( + docker images | grep 139.224.254.18:5000/ssjl/ai-ops-wechat | awk '{print $3}' +) +# 构建telpo/mrp:$image_version镜像 +docker build -f ./docker/Dockerfile.latest . -t ssjl/ai-ops-wechat:$image_version +#TODO:推送镜像到私有仓库 +echo '=================开始推送镜像=======================' +docker tag ssjl/ai-ops-wechat:$image_version 139.224.254.18:5000/ssjl/ai-ops-wechat:$image_version +docker push 139.224.254.18:5000/ssjl/ai-ops-wechat:$image_version +echo '=================推送镜像完成=======================' +#删除产生的None镜像 +docker rmi -f $(docker images | grep none | awk '{print $3}') +# 查看镜像列表 +docker images \ No newline at end of file diff --git a/voice/ali/ali_api.py b/voice/ali/ali_api.py new file mode 100644 index 0000000..def5c7a --- /dev/null +++ b/voice/ali/ali_api.py @@ -0,0 +1,216 @@ +# coding=utf-8 +""" +Author: chazzjimel +Email: chazzjimel@gmail.com +wechat:cheung-z-x + +Description: + +""" + +import http.client +import json +import time +import requests +import datetime +import hashlib +import hmac +import base64 +import urllib.parse +import uuid + +from common.log import logger +from common.tmp_dir import TmpDir + + +def text_to_speech_aliyun(url, text, appkey, token): + """ + 使用阿里云的文本转语音服务将文本转换为语音。 + + 参数: + - url (str): 阿里云文本转语音服务的端点URL。 + - text (str): 要转换为语音的文本。 + - appkey (str): 您的阿里云appkey。 + - token (str): 阿里云API的认证令牌。 + + 返回值: + - str: 成功时输出音频文件的路径,否则为None。 + """ + headers = { + "Content-Type": "application/json", + } + + data = { + "text": text, + "appkey": appkey, + "token": token, + "format": "wav" + } + + response = requests.post(url, headers=headers, data=json.dumps(data)) + + if response.status_code == 200 and response.headers['Content-Type'] == 'audio/mpeg': + output_file = TmpDir().path() + "reply-" + str(int(time.time())) + "-" + str(hash(text) & 0x7FFFFFFF) + ".wav" + + with open(output_file, 'wb') as file: + file.write(response.content) + logger.debug(f"音频文件保存成功,文件名:{output_file}") + else: + logger.debug("响应状态码: {}".format(response.status_code)) + logger.debug("响应内容: {}".format(response.text)) + output_file = None + + return output_file + +def speech_to_text_aliyun(url, audioContent, appkey, token): + """ + 使用阿里云的语音识别服务识别音频文件中的语音。 + + 参数: + - url (str): 阿里云语音识别服务的端点URL。 + - audioContent (byte): pcm音频数据。 + - appkey (str): 您的阿里云appkey。 + - token (str): 阿里云API的认证令牌。 + + 返回值: + - str: 成功时输出识别到的文本,否则为None。 + """ + format = 'pcm' + sample_rate = 16000 + enablePunctuationPrediction = True + enableInverseTextNormalization = True + enableVoiceDetection = False + + # 设置RESTful请求参数 + request = url + '?appkey=' + appkey + request = request + '&format=' + format + request = request + '&sample_rate=' + str(sample_rate) + + if enablePunctuationPrediction : + request = request + '&enable_punctuation_prediction=' + 'true' + + if enableInverseTextNormalization : + request = request + '&enable_inverse_text_normalization=' + 'true' + + if enableVoiceDetection : + request = request + '&enable_voice_detection=' + 'true' + + host = 'nls-gateway-cn-shanghai.aliyuncs.com' + + # 设置HTTPS请求头部 + httpHeaders = { + 'X-NLS-Token': token, + 'Content-type': 'application/octet-stream', + 'Content-Length': len(audioContent) + } + + conn = http.client.HTTPSConnection(host) + conn.request(method='POST', url=request, body=audioContent, headers=httpHeaders) + + response = conn.getresponse() + body = response.read() + try: + body = json.loads(body) + status = body['status'] + if status == 20000000 : + result = body['result'] + if result : + logger.info(f"阿里云语音识别到了:{result}") + conn.close() + return result + else : + logger.error(f"语音识别失败,状态码: {status}") + except ValueError: + logger.error(f"语音识别失败,收到非JSON格式的数据: {body}") + conn.close() + return None + + +class AliyunTokenGenerator: + """ + 用于生成阿里云服务认证令牌的类。 + + 属性: + - access_key_id (str): 您的阿里云访问密钥ID。 + - access_key_secret (str): 您的阿里云访问密钥秘密。 + """ + + def __init__(self, access_key_id, access_key_secret): + self.access_key_id = access_key_id + self.access_key_secret = access_key_secret + + def sign_request(self, parameters): + """ + 为阿里云服务签名请求。 + + 参数: + - parameters (dict): 请求的参数字典。 + + 返回值: + - str: 请求的签名签章。 + """ + # 将参数按照字典顺序排序 + sorted_params = sorted(parameters.items()) + + # 构造待签名的查询字符串 + canonicalized_query_string = '' + for (k, v) in sorted_params: + canonicalized_query_string += '&' + self.percent_encode(k) + '=' + self.percent_encode(v) + + # 构造用于签名的字符串 + string_to_sign = 'GET&%2F&' + self.percent_encode(canonicalized_query_string[1:]) # 使用GET方法 + + # 使用HMAC算法计算签名 + h = hmac.new((self.access_key_secret + "&").encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1) + signature = base64.encodebytes(h.digest()).strip() + + return signature + + def percent_encode(self, encode_str): + """ + 对字符串进行百分比编码。 + + 参数: + - encode_str (str): 要编码的字符串。 + + 返回值: + - str: 编码后的字符串。 + """ + encode_str = str(encode_str) + res = urllib.parse.quote(encode_str, '') + res = res.replace('+', '%20') + res = res.replace('*', '%2A') + res = res.replace('%7E', '~') + return res + + def get_token(self): + """ + 获取阿里云服务的令牌。 + + 返回值: + - str: 获取到的令牌。 + """ + # 设置请求参数 + params = { + 'Format': 'JSON', + 'Version': '2019-02-28', + 'AccessKeyId': self.access_key_id, + 'SignatureMethod': 'HMAC-SHA1', + 'Timestamp': datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + 'SignatureVersion': '1.0', + 'SignatureNonce': str(uuid.uuid4()), # 使用uuid生成唯一的随机数 + 'Action': 'CreateToken', + 'RegionId': 'cn-shanghai' + } + + # 计算签名 + signature = self.sign_request(params) + params['Signature'] = signature + + # 构造请求URL + url = 'http://nls-meta.cn-shanghai.aliyuncs.com/?' + urllib.parse.urlencode(params) + + # 发送请求 + response = requests.get(url) + + return response.text diff --git a/voice/ali/ali_voice.py b/voice/ali/ali_voice.py new file mode 100644 index 0000000..9ea8050 --- /dev/null +++ b/voice/ali/ali_voice.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +""" +Author: chazzjimel +Email: chazzjimel@gmail.com +wechat:cheung-z-x + +Description: +ali voice service + +""" +import json +import os +import re +import time + + +from common.log import logger +from voice.audio_convert import get_pcm_from_wav +from voice.voice import Voice +from voice.ali.ali_api import AliyunTokenGenerator, speech_to_text_aliyun, text_to_speech_aliyun +from config import conf + +from pypinyin import pinyin, Style +from common.singleton import singleton + +@singleton +class AliVoice(Voice): + def __init__(self): + """ + 初始化AliVoice类,从配置文件加载必要的配置。 + """ + try: + curdir = os.path.dirname(__file__) + config_path = os.path.join(curdir, "config.json") + print(config_path) + with open(config_path, "r") as fr: + config = json.load(fr) + self.token = None + self.token_expire_time = 0 + # 默认复用阿里云千问的 access_key 和 access_secret + self.api_url_voice_to_text = config.get("api_url_voice_to_text") + self.api_url_text_to_voice = config.get("api_url_text_to_voice") + self.app_key = config.get("app_key") + self.access_key_id = conf().get("qwen_access_key_id") or config.get("access_key_id") + self.access_key_secret = conf().get("qwen_access_key_secret") or config.get("access_key_secret") + except Exception as e: + logger.warn("AliVoice init failed: %s, ignore " % e) + + def textToVoice(self, text): + """ + 将文本转换为语音文件。 + + :param text: 要转换的文本。 + :return: 返回一个Reply对象,其中包含转换得到的语音文件或错误信息。 + """ + # 清除文本中的非中文、非英文和非基本字符 + text = re.sub(r'[^\u4e00-\u9fa5\u3040-\u30FF\uAC00-\uD7AFa-zA-Z0-9' + r'äöüÄÖÜáéíóúÁÉÍÓÚàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛçÇñÑ,。!?,.]', '', text) + # 提取有效的token + token_id = self.get_valid_token() + fileName = text_to_speech_aliyun(self.api_url_text_to_voice, text, self.app_key, token_id) + + if fileName: + logger.info("[Ali] textToVoice text={} voice file name={}".format(text, fileName)) + return fileName + # reply = Reply(ReplyType.VOICE, fileName) + # else: + # reply = Reply(ReplyType.ERROR, "抱歉,语音合成失败") + # return reply + + def voiceToText(self, voice_file): + """ + 将语音文件转换为文本。 + + :param voice_file: 要转换的语音文件。 + :return: 返回一个Reply对象,其中包含转换得到的文本或错误信息。 + """ + # 提取有效的token + token_id = self.get_valid_token() + logger.debug("[Ali] voice file name={}".format(voice_file)) + pcm = get_pcm_from_wav(voice_file) + text = speech_to_text_aliyun(self.api_url_voice_to_text, pcm, self.app_key, token_id) + # print(text) + + if text: + return text + # reply = Reply(ReplyType.TEXT, text) + # else: + # reply = Reply(ReplyType.ERROR, "抱歉,语音识别失败") + # return reply + + def get_valid_token(self): + """ + 获取有效的阿里云token。 + + :return: 返回有效的token字符串。 + """ + current_time = time.time() + if self.token is None or current_time >= self.token_expire_time: + get_token = AliyunTokenGenerator(self.access_key_id, self.access_key_secret) + token_str = get_token.get_token() + token_data = json.loads(token_str) + self.token = token_data["Token"]["Id"] + # 将过期时间减少一小段时间(例如5分钟),以避免在边界条件下的过期 + self.token_expire_time = token_data["Token"]["ExpireTime"] - 300 + logger.debug(f"新获取的阿里云token:{self.token}") + else: + logger.debug("使用缓存的token") + return self.token diff --git a/voice/ali/config.json b/voice/ali/config.json new file mode 100644 index 0000000..71cedd3 --- /dev/null +++ b/voice/ali/config.json @@ -0,0 +1,7 @@ +{ + "api_url_text_to_voice": "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/tts", + "api_url_voice_to_text": "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/asr", + "app_key": "F3VB6magxpjpPgKH", + "access_key_id": "LTAI5tJS8kD1mh2fLzVJ4u3w", + "access_key_secret": "ahiLuHLiSeqBDMCgtmc9Qe3uvgo6pJ" +} \ No newline at end of file diff --git a/voice/ali/config.json.template b/voice/ali/config.json.template new file mode 100644 index 0000000..563c57f --- /dev/null +++ b/voice/ali/config.json.template @@ -0,0 +1,7 @@ +{ + "api_url_text_to_voice": "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/tts", + "api_url_voice_to_text": "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/asr", + "app_key": "", + "access_key_id": "", + "access_key_secret": "" +} \ No newline at end of file diff --git a/voice/audio_convert.py b/voice/audio_convert.py new file mode 100644 index 0000000..fb5c2e8 --- /dev/null +++ b/voice/audio_convert.py @@ -0,0 +1,136 @@ +import shutil +import wave + +from common.log import logger + + +try: + import pysilk +except ImportError: + logger.debug("import pysilk failed, wechaty voice message will not be supported.") + +from pydub import AudioSegment + +sil_supports = [8000, 12000, 16000, 24000, 32000, 44100, 48000] # slk转wav时,支持的采样率 + + +def find_closest_sil_supports(sample_rate): + """ + 找到最接近的支持的采样率 + """ + if sample_rate in sil_supports: + return sample_rate + closest = 0 + mindiff = 9999999 + for rate in sil_supports: + diff = abs(rate - sample_rate) + if diff < mindiff: + closest = rate + mindiff = diff + return closest + + +def get_pcm_from_wav(wav_path): + """ + 从 wav 文件中读取 pcm + + :param wav_path: wav 文件路径 + :returns: pcm 数据 + """ + wav = wave.open(wav_path, "rb") + return wav.readframes(wav.getnframes()) + + +def any_to_mp3(any_path, mp3_path): + """ + 把任意格式转成mp3文件 + """ + if any_path.endswith(".mp3"): + shutil.copy2(any_path, mp3_path) + return + if any_path.endswith(".sil") or any_path.endswith(".silk") or any_path.endswith(".slk"): + sil_to_wav(any_path, any_path) + any_path = mp3_path + audio = AudioSegment.from_file(any_path) + audio.export(mp3_path, format="mp3") + + +def any_to_wav(any_path, wav_path): + """ + 把任意格式转成wav文件 + """ + if any_path.endswith(".wav"): + shutil.copy2(any_path, wav_path) + return + if any_path.endswith(".sil") or any_path.endswith(".silk") or any_path.endswith(".slk"): + return sil_to_wav(any_path, wav_path) + audio = AudioSegment.from_file(any_path) + audio.set_frame_rate(8000) # 百度语音转写支持8000采样率, pcm_s16le, 单通道语音识别 + audio.set_channels(1) + audio.export(wav_path, format="wav", codec='pcm_s16le') + + +def any_to_sil(any_path, sil_path): + """ + 把任意格式转成sil文件 + """ + if any_path.endswith(".sil") or any_path.endswith(".silk") or any_path.endswith(".slk"): + shutil.copy2(any_path, sil_path) + return 10000 + audio = AudioSegment.from_file(any_path) + rate = find_closest_sil_supports(audio.frame_rate) + # Convert to PCM_s16 + pcm_s16 = audio.set_sample_width(2) + pcm_s16 = pcm_s16.set_frame_rate(rate) + wav_data = pcm_s16.raw_data + silk_data = pysilk.encode(wav_data, data_rate=rate, sample_rate=rate) + with open(sil_path, "wb") as f: + f.write(silk_data) + return audio.duration_seconds * 1000 + + +def any_to_amr(any_path, amr_path): + """ + 把任意格式转成amr文件 + """ + if any_path.endswith(".amr"): + shutil.copy2(any_path, amr_path) + return + if any_path.endswith(".sil") or any_path.endswith(".silk") or any_path.endswith(".slk"): + raise NotImplementedError("Not support file type: {}".format(any_path)) + audio = AudioSegment.from_file(any_path) + audio = audio.set_frame_rate(8000) # only support 8000 + audio.export(amr_path, format="amr") + return audio.duration_seconds * 1000 + + +def sil_to_wav(silk_path, wav_path, rate: int = 24000): + """ + silk 文件转 wav + """ + wav_data = pysilk.decode_file(silk_path, to_wav=True, sample_rate=rate) + with open(wav_path, "wb") as f: + f.write(wav_data) + + +def split_audio(file_path, max_segment_length_ms=60000): + """ + 分割音频文件 + """ + audio = AudioSegment.from_file(file_path) + audio_length_ms = len(audio) + if audio_length_ms <= max_segment_length_ms: + return audio_length_ms, [file_path] + segments = [] + for start_ms in range(0, audio_length_ms, max_segment_length_ms): + end_ms = min(audio_length_ms, start_ms + max_segment_length_ms) + segment = audio[start_ms:end_ms] + segments.append(segment) + file_prefix = file_path[: file_path.rindex(".")] + format = file_path[file_path.rindex(".") + 1 :] + files = [] + for i, segment in enumerate(segments): + path = f"{file_prefix}_{i+1}" + f".{format}" + segment.export(path, format=format) + files.append(path) + return audio_length_ms, files diff --git a/voice/voice.py b/voice/voice.py new file mode 100644 index 0000000..1ca199b --- /dev/null +++ b/voice/voice.py @@ -0,0 +1,17 @@ +""" +Voice service abstract class +""" + + +class Voice(object): + def voiceToText(self, voice_file): + """ + Send voice to voice service and get text + """ + raise NotImplementedError + + def textToVoice(self, text): + """ + Send text to voice service and get voice + """ + raise NotImplementedError