Skip to content

Commit

Permalink
更新
Browse files Browse the repository at this point in the history
  • Loading branch information
YangRucheng committed Jan 5, 2025
1 parent 10599d0 commit 6942abe
Show file tree
Hide file tree
Showing 8 changed files with 398 additions and 143 deletions.
46 changes: 32 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,39 @@ WXMP_BOTS='
+ URL(服务器地址): `https://example.com/wxmp/revice/<app_id>`
+ Token(令牌):暂不支持,随意填
+ 消息加密方式:明文模式
+ 数据格式:JSON
+ 数据格式:JSON (公众号为XML)

### 开发进度
### 适配情况

#### 小程序客服消息
<div align="center">

| | 小程序(事件推送) | 小程序(发送消息) | 公众号(事件推送) | 公众号(发送消息) |
| ------------ | ------------------ | ------------------ | ------------------ | ------------------ |
| 文字消息 |||||
| 图片消息 |||||
| 图文链接 |||||
| 小程序卡片 |||| |
| 语音消息 ||| | |
| 音乐消息 |||| |
| 视频消息 ||| | |
| 小视频消息 ||| | |
| 地理位置消息 ||| | |

</div>

❌官方不支持 · ✅已适配 · 其他官方支持但暂未适配

> 由于我没有已认证的 公众号/服务号,无法测试,如有问题请提 Issue!
### 参考文档

#### 微信开发文档

+ [公众号事件推送](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html)
+ [公众号发送消息](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html#客服接口-发消息)
+ [小程序事件推送]()
+ [小程序发送消息](https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/kf-mgnt/kf-message/sendCustomMessage.html)

- [x] 文字消息
- [x] 图片消息
- [x] 图文链接
- [x] 小程序卡片

#### 公众号客服消息
#### 其他补充信息

- [x] 文字消息
- [x] 图片消息
- [ ] 语音消息
- [ ] 视频消息
- [ ] 音乐消息
+ [不支持表情包](https://developers.weixin.qq.com/community/develop/doc/00000ee4eb8190937f227559f66c00)
51 changes: 18 additions & 33 deletions nonebot/adapters/wxmp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import xmltodict
import asyncio
import hashlib
import secrets
import json
import sys
import re
Expand All @@ -29,9 +30,14 @@

from .bot import Bot
from .event import *
from .utils import log
from .config import Config, BotInfo
from .message import Message, MessageSegment

from .exception import (
ActionFailed,
NetworkError,
ApiNotAvailable,
)

from nonebot import get_plugin_config
from nonebot.drivers import (
Expand All @@ -43,14 +49,6 @@
HTTPClientMixin,
WebSocketServerSetup
)
from nonebot.exception import (
ActionFailed,
NetworkError,
ApiNotAvailable,
)


log = logger_wrapper("WXMP")


class Adapter(BaseAdapter):
Expand Down Expand Up @@ -103,22 +101,6 @@ def setup(self) -> None:
)
self.setup_http_server(http_setup)

http_setup = HTTPServerSetup(
URL(f"/wxmp/revice"),
"GET",
f"{self.get_name()} Root Verify",
self._handle_verify,
)
self.setup_http_server(http_setup)

http_setup = HTTPServerSetup(
URL(f"/wxmp/revice"),
"POST",
f"{self.get_name()} Root Event",
self._handle_event,
)
self.setup_http_server(http_setup)

self.driver.on_shutdown(self.shutdown)

async def shutdown(self) -> None:
Expand All @@ -135,6 +117,7 @@ async def shutdown(self) -> None:

@classmethod
def parse_body(cls, data: str) -> dict:
""" 解析微信公众平台的事件数据 """
try:
return json.loads(data)
except json.JSONDecodeError:
Expand All @@ -154,12 +137,12 @@ async def _handle_event(self, request: Request) -> Response:
bot: Bot = self.bots.get(self._get_appid(url.path), None)

if not bot:
return Response(200, content="success")
return Response(404, content="Bot not found")

if request.content:
concat_string: str = ''.join(sorted([bot.bot_info.token, timestamp, nonce]))
sha1_signature = hashlib.sha1(concat_string.encode('utf-8')).hexdigest()
if sha1_signature != signature:
if not secrets.compare_digest(sha1_signature, signature):
return Response(403, content="Invalid signature")
else:
payload: dict = self.parse_body(request.content)
Expand Down Expand Up @@ -190,19 +173,19 @@ async def _handle_verify(self, request: Request) -> Any:

bot: Bot = self.bots.get(self._get_appid(url.path), None)

if not bot: # 默认验证通过
return Response(200, content=echostr)
if not bot:
return Response(404, content="Bot not found")

concat_string: str = ''.join(sorted([timestamp, nonce, bot.bot_info.token]))
sha1_signature = hashlib.sha1(concat_string.encode('utf-8')).hexdigest()

if sha1_signature == signature:
if secrets.compare_digest(sha1_signature, signature):
return Response(200, content=echostr)
else:
return Response(403, content="Invalid signature")

def _get_appid(self, path: str) -> BotInfo | None:
""" 从链接中获取 Bot 配置 """
def _get_appid(self, path: str) -> str:
""" 从链接中获取 Bot 的 AppID """
return path.split('/')[-1]

async def _call_api(self, bot: Bot, api: str, **data: Any) -> Response:
Expand All @@ -221,6 +204,8 @@ async def _call_api(self, bot: Bot, api: str, **data: Any) -> Response:
files=data.get("files", None),
)
resp = await self.request(request)

