zynko 0.1.0__py3-none-any.whl

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.
zynko/interaction.py ADDED
@@ -0,0 +1,106 @@
1
+ """Someone used one of the bot's "/" commands, or clicked a button on one of its messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ from .message import Message
8
+ from .models import Card, Channel, Components, Member, User
9
+
10
+ if TYPE_CHECKING:
11
+ from .client import Client
12
+
13
+ __all__ = ["Interaction"]
14
+
15
+
16
+ class Interaction:
17
+ """A command or a click. Answer within 15 minutes.
18
+
19
+ :meth:`reply` posts a message (or one only they see), :meth:`update` changes the message
20
+ whose button was clicked::
21
+
22
+ @client.on("interaction")
23
+ async def on_interaction(i: zynko.Interaction):
24
+ if i.command == "dado":
25
+ await i.reply(f"🎲 {random.randint(1, i.option('lados', 6))}")
26
+ """
27
+
28
+ def __init__(self, client: Client, data: dict[str, Any]) -> None:
29
+ self.client = client
30
+ self.raw = data
31
+ self.id: str = str(data["id"])
32
+ self.token: str = data["token"]
33
+ self.type: Literal["command", "button"] = data.get("type", "command")
34
+ self.channel_id: str = str(data["channel_id"])
35
+ self.guild_id: str | None = data.get("guild_id")
36
+ #: Who used it.
37
+ self.user = User.from_dict(data["user"])
38
+ self.member = Member.from_dict(data["member"]) if data.get("member") else None
39
+ command = data.get("command") or {}
40
+ #: The command's name, for commands.
41
+ self.command: str | None = command.get("name")
42
+ #: What was typed for each option, by name.
43
+ self.options: dict[str, str | int | float | bool] = {o["name"]: o.get("value") for o in command.get("options") or []}
44
+ #: The clicked button's ``custom_id``, for buttons.
45
+ self.custom_id: str | None = data.get("custom_id")
46
+ #: The message whose button was clicked.
47
+ self.message = Message(client, data["message"]) if data.get("message") else None
48
+ #: Whether it was answered already.
49
+ self.replied = False
50
+
51
+ def __repr__(self) -> str:
52
+ what = f"command={self.command!r}" if self.type == "command" else f"custom_id={self.custom_id!r}"
53
+ return f"<Interaction id={self.id} type={self.type} {what} user={self.user.username!r}>"
54
+
55
+ def option(self, name: str, default: Any = None) -> Any:
56
+ """One option's value; ``default`` when it was left out. User, channel and role options are ids."""
57
+ return self.options.get(name, default)
58
+
59
+ @property
60
+ def channel(self) -> Channel | None:
61
+ return self.client.channels.get(self.channel_id)
62
+
63
+ async def reply(
64
+ self,
65
+ content: str | None = None,
66
+ *,
67
+ cards: list[Card] | None = None,
68
+ components: Components | None = None,
69
+ ephemeral: bool = False,
70
+ ) -> Message:
71
+ """Answers with a message in the channel, or only for that person with ``ephemeral=True``.
72
+
73
+ May be called again for follow-ups.
74
+ """
75
+ body: dict[str, Any] = {"type": "message"}
76
+ if content is not None:
77
+ body["content"] = content
78
+ if cards is not None:
79
+ body["cards"] = cards
80
+ if components is not None:
81
+ body["components"] = components
82
+ if ephemeral:
83
+ body["ephemeral"] = True
84
+ msg = await self.client.rest.interaction_callback(self.id, self.token, body)
85
+ self.replied = True
86
+ return Message(self.client, msg)
87
+
88
+ async def update(
89
+ self, content: str | None = None, *, cards: list[Card] | None = None, components: Components | None = None
90
+ ) -> Message:
91
+ """Changes the message whose button was clicked: whatever is given (text, cards, buttons).
92
+
93
+ ``components=[]`` removes the buttons.
94
+ """
95
+ if self.type != "button":
96
+ raise RuntimeError("update() é só para cliques em botões")
97
+ body: dict[str, Any] = {"type": "update"}
98
+ if content is not None:
99
+ body["content"] = content
100
+ if cards is not None:
101
+ body["cards"] = cards
102
+ if components is not None:
103
+ body["components"] = components
104
+ msg = await self.client.rest.interaction_callback(self.id, self.token, body)
105
+ self.replied = True
106
+ return Message(self.client, msg)
zynko/message.py ADDED
@@ -0,0 +1,121 @@
1
+ """A message, with shortcuts to answer it, react, edit and delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from .models import Attachment, Card, Channel, Components, Member, User
8
+ from .rest import FileInput
9
+
10
+ if TYPE_CHECKING:
11
+ from .client import Client
12
+
13
+ __all__ = ["Message"]
14
+
15
+
16
+ class Message:
17
+ """A message as the API sends it. ``raw`` keeps the JSON."""
18
+
19
+ __slots__ = (
20
+ "client",
21
+ "raw",
22
+ "id",
23
+ "channel_id",
24
+ "author",
25
+ "content",
26
+ "timestamp",
27
+ "edited_timestamp",
28
+ "reply_to",
29
+ "attachments",
30
+ "reactions",
31
+ "embeds",
32
+ "cards",
33
+ "components",
34
+ "interaction",
35
+ "ephemeral",
36
+ "nonce",
37
+ "partial",
38
+ )
39
+
40
+ def __init__(self, client: Client, data: dict[str, Any]) -> None:
41
+ self.client = client
42
+ self.raw = data
43
+ self.id: str = str(data["id"])
44
+ self.channel_id: str = str(data["channel_id"])
45
+ #: A MESSAGE_UPDATE brings only what changed (``id``, ``channel_id``, ``content``,
46
+ #: ``edited_timestamp``, ``embeds``): then ``partial`` is True and ``author`` is empty.
47
+ self.partial = "author" not in data
48
+ self.author = User.from_dict(data["author"]) if not self.partial else User("", "", "", None, False)
49
+ self.content: str = data.get("content") or ""
50
+ self.timestamp: int = int(data.get("timestamp") or 0)
51
+ self.edited_timestamp: int | None = data.get("edited_timestamp")
52
+ #: The message this one answers: ``{id, author, content}``.
53
+ self.reply_to: dict[str, Any] | None = data.get("reply_to")
54
+ self.attachments = [Attachment.from_dict(a) for a in data.get("attachments") or []]
55
+ #: ``{emoji, count, me}`` each.
56
+ self.reactions: list[dict[str, Any]] = data.get("reactions") or []
57
+ #: Link previews.
58
+ self.embeds: list[dict[str, Any]] = data.get("embeds") or []
59
+ self.cards: list[Card] = data.get("cards") or []
60
+ self.components: Components = data.get("components") or []
61
+ #: The command or click this (bot) message answers: ``{id, name?, user}``.
62
+ self.interaction: dict[str, Any] | None = data.get("interaction")
63
+ #: Only its reader sees it, and it is not kept.
64
+ self.ephemeral = bool(data.get("ephemeral", False))
65
+ self.nonce: str | None = data.get("nonce")
66
+
67
+ def __repr__(self) -> str:
68
+ return f"<Message id={self.id} channel_id={self.channel_id} author={self.author.username!r} content={self.content[:40]!r}>"
69
+
70
+ @property
71
+ def channel(self) -> Channel | None:
72
+ """The channel, when the client has seen it."""
73
+ return self.client.channels.get(self.channel_id)
74
+
75
+ @property
76
+ def guild_id(self) -> str | None:
77
+ """The guild (servidor) the message is in; None in a DM."""
78
+ c = self.channel
79
+ return c.guild_id if c else None
80
+
81
+ @property
82
+ def member(self) -> Member | None:
83
+ """The author as a member of the guild (nickname, roles); None in DMs."""
84
+ g = self.guild_id
85
+ guild = self.client.guilds.get(g) if g else None
86
+ return guild.members.get(self.author.id) if guild else None
87
+
88
+ @property
89
+ def mine(self) -> bool:
90
+ """Whether the author is the bot itself."""
91
+ return self.client.user is not None and self.author.id == self.client.user.id
92
+
93
+ async def reply(
94
+ self,
95
+ content: str | None = None,
96
+ *,
97
+ files: list[FileInput] | None = None,
98
+ cards: list[Card] | None = None,
99
+ components: Components | None = None,
100
+ nonce: str | None = None,
101
+ ) -> Message:
102
+ """Answers in the same channel, as a reply to this message."""
103
+ return await self.client.send(
104
+ self.channel_id, content, reply_to=self.id, files=files, cards=cards, components=components, nonce=nonce
105
+ )
106
+
107
+ async def react(self, emoji: str) -> None:
108
+ await self.client.rest.react(self.channel_id, self.id, emoji)
109
+
110
+ async def unreact(self, emoji: str) -> None:
111
+ await self.client.rest.unreact(self.channel_id, self.id, emoji)
112
+
113
+ async def edit(
114
+ self, content: str | None = None, *, cards: list[Card] | None = None, components: Components | None = None
115
+ ) -> Message:
116
+ """Only the author edits: a bot edits its own messages (text, cards, buttons)."""
117
+ data = await self.client.rest.edit_message(self.channel_id, self.id, content, cards=cards, components=components)
118
+ return Message(self.client, data)
119
+
120
+ async def delete(self) -> None:
121
+ await self.client.rest.delete_message(self.channel_id, self.id)
zynko/models.py ADDED
@@ -0,0 +1,387 @@
1
+ """The Zynko API's objects, as the server sends them (docs.zynko.org/api/objetos).
2
+
3
+ Ids are strings; times are milliseconds since 1970. Each model keeps the JSON it came from in
4
+ ``raw``, so fields this library does not know yet are still reachable.
5
+
6
+ What a bot *sends* (cards, buttons, commands) is plain dicts, typed with ``TypedDict``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Literal, TypedDict
13
+
14
+ __all__ = [
15
+ "ChannelType",
16
+ "User",
17
+ "Overwrite",
18
+ "Channel",
19
+ "Member",
20
+ "Role",
21
+ "Emoji",
22
+ "Attachment",
23
+ "VoiceState",
24
+ "Guild",
25
+ "Card",
26
+ "CardAuthor",
27
+ "CardField",
28
+ "CardFooter",
29
+ "CardImage",
30
+ "Button",
31
+ "ButtonStyle",
32
+ "Components",
33
+ "Command",
34
+ "CommandOption",
35
+ "CommandChoice",
36
+ "OptionType",
37
+ ]
38
+
39
+ JSON = dict[str, Any]
40
+
41
+
42
+ class ChannelType:
43
+ TEXT = 0
44
+ DM = 1
45
+ VOICE = 2
46
+ CATEGORY = 4
47
+ POST = 11
48
+ FORUM = 15
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class User:
53
+ id: str
54
+ username: str
55
+ display_name: str
56
+ avatar: str | None
57
+ bot: bool
58
+ raw: JSON = field(default_factory=dict, repr=False, compare=False)
59
+
60
+ @classmethod
61
+ def from_dict(cls, d: JSON) -> User:
62
+ return cls(
63
+ id=str(d["id"]),
64
+ username=d.get("username", ""),
65
+ display_name=d.get("display_name") or d.get("username", ""),
66
+ avatar=d.get("avatar"),
67
+ bot=bool(d.get("bot", False)),
68
+ raw=d,
69
+ )
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class Overwrite:
74
+ """A channel's permission overwrite: ``type`` 0 is a role, 1 a member."""
75
+
76
+ id: str
77
+ type: int
78
+ allow: int
79
+ deny: int
80
+
81
+ @classmethod
82
+ def from_dict(cls, d: JSON) -> Overwrite:
83
+ return cls(id=str(d["id"]), type=int(d.get("type", 0)), allow=int(d.get("allow", 0)), deny=int(d.get("deny", 0)))
84
+
85
+
86
+ @dataclass(slots=True)
87
+ class Channel:
88
+ id: str
89
+ guild_id: str | None
90
+ type: int
91
+ name: str
92
+ topic: str | None = None
93
+ position: int = 0
94
+ parent_id: str | None = None
95
+ last_message_id: str | None = None
96
+ #: The voice server a room runs on, when someone picked one.
97
+ voice_server: str | None = None
98
+ #: In a DM: the other person.
99
+ recipients: list[User] = field(default_factory=list)
100
+ permission_overwrites: list[Overwrite] = field(default_factory=list)
101
+ raw: JSON = field(default_factory=dict, repr=False, compare=False)
102
+
103
+ @classmethod
104
+ def from_dict(cls, d: JSON) -> Channel:
105
+ return cls(
106
+ id=str(d["id"]),
107
+ guild_id=d.get("guild_id"),
108
+ type=int(d.get("type", 0)),
109
+ name=d.get("name") or "",
110
+ topic=d.get("topic"),
111
+ position=int(d.get("position") or 0),
112
+ parent_id=d.get("parent_id"),
113
+ last_message_id=d.get("last_message_id"),
114
+ voice_server=d.get("voice_server"),
115
+ recipients=[User.from_dict(u) for u in d.get("recipients") or []],
116
+ permission_overwrites=[Overwrite.from_dict(o) for o in d.get("permission_overwrites") or []],
117
+ raw=d,
118
+ )
119
+
120
+
121
+ @dataclass(slots=True)
122
+ class Member:
123
+ guild_id: str
124
+ user: User
125
+ nick: str | None
126
+ is_admin: bool
127
+ joined_at: int
128
+ #: Not including @everyone, which everybody has.
129
+ roles: list[str]
130
+ raw: JSON = field(default_factory=dict, repr=False, compare=False)
131
+
132
+ @classmethod
133
+ def from_dict(cls, d: JSON) -> Member:
134
+ return cls(
135
+ guild_id=str(d.get("guild_id", "")),
136
+ user=User.from_dict(d["user"]),
137
+ nick=d.get("nick"),
138
+ is_admin=bool(d.get("is_admin", False)),
139
+ joined_at=int(d.get("joined_at") or 0),
140
+ roles=[str(r) for r in d.get("roles") or []],
141
+ raw=d,
142
+ )
143
+
144
+ @property
145
+ def name(self) -> str:
146
+ """The nickname, or the account's display name."""
147
+ return self.nick or self.user.display_name
148
+
149
+
150
+ @dataclass(slots=True)
151
+ class Role:
152
+ id: str
153
+ guild_id: str
154
+ name: str
155
+ color: int | None
156
+ position: int
157
+ permissions: int
158
+ hoist: bool
159
+ raw: JSON = field(default_factory=dict, repr=False, compare=False)
160
+
161
+ @classmethod
162
+ def from_dict(cls, d: JSON) -> Role:
163
+ return cls(
164
+ id=str(d["id"]),
165
+ guild_id=str(d.get("guild_id", "")),
166
+ name=d.get("name", ""),
167
+ color=d.get("color"),
168
+ position=int(d.get("position") or 0),
169
+ permissions=int(d.get("permissions") or 0),
170
+ hoist=bool(d.get("hoist", False)),
171
+ raw=d,
172
+ )
173
+
174
+
175
+ @dataclass(slots=True)
176
+ class Emoji:
177
+ id: str
178
+ guild_id: str
179
+ name: str
180
+ url: str
181
+ animated: bool
182
+ raw: JSON = field(default_factory=dict, repr=False, compare=False)
183
+
184
+ @classmethod
185
+ def from_dict(cls, d: JSON) -> Emoji:
186
+ return cls(
187
+ id=str(d["id"]),
188
+ guild_id=str(d.get("guild_id", "")),
189
+ name=d.get("name", ""),
190
+ url=d.get("url", ""),
191
+ animated=bool(d.get("animated", False)),
192
+ raw=d,
193
+ )
194
+
195
+
196
+ @dataclass(slots=True)
197
+ class Attachment:
198
+ id: str
199
+ filename: str
200
+ content_type: str
201
+ size: int
202
+ #: ``/api/attachments/{id}``: needs the token to download.
203
+ url: str
204
+ raw: JSON = field(default_factory=dict, repr=False, compare=False)
205
+
206
+ @classmethod
207
+ def from_dict(cls, d: JSON) -> Attachment:
208
+ return cls(
209
+ id=str(d["id"]),
210
+ filename=d.get("filename", ""),
211
+ content_type=d.get("content_type", ""),
212
+ size=int(d.get("size") or 0),
213
+ url=d.get("url", ""),
214
+ raw=d,
215
+ )
216
+
217
+
218
+ @dataclass(slots=True)
219
+ class VoiceState:
220
+ user_id: str
221
+ guild_id: str | None
222
+ #: None when the account left voice.
223
+ channel_id: str | None
224
+ self_mute: bool = False
225
+ self_deaf: bool = False
226
+ self_stream: bool = False
227
+ self_video: bool = False
228
+ session_id: str = ""
229
+ raw: JSON = field(default_factory=dict, repr=False, compare=False)
230
+
231
+ @classmethod
232
+ def from_dict(cls, d: JSON) -> VoiceState:
233
+ return cls(
234
+ user_id=str(d["user_id"]),
235
+ guild_id=d.get("guild_id"),
236
+ channel_id=d.get("channel_id"),
237
+ self_mute=bool(d.get("self_mute", False)),
238
+ self_deaf=bool(d.get("self_deaf", False)),
239
+ self_stream=bool(d.get("self_stream", False)),
240
+ self_video=bool(d.get("self_video", False)),
241
+ session_id=d.get("session_id") or "",
242
+ raw=d,
243
+ )
244
+
245
+
246
+ @dataclass(slots=True)
247
+ class Guild:
248
+ """A guild (servidor) as the client keeps it, updated by the gateway's events."""
249
+
250
+ id: str
251
+ name: str
252
+ icon: str | None
253
+ owner_id: str
254
+ channels: dict[str, Channel] = field(default_factory=dict)
255
+ #: By user id.
256
+ members: dict[str, Member] = field(default_factory=dict)
257
+ roles: dict[str, Role] = field(default_factory=dict)
258
+ emojis: list[Emoji] = field(default_factory=list)
259
+ #: Ids of members online now (needs the PRESENCES intent to stay current).
260
+ online: set[str] = field(default_factory=set)
261
+ raw: JSON = field(default_factory=dict, repr=False, compare=False)
262
+
263
+ @classmethod
264
+ def from_dict(cls, d: JSON) -> Guild:
265
+ channels = [Channel.from_dict(c) for c in d.get("channels") or []]
266
+ members = [Member.from_dict(m) for m in d.get("members") or []]
267
+ roles = [Role.from_dict(r) for r in d.get("roles") or []]
268
+ return cls(
269
+ id=str(d["id"]),
270
+ name=d.get("name", ""),
271
+ icon=d.get("icon"),
272
+ owner_id=str(d.get("owner_id", "")),
273
+ channels={c.id: c for c in channels},
274
+ members={m.user.id: m for m in members},
275
+ roles={r.id: r for r in roles},
276
+ emojis=[Emoji.from_dict(e) for e in d.get("emojis") or []],
277
+ online={str(p) for p in d.get("presences") or []},
278
+ raw=d,
279
+ )
280
+
281
+
282
+ # ---------- what a bot sends ----------
283
+
284
+
285
+ class _CardAuthor(TypedDict):
286
+ name: str
287
+
288
+
289
+ class CardAuthor(_CardAuthor, total=False):
290
+ url: str
291
+ icon_url: str
292
+
293
+
294
+ class _CardField(TypedDict):
295
+ name: str
296
+ value: str
297
+
298
+
299
+ class CardField(_CardField, total=False):
300
+ inline: bool
301
+
302
+
303
+ class CardImage(TypedDict):
304
+ url: str
305
+
306
+
307
+ class _CardFooter(TypedDict):
308
+ text: str
309
+
310
+
311
+ class CardFooter(_CardFooter, total=False):
312
+ icon_url: str
313
+
314
+
315
+ class Card(TypedDict, total=False):
316
+ """A bot's card: a title, text, fields and pictures with a colored edge."""
317
+
318
+ title: str
319
+ #: Markdown, like a message.
320
+ description: str
321
+ #: Where the title points.
322
+ url: str
323
+ #: The edge's color, 0xRRGGBB.
324
+ color: int
325
+ author: CardAuthor
326
+ fields: list[CardField]
327
+ #: Wide, under the text.
328
+ image: CardImage
329
+ #: Small, beside the text.
330
+ thumbnail: CardImage
331
+ footer: CardFooter
332
+ #: Shown in the footer, in ms.
333
+ timestamp: int
334
+
335
+
336
+ ButtonStyle = Literal["primary", "secondary", "success", "danger", "link"]
337
+
338
+
339
+ class _Button(TypedDict):
340
+ style: ButtonStyle
341
+
342
+
343
+ class Button(_Button, total=False):
344
+ label: str
345
+ emoji: str
346
+ #: What the bot gets back when it is clicked (all but links).
347
+ custom_id: str
348
+ #: Link buttons only.
349
+ url: str
350
+ disabled: bool
351
+
352
+
353
+ #: Rows of buttons, top to bottom: up to 5 rows of 5.
354
+ Components = list[list[Button]]
355
+
356
+ OptionType = Literal["string", "integer", "number", "boolean", "user", "channel", "role"]
357
+
358
+
359
+ class CommandChoice(TypedDict):
360
+ name: str
361
+ value: str | int | float
362
+
363
+
364
+ class _CommandOption(TypedDict):
365
+ name: str
366
+ description: str
367
+ type: OptionType
368
+
369
+
370
+ class CommandOption(_CommandOption, total=False):
371
+ required: bool
372
+ #: The only values allowed.
373
+ choices: list[CommandChoice]
374
+ min_value: float
375
+ max_value: float
376
+
377
+
378
+ class _Command(TypedDict):
379
+ #: 1 to 32 lowercase letters, digits, _ or -.
380
+ name: str
381
+ description: str
382
+
383
+
384
+ class Command(_Command, total=False):
385
+ """A "/" command (docs.zynko.org/bots/comandos)."""
386
+
387
+ options: list[CommandOption]
zynko/permissions.py ADDED
@@ -0,0 +1,52 @@
1
+ """What someone may do in a channel, computed the way the server does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .bits import ALL_PERMISSIONS, Permissions
6
+ from .models import Channel, Guild
7
+
8
+ __all__ = ["compute_permissions"]
9
+
10
+
11
+ def compute_permissions(guild: Guild | None, channel: Channel | None, user_id: str) -> Permissions:
12
+ """@everyone and the member's roles, then the channel's overwrites.
13
+
14
+ Owners and admins may do everything. Without VIEW_CHANNEL nothing is left; without
15
+ SEND_MESSAGES no ATTACH_FILES; without CONNECT no SPEAK or STREAM.
16
+ """
17
+ P = Permissions
18
+ if channel is None or guild is None:
19
+ return P(0)
20
+ member = guild.members.get(user_id)
21
+ if member is None:
22
+ return P(0)
23
+ if guild.owner_id == user_id or member.is_admin:
24
+ return P(ALL_PERMISSIONS)
25
+ everyone_role = guild.roles.get(guild.id)
26
+ base = everyone_role.permissions if everyone_role else 0
27
+ for r in member.roles:
28
+ role = guild.roles.get(r)
29
+ base |= role.permissions if role else 0
30
+ if base & P.ADMINISTRATOR:
31
+ return P(ALL_PERMISSIONS)
32
+ overwrites = channel.permission_overwrites
33
+ p = base
34
+ everyone = next((o for o in overwrites if o.type == 0 and o.id == guild.id), None)
35
+ if everyone is not None:
36
+ p = (p & ~everyone.deny) | everyone.allow
37
+ allow = deny = 0
38
+ for o in overwrites:
39
+ if o.type == 0 and o.id != guild.id and o.id in member.roles:
40
+ allow |= o.allow
41
+ deny |= o.deny
42
+ p = (p & ~deny) | allow
43
+ own = next((o for o in overwrites if o.type == 1 and o.id == user_id), None)
44
+ if own is not None:
45
+ p = (p & ~own.deny) | own.allow
46
+ if not p & P.VIEW_CHANNEL:
47
+ return P(0)
48
+ if not p & P.SEND_MESSAGES:
49
+ p &= ~P.ATTACH_FILES
50
+ if not p & P.CONNECT:
51
+ p &= ~(P.SPEAK | P.STREAM)
52
+ return P(p)