Browse Source

初始化

master
H Vs 1 month ago
commit
ae6491c81a
45 changed files with 5465 additions and 0 deletions
  1. +39
    -0
      .gitignore
  2. +29
    -0
      ai-ops-wx_run.sh
  3. +0
    -0
      app/__init__.py
  4. +54
    -0
      app/celery_app.py
  5. +0
    -0
      app/endpoints/__init__.py
  6. +200
    -0
      app/endpoints/agent_endpoint.py
  7. +53
    -0
      app/endpoints/config_endpoint.py
  8. +72
    -0
      app/endpoints/contacts_endpoint.py
  9. +48
    -0
      app/endpoints/groups_endpoint.py
  10. +905
    -0
      app/endpoints/pipeline_endpoint.py
  11. +126
    -0
      app/endpoints/sns_endpoint.py
  12. +233
    -0
      app/main.py
  13. +180
    -0
      app/middleware.py
  14. +28
    -0
      app/tasks.py
  15. +0
    -0
      common/__init__.py
  16. +43
    -0
      common/expired_dict.py
  17. +176
    -0
      common/log.py
  18. +5
    -0
      common/memory.py
  19. +9
    -0
      common/singleton.py
  20. +17
    -0
      common/tmp_dir.py
  21. +409
    -0
      common/utils.py
  22. +9
    -0
      config-dev.json
  23. +9
    -0
      config-production.json
  24. +37
    -0
      config-template.json
  25. +9
    -0
      config-test.json
  26. +9
    -0
      config.json
  27. +179
    -0
      config.py
  28. +42
    -0
      docker/Dockerfile.latest
  29. +8
    -0
      docker/build.latest.sh
  30. +45
    -0
      docker/entrypoint.sh
  31. +171
    -0
      model/models.py
  32. +58
    -0
      requirements.txt
  33. +27
    -0
      run.py
  34. +263
    -0
      services/biz_service.py
  35. +1080
    -0
      services/gewe_service.py
  36. +166
    -0
      services/kafka_service.py
  37. +198
    -0
      services/redis_service.py
  38. +20
    -0
      setup_production.sh
  39. +17
    -0
      setup_test.sh
  40. +216
    -0
      voice/ali/ali_api.py
  41. +109
    -0
      voice/ali/ali_voice.py
  42. +7
    -0
      voice/ali/config.json
  43. +7
    -0
      voice/ali/config.json.template
  44. +136
    -0
      voice/audio_convert.py
  45. +17
    -0
      voice/voice.py

+ 39
- 0
.gitignore View File

@@ -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

+ 29
- 0
ai-ops-wx_run.sh View File

@@ -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

+ 0
- 0
app/__init__.py View File


+ 54
- 0
app/celery_app.py View File

@@ -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'])

+ 0
- 0
app/endpoints/__init__.py View File


+ 200
- 0
app/endpoints/agent_endpoint.py View File

@@ -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': '操作成功'}

+ 53
- 0
app/endpoints/config_endpoint.py View File

@@ -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}

+ 72
- 0
app/endpoints/contacts_endpoint.py View File

@@ -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

+ 48
- 0
app/endpoints/groups_endpoint.py View File

@@ -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

+ 905
- 0
app/endpoints/pipeline_endpoint.py View File

@@ -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







+ 126
- 0
app/endpoints/sns_endpoint.py View File

@@ -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

+ 233
- 0
app/main.py View File

@@ -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
}


+ 180
- 0
app/middleware.py View File

@@ -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())

+ 28
- 0
app/tasks.py View File

@@ -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

+ 0
- 0
common/__init__.py View File


+ 43
- 0
common/expired_dict.py View File

@@ -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__()

+ 176
- 0
common/log.py View File

@@ -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()

+ 5
- 0
common/memory.py View File

@@ -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)

+ 9
- 0
common/singleton.py View File

@@ -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

+ 17
- 0
common/tmp_dir.py View File

@@ -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) + "/"

+ 409
- 0
common/utils.py View File

@@ -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

+ 9
- 0
config-dev.json View File

@@ -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"
}

+ 9
- 0
config-production.json View File

@@ -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"
}

+ 37
- 0
config-template.json View File

@@ -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": ""
}

+ 9
- 0
config-test.json View File

@@ -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"
}

+ 9
- 0
config.json View File

@@ -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"
}

+ 179
- 0
config.py View File

@@ -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": []}

+ 42
- 0
docker/Dockerfile.latest View File

@@ -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"]

+ 8
- 0
docker/build.latest.sh View File

@@ -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)

+ 45
- 0
docker/entrypoint.sh View File

@@ -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

+ 171
- 0
model/models.py View File

@@ -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

+ 58
- 0
requirements.txt View File

@@ -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

+ 27
- 0
run.py View File

@@ -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,取消注释

+ 263
- 0
services/biz_service.py View File

@@ -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')

+ 1080
- 0
services/gewe_service.py
File diff suppressed because it is too large
View File


+ 166
- 0
services/kafka_service.py View File

@@ -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

+ 198
- 0
services/redis_service.py View File

@@ -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

+ 20
- 0
setup_production.sh View File

@@ -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

+ 17
- 0
setup_test.sh View File

@@ -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

+ 216
- 0
voice/ali/ali_api.py View File

@@ -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

+ 109
- 0
voice/ali/ali_voice.py View File

@@ -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

+ 7
- 0
voice/ali/config.json View File

@@ -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"
}

+ 7
- 0
voice/ali/config.json.template View File

@@ -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": ""
}

+ 136
- 0
voice/audio_convert.py View File

@@ -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

+ 17
- 0
voice/voice.py View File

@@ -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

Loading…
Cancel
Save