if resp.status_code != 200 or not resp.content:
raise NetworkError(f"Call API {api} failed with status code {resp.status_code}.")
raise ActionFailed(retcode=resp.status_code, api=api, info=str(resp.content))

return resp
85 changes: 61 additions & 24 deletions nonebot/adapters/wxmp/bot.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,30 @@
from typing import Union, Any, Optional, Type, TYPE_CHECKING, cast, Literal
from typing_extensions import override
from pathlib import Path
import json
import time

from nonebot.message import handle_event
from nonebot.utils import logger_wrapper
from nonebot.adapters import Bot as BaseBot
from nonebot.drivers import Request, Response
from nonebot.exception import (
ActionFailed,
NetworkError,
ApiNotAvailable,
)
from nonebot.drivers import (
Request,
Response,
)

from .event import *
from .utils import log
from .config import BotInfo
from .message import Message, MessageSegment
from .exception import NetworkError, ActionFailed

if TYPE_CHECKING:
from .adapter import Adapter


log = logger_wrapper("WXMP")


class Bot(BaseBot):
adapter: "Adapter"

@override
def __init__(self, adapter: "Adapter", self_id: str, bot_info: BotInfo):
Expand All @@ -52,7 +48,7 @@ async def handle_event(self, event: Type[Event]):
""" 处理事件 """
await handle_event(self, event)

async def _get_access_token(self) -> str:
async def _get_access_token(self, force_refresh: bool = False) -> str:
""" 获取微信公众平台的 access_token """
now = time.time()
if (self._expires_in or 0) > now:
Expand All @@ -65,15 +61,12 @@ async def _get_access_token(self) -> str:
"grant_type": "client_credential",
"appid": self.bot_info.appid,
"secret": self.bot_info.secret,
"force_refresh": False,
"force_refresh": force_refresh,
},
)
resp = await self.adapter.request(request)
if resp.status_code != 200 or not resp.content:
raise NetworkError(
f"Get authorization failed with status code {resp.status_code}."
" Please check your config."
)
raise ActionFailed(retcode=resp.status_code, info=str(resp.content))
res: dict = json.loads(resp.content)
self._expires_in = now + res["expires_in"]
self._access_token = res["access_token"]
Expand All @@ -84,11 +77,10 @@ async def call_json_api(self, api: str, **data: Any) -> dict:
resp: Response = await self.call_api(api=api, **data)
res: dict = json.loads(resp.content)
if res.get("errcode", 0) != 0:
log("ERROR", f"Call API {api} failed with error {res}")
raise ActionFailed()
raise ActionFailed(retcode=res["errcode"], info=res)
return res

