oibot 2026.2.25__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ name: pypi
2
+
3
+ on: push
4
+
5
+ jobs:
6
+ pypi:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@master
10
+ - uses: actions/setup-python@master
11
+ with:
12
+ python-version: 3.x
13
+ - run: |
14
+ python -m pip install build --user
15
+ python -m build --sdist --wheel --outdir dist
16
+ - uses: pypa/gh-action-pypi-publish@master
17
+ with:
18
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: oibot
3
+ Version: 2026.2.25
4
+ Summary: a lightweight bot framework
5
+ Requires-Python: >=3
6
+ Requires-Dist: aiohttp
7
+ Requires-Dist: cryptography
8
+ Description-Content-Type: text/markdown
9
+
10
+ # OiBot
11
+
12
+ A lightweight bot framework built on [aiohttp](https://github.com/aio-libs/aiohttp) and the official protocol.
@@ -0,0 +1,3 @@
1
+ # OiBot
2
+
3
+ A lightweight bot framework built on [aiohttp](https://github.com/aio-libs/aiohttp) and the official protocol.
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "oibot"
3
+ version = "2026.2.25"
4
+ description = "a lightweight bot framework"
5
+ readme = "README.md"
6
+ requires-python = ">=3"
7
+ dependencies = [
8
+ "aiohttp",
9
+ "cryptography",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
File without changes
@@ -0,0 +1,147 @@
1
+ import asyncio
2
+ import logging
3
+ from contextvars import ContextVar
4
+ from enum import IntEnum
5
+ from http import HTTPStatus
6
+ from os import environ
7
+ from typing import Any, Iterable, Literal, NamedTuple, Optional
8
+
9
+ from aiohttp import ClientSession, web
10
+ from aiohttp.web_request import Request
11
+ from aiohttp.web_response import Response
12
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
13
+
14
+ from oibot.mixin.access_token import AccessTokenMixin
15
+ from oibot.mixin.recall_message import RecallMessageMixin
16
+ from oibot.mixin.send_message import SendMessageMixin
17
+ from oibot.mixin.upload_file import UploadFileMixin
18
+
19
+
20
+ class OP(IntEnum):
21
+ MESSAGE = 0
22
+ VERIFICATION = 13
23
+
24
+
25
+ class Bot(NamedTuple):
26
+ app_id: str
27
+ app_token: str
28
+ app_secret: str
29
+
30
+
31
+ class Singleton(type):
32
+ instance: Optional["OiBot"] = None
33
+
34
+ def __call__(cls, *args, **kwargs) -> "OiBot":
35
+ if not (instance := cls.instance):
36
+ cls.instance = instance = super().__call__(*args, **kwargs)
37
+
38
+ return instance
39
+
40
+
41
+ class OiBot(
42
+ AccessTokenMixin,
43
+ RecallMessageMixin,
44
+ SendMessageMixin,
45
+ UploadFileMixin,
46
+ metaclass=Singleton,
47
+ ):
48
+ __slots__ = ("app", "plugin_manager", "bot")
49
+
50
+ def __init__(self, plugins: str | Iterable[str] | None = None, **kwargs) -> None:
51
+ from oibot.plugin import PluginManager
52
+
53
+ self.plugin_manager = plugin_manager = PluginManager(self)
54
+
55
+ if isinstance(plugins, str):
56
+ plugin_manager.import_from(plugins)
57
+
58
+ elif isinstance(plugins, Iterable):
59
+ for plugin in plugins:
60
+ plugin_manager.import_from(plugin)
61
+
62
+ self.app = app = web.Application(**kwargs)
63
+
64
+ app.router.add_post(path="/", handler=self.handler)
65
+
66
+ self.bot: ContextVar[Bot] = ContextVar("bot")
67
+
68
+ async def __call__(
69
+ self,
70
+ method: Literal[
71
+ "GET",
72
+ "POST",
73
+ "PUT",
74
+ "DELETE",
75
+ "PATCH",
76
+ "HEAD",
77
+ "OPTIONS",
78
+ "TRACE",
79
+ "CONNECT",
80
+ ],
81
+ url: str,
82
+ **kwargs,
83
+ ) -> Any:
84
+ logging.debug(f"{method=} {url=} {kwargs=}")
85
+
86
+ async with ClientSession(
87
+ base_url="https://api.sgroup.qq.com", # "https://sandbox.api.sgroup.qq.com"
88
+ ) as session:
89
+ async with session.request(method, url, **kwargs) as resp:
90
+ resp.raise_for_status()
91
+
92
+ return await resp.json()
93
+
94
+ async def handler(
95
+ self, request: Request, *, background_tasks: set[asyncio.Task] = set()
96
+ ) -> Response:
97
+ ctx = await request.json()
98
+
99
+ logging.debug(ctx)
100
+
101
+ self.bot.set(
102
+ bot := Bot(
103
+ app_id=request.query.get("id", environ["OIBOT_APP_ID"]),
104
+ app_token=request.query.get("token", environ["OIBOT_APP_TOKEN"]),
105
+ app_secret=request.query.get("secret", environ["OIBOT_APP_SECRET"]),
106
+ )
107
+ )
108
+
109
+ match ctx["op"]:
110
+ case OP.MESSAGE:
111
+ background_tasks.add(
112
+ task := asyncio.create_task(self.plugin_manager(ctx))
113
+ )
114
+
115
+ task.add_done_callback(background_tasks.discard)
116
+
117
+ case OP.VERIFICATION:
118
+ logging.info("webhook verification request received")
119
+
120
+ if not (secret := bot.app_secret):
121
+ raise ValueError("parameter `app_secret` must be specified")
122
+
123
+ secret = secret.encode("utf-8")
124
+
125
+ while len(secret) < 32:
126
+ secret *= 2
127
+
128
+ d = ctx["d"]
129
+
130
+ return web.json_response(
131
+ {
132
+ "plain_token": d["plain_token"],
133
+ "signature": (
134
+ Ed25519PrivateKey.from_private_bytes(secret[:32])
135
+ .sign(f"{d['event_ts']}{d['plain_token']}".encode("utf-8"))
136
+ .hex()
137
+ ),
138
+ }
139
+ )
140
+
141
+ case _:
142
+ logging.warning(f"invalid type received {ctx=}")
143
+
144
+ return web.Response(body=None, status=HTTPStatus.OK)
145
+
146
+ def run(self, *args, **kwargs) -> None:
147
+ web.run_app(self.app, *args, **kwargs)
@@ -0,0 +1,117 @@
1
+ import logging
2
+ from enum import StrEnum
3
+ from typing import ClassVar, TypedDict
4
+
5
+ from oibot.bot import OiBot
6
+
7
+
8
+ class EventType(StrEnum):
9
+ AT_MESSAGE_CREATE = "AT_MESSAGE_CREATE"
10
+ AUDIO_FINISH = "AUDIO_FINISH"
11
+ AUDIO_OR_LIVE_CHANNEL_MEMBER_ENTER = "AUDIO_OR_LIVE_CHANNEL_MEMBER_ENTER"
12
+ AUDIO_OR_LIVE_CHANNEL_MEMBER_EXIT = "AUDIO_OR_LIVE_CHANNEL_MEMBER_EXIT"
13
+ AUDIO_START = "AUDIO_START"
14
+ C2C_MESSAGE_CREATE = "C2C_MESSAGE_CREATE"
15
+ C2C_MSG_RECEIVE = "C2C_MSG_RECEIVE"
16
+ C2C_MSG_REJECT = "C2C_MSG_REJECT"
17
+ CHANNEL_CREATE = "CHANNEL_CREATE"
18
+ CHANNEL_DELETE = "CHANNEL_DELETE"
19
+ CHANNEL_UPDATE = "CHANNEL_UPDATE"
20
+ DIRECT_MESSAGE_CREATE = "DIRECT_MESSAGE_CREATE"
21
+ DIRECT_MESSAGE_DELETE = "DIRECT_MESSAGE_DELETE"
22
+ FORUM_POST_CREATE = "FORUM_POST_CREATE"
23
+ FORUM_POST_DELETE = "FORUM_POST_DELETE"
24
+ FORUM_PUBLISH_AUDIT_RESULT = "FORUM_PUBLISH_AUDIT_RESULT"
25
+ FORUM_REPLY_CREATE = "FORUM_REPLY_CREATE"
26
+ FORUM_REPLY_DELETE = "FORUM_REPLY_DELETE"
27
+ FORUM_THREAD_CREATE = "FORUM_THREAD_CREATE"
28
+ FORUM_THREAD_DELETE = "FORUM_THREAD_DELETE"
29
+ FORUM_THREAD_UPDATE = "FORUM_THREAD_UPDATE"
30
+ FRIEND_ADD = "FRIEND_ADD"
31
+ FRIEND_DEL = "FRIEND_DEL"
32
+ GROUP_ADD_ROBOT = "GROUP_ADD_ROBOT"
33
+ GROUP_AT_MESSAGE_CREATE = "GROUP_AT_MESSAGE_CREATE"
34
+ GROUP_DEL_ROBOT = "GROUP_DEL_ROBOT"
35
+ GROUP_MSG_RECEIVE = "GROUP_MSG_RECEIVE"
36
+ GROUP_MSG_REJECT = "GROUP_MSG_REJECT"
37
+ GUILD_CREATE = "GUILD_CREATE"
38
+ GUILD_DELETE = "GUILD_DELETE"
39
+ GUILD_MEMBER_ADD = "GUILD_MEMBER_ADD"
40
+ GUILD_MEMBER_REMOVE = "GUILD_MEMBER_REMOVE"
41
+ GUILD_MEMBER_UPDATE = "GUILD_MEMBER_UPDATE"
42
+ GUILD_UPDATE = "GUILD_UPDATE"
43
+ INTERACTION_CREATE = "INTERACTION_CREATE"
44
+ MESSAGE_AUDIT_PASS = "MESSAGE_AUDIT_PASS"
45
+ MESSAGE_AUDIT_REJECT = "MESSAGE_AUDIT_REJECT"
46
+ MESSAGE_CREATE = "MESSAGE_CREATE"
47
+ MESSAGE_DELETE = "MESSAGE_DELETE"
48
+ MESSAGE_REACTION_ADD = "MESSAGE_REACTION_ADD"
49
+ MESSAGE_REACTION_REMOVE = "MESSAGE_REACTION_REMOVE"
50
+ OFF_MIC = "OFF_MIC"
51
+ ON_MIC = "ON_MIC"
52
+ OPEN_FORUM_POST_CREATE = "OPEN_FORUM_POST_CREATE"
53
+ OPEN_FORUM_POST_DELETE = "OPEN_FORUM_POST_DELETE"
54
+ OPEN_FORUM_REPLY_CREATE = "OPEN_FORUM_REPLY_CREATE"
55
+ OPEN_FORUM_REPLY_DELETE = "OPEN_FORUM_REPLY_DELETE"
56
+ OPEN_FORUM_THREAD_CREATE = "OPEN_FORUM_THREAD_CREATE"
57
+ OPEN_FORUM_THREAD_DELETE = "OPEN_FORUM_THREAD_DELETE"
58
+ OPEN_FORUM_THREAD_UPDATE = "OPEN_FORUM_THREAD_UPDATE"
59
+ PUBLIC_MESSAGE_DELETE = "PUBLIC_MESSAGE_DELETE"
60
+
61
+
62
+ class Context(TypedDict):
63
+ d: dict
64
+ id: str
65
+ op: int
66
+ s: int
67
+ t: str
68
+
69
+
70
+ class Event:
71
+ __slots__ = ("bot", "_d", "_id", "_op", "_s", "_t")
72
+
73
+ _d: dict
74
+ _id: str
75
+ _op: int
76
+ _s: int
77
+ _t: str
78
+
79
+ event_type: ClassVar[EventType]
80
+
81
+ event: ClassVar[dict[str, type["Event"]]] = {}
82
+
83
+ def __new__(cls, bot: OiBot, ctx: Context) -> "Event":
84
+ return (
85
+ event.__new__(event, bot, ctx)
86
+ if (event := cls.dispatch(ctx)) is not cls
87
+ else super().__new__(cls)
88
+ )
89
+
90
+ def __init__(self, bot: OiBot, ctx: Context) -> None:
91
+ self.bot = bot
92
+
93
+ self._d = ctx["d"]
94
+ self._id = ctx["id"]
95
+ self._op = ctx["op"]
96
+ # self._s = ctx["s"]
97
+ self._t = ctx["t"]
98
+
99
+ logging.info(self.__repr__())
100
+
101
+ def __init_subclass__(cls, *args, **kwargs) -> None:
102
+ logging.debug(f"registered {cls} as type {cls._t}")
103
+
104
+ Event.event[cls.event_type] = cls
105
+
106
+ def __repr__(self) -> str:
107
+ return f"""{self.__class__.__name__}({
108
+ ", ".join(
109
+ f"{item}={value}"
110
+ for item in self.__slots__
111
+ if (not item.startswith("__")) and (value := getattr(self, item, None))
112
+ )
113
+ })"""
114
+
115
+ @classmethod
116
+ def dispatch(cls, ctx: Context) -> type["Event"]:
117
+ return cls.event.get(ctx["t"], cls)
@@ -0,0 +1,159 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+ from itertools import count
4
+ from typing import ClassVar, Literal, NamedTuple
5
+
6
+ from oibot.bot import OiBot
7
+ from oibot.event import Context, Event
8
+ from oibot.mixin.send_message import (
9
+ Ark,
10
+ Embed,
11
+ Keyboard,
12
+ Markdown,
13
+ Media,
14
+ MessageReference,
15
+ SendMessageResponse,
16
+ )
17
+
18
+
19
+ class C2CMessageCreateEvent(Event):
20
+ __slots__ = (
21
+ "id",
22
+ "content",
23
+ "timestamp",
24
+ "author",
25
+ "attachments",
26
+ "message_scene",
27
+ "message_type",
28
+ "msg_seq",
29
+ )
30
+
31
+ class Author(NamedTuple):
32
+ id: str
33
+ user_openid: str
34
+ union_openid: str
35
+
36
+ class Attachment(NamedTuple):
37
+ content_type: str
38
+ filename: str
39
+ height: int
40
+ size: int
41
+ url: str
42
+ width: int
43
+
44
+ class MessageScene(NamedTuple):
45
+ source: str
46
+ callback_data: str
47
+
48
+ event_type: ClassVar[Literal["C2C_MESSAGE_CREATE"]] = "C2C_MESSAGE_CREATE"
49
+
50
+ id: str
51
+ content: str
52
+ timestamp: datetime
53
+ author: Author
54
+ attachments: list[Attachment]
55
+
56
+ message_scene: MessageScene
57
+ message_type: int
58
+
59
+ def __init__(self, bot: OiBot, ctx: Context) -> None:
60
+ d = ctx["d"]
61
+
62
+ self.id = d["id"]
63
+ self.content = d["content"]
64
+ self.timestamp = d["timestamp"]
65
+
66
+ self.author = self.Author(
67
+ id=d["author"]["id"],
68
+ user_openid=d["author"]["user_openid"],
69
+ union_openid=d["author"]["union_openid"],
70
+ )
71
+
72
+ self.attachments = [
73
+ self.Attachment(
74
+ content_type=attachment["content_type"],
75
+ filename=attachment["filename"],
76
+ height=attachment["height"],
77
+ size=attachment["size"],
78
+ url=attachment["url"],
79
+ width=attachment["width"],
80
+ )
81
+ for attachment in d.get("attachments", [])
82
+ ]
83
+
84
+ self.message_scene = self.MessageScene(
85
+ source=d["message_scene"]["source"],
86
+ callback_data=d["message_scene"]["callback_data"],
87
+ )
88
+ self.message_type = d["message_type"]
89
+
90
+ super().__init__(bot, ctx)
91
+
92
+ self.msg_seq = count()
93
+
94
+ async def reply(
95
+ self,
96
+ content: str | None = None,
97
+ markdown: Markdown | None = None,
98
+ keyboard: Keyboard | None = None,
99
+ embed: Embed | None = None,
100
+ ark: Ark | None = None,
101
+ media: Media | None = None,
102
+ message_reference: MessageReference | None = None,
103
+ event_id: str | None = None,
104
+ msg_seq: int = 0,
105
+ ) -> SendMessageResponse:
106
+ return await self.bot.send_private_message(
107
+ openid=self.author.union_openid,
108
+ content=content,
109
+ markdown=markdown,
110
+ keyboard=keyboard,
111
+ embed=embed,
112
+ ark=ark,
113
+ media=media,
114
+ message_reference=message_reference,
115
+ event_id=event_id,
116
+ msg_id=self.id,
117
+ msg_seq=msg_seq or next(self.msg_seq),
118
+ )
119
+
120
+ async def defer(
121
+ self,
122
+ content: str | None = None,
123
+ markdown: Markdown | None = None,
124
+ keyboard: Keyboard | None = None,
125
+ embed: Embed | None = None,
126
+ ark: Ark | None = None,
127
+ media: Media | None = None,
128
+ message_reference: MessageReference | None = None,
129
+ event_id: str | None = None,
130
+ msg_seq: int = 0,
131
+ ) -> "C2CMessageCreateEvent":
132
+ self.bot.plugin_manager.sessions[
133
+ key := (
134
+ self.bot.bot.get(),
135
+ getattr(self, "group_openid", None),
136
+ (
137
+ getattr(self, "author.member_openid", None)
138
+ or getattr(self, "author.user_openid", None)
139
+ ),
140
+ )
141
+ ] = future = asyncio.get_running_loop().create_future()
142
+
143
+ await self.reply(
144
+ content=content,
145
+ markdown=markdown,
146
+ keyboard=keyboard,
147
+ embed=embed,
148
+ ark=ark,
149
+ media=media,
150
+ message_reference=message_reference,
151
+ event_id=event_id,
152
+ msg_seq=msg_seq,
153
+ )
154
+
155
+ try:
156
+ return await future
157
+
158
+ finally:
159
+ self.bot.plugin_manager.sessions.pop(key, None)
@@ -0,0 +1,63 @@
1
+ from datetime import datetime
2
+ from typing import ClassVar, Literal, NamedTuple
3
+
4
+ from oibot.bot import OiBot
5
+ from oibot.event import Context, Event
6
+ from oibot.mixin.send_message import (
7
+ Ark,
8
+ Embed,
9
+ Keyboard,
10
+ Markdown,
11
+ Media,
12
+ MessageReference,
13
+ SendMessageResponse,
14
+ )
15
+
16
+
17
+ class FriendAddEvent(Event):
18
+ __slots__ = ("timestamp", "openid", "author")
19
+
20
+ class Author(NamedTuple):
21
+ union_openid: str
22
+
23
+ event_type: ClassVar[Literal["FRIEND_ADD"]] = "FRIEND_ADD"
24
+
25
+ timestamp: datetime
26
+ openid: str
27
+ author: Author
28
+
29
+ def __init__(self, bot: OiBot, ctx: Context) -> None:
30
+ d = ctx["d"]
31
+
32
+ self.timestamp = d["timestamp"]
33
+ self.openid = d["openid"]
34
+ self.author = self.Author(union_openid=d["author"]["union_openid"])
35
+
36
+ super().__init__(bot, ctx)
37
+
38
+ async def reply(
39
+ self,
40
+ content: str | None = None,
41
+ markdown: Markdown | None = None,
42
+ keyboard: Keyboard | None = None,
43
+ embed: Embed | None = None,
44
+ ark: Ark | None = None,
45
+ media: Media | None = None,
46
+ message_reference: MessageReference | None = None,
47
+ event_id: str | None = None,
48
+ msg_id: str | None = None,
49
+ msg_seq: int = 0,
50
+ ) -> SendMessageResponse:
51
+ return await self.bot.send_private_message(
52
+ openid=self.author.union_openid,
53
+ content=content,
54
+ markdown=markdown,
55
+ keyboard=keyboard,
56
+ embed=embed,
57
+ ark=ark,
58
+ media=media,
59
+ message_reference=message_reference,
60
+ event_id=event_id,
61
+ msg_id=msg_id,
62
+ msg_seq=msg_seq,
63
+ )
@@ -0,0 +1,27 @@
1
+ from datetime import datetime
2
+ from typing import ClassVar, Literal, NamedTuple
3
+
4
+ from oibot.bot import OiBot
5
+ from oibot.event import Context, Event
6
+
7
+
8
+ class FriendDelEvent(Event):
9
+ __slots__ = ("timestamp", "openid", "author")
10
+
11
+ class Author(NamedTuple):
12
+ union_openid: str
13
+
14
+ event_type: ClassVar[Literal["FRIEND_DEL"]] = "FRIEND_DEL"
15
+
16
+ timestamp: datetime
17
+ openid: str
18
+ author: Author
19
+
20
+ def __init__(self, bot: OiBot, ctx: Context) -> None:
21
+ d = ctx["d"]
22
+
23
+ self.timestamp = d["timestamp"]
24
+ self.openid = d["openid"]
25
+ self.author = self.Author(union_openid=d["author"]["union_openid"])
26
+
27
+ super().__init__(bot, ctx)
@@ -0,0 +1,60 @@
1
+ from datetime import datetime
2
+ from typing import ClassVar, Literal
3
+
4
+ from oibot.bot import OiBot
5
+ from oibot.event import Context, Event
6
+ from oibot.mixin.send_message import (
7
+ Ark,
8
+ Embed,
9
+ Keyboard,
10
+ Markdown,
11
+ Media,
12
+ MessageReference,
13
+ SendMessageResponse,
14
+ )
15
+
16
+
17
+ class GroupAddRobotEvent(Event):
18
+ __slots__ = ("timestamp", "group_openid", "op_member_openid")
19
+
20
+ event_type: ClassVar[Literal["GROUP_ADD_ROBOT"]] = "GROUP_ADD_ROBOT"
21
+
22
+ timestamp: datetime
23
+ group_openid: str
24
+ op_member_openid: str
25
+
26
+ def __init__(self, bot: OiBot, ctx: Context) -> None:
27
+ d = ctx["d"]
28
+
29
+ self.timestamp = d["timestamp"]
30
+ self.group_openid = d["group_openid"]
31
+ self.op_member_openid = d["op_member_openid"]
32
+
33
+ super().__init__(bot, ctx)
34
+
35
+ async def reply(
36
+ self,
37
+ content: str | None = None,
38
+ markdown: Markdown | None = None,
39
+ keyboard: Keyboard | None = None,
40
+ embed: Embed | None = None,
41
+ ark: Ark | None = None,
42
+ media: Media | None = None,
43
+ message_reference: MessageReference | None = None,
44
+ event_id: str | None = None,
45
+ msg_id: str | None = None,
46
+ msg_seq: int = 0,
47
+ ) -> SendMessageResponse:
48
+ return await self.bot.send_group_message(
49
+ group_openid=self.group_openid,
50
+ content=content,
51
+ markdown=markdown,
52
+ keyboard=keyboard,
53
+ embed=embed,
54
+ ark=ark,
55
+ media=media,
56
+ message_reference=message_reference,
57
+ event_id=event_id,
58
+ msg_id=msg_id,
59
+ msg_seq=msg_seq,
60
+ )