tlgr-cli 2.0.1__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.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """Every operation module is discovered here, and the registry is linted.
2
+
3
+ Importing this package is what populates `tlgr.registry.REGISTRY`. The modules
4
+ are found by walking the package directory rather than named in a list: the
5
+ list was a second place to forget a group, and every group PR that added one
6
+ collided with every other PR on the same three lines. Discovery is
7
+ deterministic — the names are sorted, so the registry is built in the same
8
+ order in every checkout — and `_`-prefixed modules are skipped because they
9
+ hold the plumbing (`_spec`, `_send`, …), not specs.
10
+
11
+ The lint runs at the end, so a malformed spec fails the import rather than
12
+ surfacing later as a missing command or a broken doc.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ import pkgutil
19
+ from types import ModuleType
20
+
21
+ from tlgr.ops._spec import OpContext, OperationSpec, PageKind, Surface
22
+ from tlgr.registry import REGISTRY, canonical, get, lint_or_raise, register
23
+
24
+ __all__ = [
25
+ "REGISTRY",
26
+ "OpContext",
27
+ "OperationSpec",
28
+ "PageKind",
29
+ "Surface",
30
+ "canonical",
31
+ "get",
32
+ "op_module_names",
33
+ "register",
34
+ ]
35
+
36
+
37
+ def op_module_names() -> tuple[str, ...]:
38
+ """The operation modules in this package, in registration order.
39
+
40
+ Sorted, so two checkouts register the same specs in the same order and the
41
+ generated docs do not depend on a directory's own ordering.
42
+ """
43
+ return tuple(
44
+ sorted(
45
+ info.name
46
+ for info in pkgutil.iter_modules(__path__)
47
+ if not info.name.startswith("_") and not info.ispkg
48
+ )
49
+ )
50
+
51
+
52
+ def _op_modules() -> tuple[ModuleType, ...]:
53
+ return tuple(importlib.import_module(f"{__name__}.{name}") for name in op_module_names())
54
+
55
+
56
+ for _module in _op_modules():
57
+ for _name in dir(_module):
58
+ if _name.startswith("SPEC_"):
59
+ _spec = getattr(_module, _name)
60
+ if isinstance(_spec, OperationSpec) and _spec.id not in REGISTRY:
61
+ register(_spec)
62
+
63
+ lint_or_raise()
tlgr/ops/_admin.py ADDED
@@ -0,0 +1,313 @@
1
+ """Plumbing the `chat member/admin/invite/topic/setting/stats` modules share.
2
+
3
+ Six things every group-administration operation needs and none of them should
4
+ own: the client off the context, the page window, an `InputChannel` with a
5
+ readable error when the peer is not one, the `channelFull`/`chatFull` fetch,
6
+ the `affectedHistory` drain loop, and the participant serialiser.
7
+
8
+ The serialiser is the load-bearing one. v1 flattened a `ChannelParticipant*`
9
+ into a plain user and lost `status`, `rank`, `date`, `inviter_id`,
10
+ `promoted_by`, `kicked_by` and both rights masks in the process — which is
11
+ why v1 could list members but could not answer "is this person banned or
12
+ merely restricted".
13
+
14
+ Telethon is imported inside functions, never at module scope (§2.2).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from tlgr.core.errors import NotFoundError, UsageError
22
+ from tlgr.core.pagination import PageKind, decode_cursor
23
+ from tlgr.core.timefmt import fmt_dt, to_unix
24
+ from tlgr.models.admin import Participant
25
+ from tlgr.models.peer import Peer, Rights
26
+ from tlgr.ops import _rights
27
+ from tlgr.ops._serialize import entity_to_peer, peer_id_of
28
+ from tlgr.ops._spec import OpContext
29
+
30
+ __all__ = [
31
+ "GENERAL_TOPIC",
32
+ "affected_loop",
33
+ "already",
34
+ "client",
35
+ "display_name",
36
+ "entity_id",
37
+ "entity_map",
38
+ "full_chat",
39
+ "input_channel",
40
+ "input_user",
41
+ "is_channel",
42
+ "is_small_chat",
43
+ "participant_model",
44
+ "small_chat_id",
45
+ "window",
46
+ ]
47
+
48
+ #: The General topic. It always exists, cannot be deleted, and is the one id
49
+ #: `top_msg_id` must be *omitted* for.
50
+ GENERAL_TOPIC = 1
51
+
52
+
53
+ def client(ctx: OpContext) -> Any:
54
+ handle = getattr(ctx, "client", None)
55
+ if handle is None: # pragma: no cover - the daemon always supplies one
56
+ raise UsageError("this operation needs a connected account")
57
+ return handle
58
+
59
+
60
+ def already(ctx: OpContext) -> None:
61
+ """Flag `meta.already`: the world already looked the way we were asked for."""
62
+ mark = getattr(ctx, "mark_already", None)
63
+ if callable(mark):
64
+ mark()
65
+
66
+
67
+ def window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]:
68
+ """`(limit, cursor state)` — `--limit`/`--cursor` are transport-level (L5)."""
69
+ limit = int(getattr(ctx, "limit", None) or default)
70
+ if limit < 1:
71
+ raise UsageError("--limit must be at least 1", field="limit")
72
+ token = getattr(ctx, "cursor", None)
73
+ state: dict[str, Any] = {}
74
+ if token:
75
+ state = decode_cursor(token, op=op, kind=kind, account=ctx.account)
76
+ return min(limit, 200), state
77
+
78
+
79
+ def is_channel(peer: Any) -> bool:
80
+ return type(peer).__name__ in ("InputPeerChannel", "InputPeerChannelFromMessage")
81
+
82
+
83
+ def is_small_chat(peer: Any) -> bool:
84
+ """A legacy basic group — the shape with no granular rights at all."""
85
+ return type(peer).__name__ == "InputPeerChat"
86
+
87
+
88
+ def small_chat_id(peer: Any) -> int:
89
+ return int(getattr(peer, "chat_id", 0) or 0)
90
+
91
+
92
+ def input_channel(peer: Any) -> Any:
93
+ """The `InputChannel` a `channels.*` request wants, or a usage error."""
94
+ from telethon import utils
95
+
96
+ try:
97
+ return utils.get_input_channel(peer)
98
+ except (TypeError, ValueError) as exc:
99
+ raise UsageError(
100
+ "this operation only works in a channel or supergroup; "
101
+ "convert a basic group with `tlgr chat convert <chat> supergroup`",
102
+ field="chat",
103
+ ) from exc
104
+
105
+
106
+ def input_user(peer: Any) -> Any:
107
+ """The `InputUser` a `*.editAdmin`-style request wants."""
108
+ from telethon import utils
109
+
110
+ try:
111
+ return utils.get_input_user(peer)
112
+ except (TypeError, ValueError) as exc:
113
+ raise UsageError("that reference does not name a user", field="user") from exc
114
+
115
+
116
+ async def affected_loop(ctx: OpContext, make_request: Any) -> int:
117
+ """Drive an `affectedHistory` call until `offset == 0`.
118
+
119
+ The server answers a large history with a partial result and an offset to
120
+ resume from. Calling once and reporting success is how "delete everything
121
+ this member sent" deletes the first hundred messages.
122
+ """
123
+ handle = client(ctx)
124
+ total = 0
125
+ offset = 0
126
+ for _ in range(100):
127
+ result = await handle(make_request(offset))
128
+ total += int(getattr(result, "pts_count", 0) or 0)
129
+ offset = int(getattr(result, "offset", 0) or 0)
130
+ if offset == 0:
131
+ break
132
+ limiter = getattr(ctx, "limiter", None)
133
+ if limiter is not None:
134
+ await limiter.acquire("bulk")
135
+ return total
136
+
137
+
138
+ def entity_map(result: Any) -> dict[int, Any]:
139
+ """`{marked id: entity}` for the chats and users a reply carried."""
140
+ from telethon import utils
141
+
142
+ out: dict[int, Any] = {}
143
+ for entity in list(getattr(result, "chats", None) or []) + list(
144
+ getattr(result, "users", None) or []
145
+ ):
146
+ try:
147
+ out[int(utils.get_peer_id(entity))] = entity
148
+ except (TypeError, ValueError): # pragma: no cover - defensive
149
+ continue
150
+ return out
151
+
152
+
153
+ async def full_chat(ctx: OpContext, peer: Any) -> tuple[Any, Any, dict[int, Any]]:
154
+ """`(full, entity, entities)` for a group or channel of any shape.
155
+
156
+ Basic groups and channels answer with different requests and different
157
+ field names; every caller here wants the same three things, so the split
158
+ happens once.
159
+ """
160
+ from telethon.tl.functions import channels as chan_fn
161
+ from telethon.tl.functions import messages as msg_fn
162
+
163
+ handle = client(ctx)
164
+ if is_channel(peer):
165
+ result = await handle(chan_fn.GetFullChannelRequest(channel=input_channel(peer)))
166
+ elif is_small_chat(peer):
167
+ result = await handle(msg_fn.GetFullChatRequest(chat_id=small_chat_id(peer)))
168
+ else:
169
+ raise UsageError("this operation only works in a group or channel", field="chat")
170
+ full = getattr(result, "full_chat", None)
171
+ chat_id = peer_id_of(peer) or 0
172
+ entities = entity_map(result)
173
+ entity = entities.get(chat_id)
174
+ if entity is None:
175
+ chats = list(getattr(result, "chats", None) or [])
176
+ entity = chats[0] if chats else None
177
+ return full, entity, entities
178
+
179
+
180
+ def entity_id(entity: Any) -> int:
181
+ """The marked id of an *entity* (`Chat`/`Channel`/`User`), or 0.
182
+
183
+ Not the same call as `_serialize.peer_id_of`, which reads a `Peer*`/
184
+ `InputPeer*` and answers None for an entity — an entity carries `id`,
185
+ not `channel_id`, so asking the wrong one of the two silently yields 0
186
+ for every row.
187
+ """
188
+ from telethon import utils
189
+
190
+ if entity is None:
191
+ return 0
192
+ try:
193
+ return int(utils.get_peer_id(entity))
194
+ except (TypeError, ValueError):
195
+ return 0
196
+
197
+
198
+ def display_name(entity: Any) -> str:
199
+ if entity is None:
200
+ return ""
201
+ title = getattr(entity, "title", None)
202
+ if title:
203
+ return str(title)
204
+ parts = [getattr(entity, "first_name", None), getattr(entity, "last_name", None)]
205
+ return " ".join(part for part in parts if part).strip()
206
+
207
+
208
+ _STATUS = {
209
+ "ChannelParticipantCreator": "creator",
210
+ "ChatParticipantCreator": "creator",
211
+ "ChannelParticipantAdmin": "admin",
212
+ "ChatParticipantAdmin": "admin",
213
+ "ChannelParticipantSelf": "self",
214
+ "ChannelParticipantLeft": "left",
215
+ "ChannelParticipant": "member",
216
+ "ChatParticipant": "member",
217
+ }
218
+
219
+
220
+ def _participant_id(raw: Any) -> int:
221
+ user_id = getattr(raw, "user_id", None)
222
+ if isinstance(user_id, int):
223
+ return user_id
224
+ peer = getattr(raw, "peer", None)
225
+ if peer is not None:
226
+ return abs(peer_id_of(peer) or 0)
227
+ return 0
228
+
229
+
230
+ def effective_rights(default_banned: Rights | None, member: Rights | None) -> Rights | None:
231
+ """The chat defaults, patched with one member's own mask.
232
+
233
+ A member's `banned_rights` only names what was taken away *from them*;
234
+ everything else falls back to the chat default. Reporting the per-user
235
+ mask alone answers "what did the admin type", not "what may this person
236
+ actually do", which is the question anybody moderating is asking.
237
+ """
238
+ if default_banned is None and member is None:
239
+ return None
240
+ out = Rights()
241
+ for name in _rights.MEMBER_MASK:
242
+ flag = name.replace("-", "_")
243
+ flag = {"send_rounds": "send_roundvideos"}.get(flag, flag)
244
+ if not hasattr(out, flag):
245
+ # A layer-229 right the model has no field for. `chat permission
246
+ # list` names it as unsupported; inventing a field here would
247
+ # report a permission tlgr cannot actually read.
248
+ continue
249
+ value: bool | None = None
250
+ if default_banned is not None:
251
+ value = getattr(default_banned, flag, None)
252
+ if member is not None:
253
+ own = getattr(member, flag, None)
254
+ if own is False:
255
+ value = False
256
+ elif own is True and value is None:
257
+ value = True
258
+ setattr(out, flag, value)
259
+ return out
260
+
261
+
262
+ def participant_model(
263
+ raw: Any,
264
+ *,
265
+ chat_id: int = 0,
266
+ entities: dict[int, Any] | None = None,
267
+ default_banned: Rights | None = None,
268
+ ) -> Participant:
269
+ """One `ChannelParticipant*`/`ChatParticipant*` as a `Participant`."""
270
+ kind = type(raw).__name__
271
+ user_id = _participant_id(raw)
272
+ entity = (entities or {}).get(user_id)
273
+ status = _STATUS.get(kind, "member")
274
+ if kind == "ChannelParticipantBanned":
275
+ status = "banned" if getattr(raw, "left", False) else "restricted"
276
+
277
+ admin = _rights.model_from_admin(getattr(raw, "admin_rights", None))
278
+ banned = _rights.model_from_banned(getattr(raw, "banned_rights", None))
279
+ date = getattr(raw, "date", None)
280
+ subscription = getattr(raw, "subscription_until_date", None)
281
+
282
+ return Participant(
283
+ id=user_id,
284
+ user_id=user_id,
285
+ chat_id=chat_id,
286
+ peer=entity_to_peer(entity) if entity is not None else None,
287
+ username=getattr(entity, "username", None),
288
+ name=display_name(entity),
289
+ is_bot=bool(getattr(entity, "bot", False)),
290
+ status=status,
291
+ rank=getattr(raw, "rank", None),
292
+ date=fmt_dt(date),
293
+ date_unix=to_unix(date),
294
+ inviter_id=getattr(raw, "inviter_id", None),
295
+ promoted_by=getattr(raw, "promoted_by", None),
296
+ kicked_by=getattr(raw, "kicked_by", None),
297
+ via_request=getattr(raw, "via_request", None),
298
+ subscription_until_date=fmt_dt(subscription),
299
+ admin_rights=admin,
300
+ banned_rights=banned,
301
+ effective_permissions=effective_rights(default_banned, banned),
302
+ can_edit=getattr(raw, "can_edit", None),
303
+ )
304
+
305
+
306
+ def peer_row(entity: Any) -> Peer:
307
+ return entity_to_peer(entity)
308
+
309
+
310
+ def require_found(value: Any, message: str) -> Any:
311
+ if value is None:
312
+ raise NotFoundError(message)
313
+ return value