async def send_custom_message(self, user_id: str, message: Message):
async def send_custom_message(self, user_id: str, message: Message | MessageSegment | str) -> dict:
""" 发送 客服消息 """
if isinstance(message, str):
message = Message(MessageSegment.text(message))
Expand All @@ -109,7 +101,19 @@ async def send_custom_message(self, user_id: str, message: Message):
},
)
elif segment.type == "image":
media_id = await self.upload_temp_media("image", segment.data["file"])
if segment.data["media_id"]:
media_id = segment.data["media_id"]
elif segment.data["file"]:
media_id = await self.upload_temp_media("image", segment.data["file"])
elif segment.data["file_path"]:
file_path = cast(Path, segment.data["file_path"])
media_id = await self.upload_temp_media("image", file_path.read_bytes())
elif segment.data["file_url"]:
file_url = cast(str, segment.data["file_url"])
media_id = await self.upload_temp_media("image", await self.download_file(file_url))
else:
raise ValueError("At least one of `media_id`, `file`, `file_path`, `file_url` is required")

return await self.call_json_api(
"/message/custom/send",
json={
Expand All @@ -119,6 +123,9 @@ async def send_custom_message(self, user_id: str, message: Message):
},
)
elif segment.type == "link":
if self.bot_info.type != "miniprogram":
raise ValueError("link type is only supported in miniprogram")

return await self.call_json_api(
"/message/custom/send",
json={
Expand All @@ -133,21 +140,46 @@ async def send_custom_message(self, user_id: str, message: Message):
},
)
elif segment.type == "miniprogrampage":
media_id = await self.upload_temp_media("image", segment.data["thumb_media"])
if segment.data["thumb_media_id"]:
media_id = segment.data["thumb_media_id"]
elif segment.data["thumb_media"]:
media_id = await self.upload_temp_media("image", segment.data["thumb_media"])
elif segment.data["thumb_media_path"]:
file_path = cast(Path, segment.data["thumb_media_path"])
media_id = await self.upload_temp_media("image", file_path.read_bytes())
else:
raise ValueError("At least one of `thumb_media_id`, `thumb_media`, `thumb_media_path` is required")

data = {
"title": segment.data["title"],
"pagepath": segment.data["page_path"],
"thumb_media_id": media_id,
}

return await self.call_json_api(
"/message/custom/send",
json={
"touser": user_id,
"msgtype": "miniprogrampage",
"miniprogrampage": {
"title": segment.data["title"],
"pagepath": segment.data["page_path"],
"thumb_media_id": media_id,
"miniprogrampage": data if self.bot_info.type == "miniprogram" else data | {
"appid": segment.data["appid"],
},
},
)
elif segment.type == "voice":
media_id = await self.upload_temp_media("voice", segment.data["voice"])
if self.bot_info.type != "official":
raise ValueError("voice type is only supported in official account")

if segment.data["media_id"]:
media_id = segment.data["media_id"]
elif segment.data["file"]:
media_id = await self.upload_temp_media("voice", segment.data["file"])
elif segment.data["file_path"]:
file_path = cast(Path, segment.data["file_path"])
media_id = await self.upload_temp_media("voice", file_path.read_bytes())
else:
raise ValueError("At least one of `media_id`, `file`, `file_path` is required")

return await self.call_json_api(
"/message/custom/send",
json={
Expand Down Expand Up @@ -198,3 +230,8 @@ async def set_tpying(self, command: Literal["Typing", "CancelTyping"], user_id:
"command": command,
},
)

async def download_file(self, url: str) -> bytes:
""" 下载文件 """
resp: Response = await self.adapter.request(Request("GET", url))
return resp.content
Loading

0 comments on commit 6942abe

Please sign in to comment.