envsbot 2.0.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.
Files changed (184) hide show
  1. bot/__init__.py +0 -0
  2. bot/audit.py +29 -0
  3. bot/connection.py +82 -0
  4. bot/context.py +5 -0
  5. bot/dispatch.py +235 -0
  6. bot/lifecycle.py +814 -0
  7. bot/messages.py +293 -0
  8. bot/permissions.py +114 -0
  9. bot/room_state.py +145 -0
  10. bot/routing.py +74 -0
  11. config_sample.py +933 -0
  12. core_plugins/__init__.py +0 -0
  13. core_plugins/_admin.py +1070 -0
  14. core_plugins/_core.py +671 -0
  15. core_plugins/_reg_profile.py +418 -0
  16. core_plugins/audit.py +598 -0
  17. core_plugins/backups.py +435 -0
  18. core_plugins/config_cmd.py +619 -0
  19. core_plugins/doctor.py +1342 -0
  20. core_plugins/help.py +1319 -0
  21. core_plugins/outbox.py +146 -0
  22. core_plugins/plugins.py +449 -0
  23. core_plugins/presence.py +224 -0
  24. core_plugins/reports.py +184 -0
  25. core_plugins/rooms/__init__.py +17 -0
  26. core_plugins/rooms/commands.py +929 -0
  27. core_plugins/rooms/defaults.py +190 -0
  28. core_plugins/rooms/invites.py +617 -0
  29. core_plugins/rooms/lifecycle.py +481 -0
  30. core_plugins/rooms/permissions.py +58 -0
  31. core_plugins/rooms/presence.py +238 -0
  32. core_plugins/rooms/settings.py +143 -0
  33. core_plugins/rooms/state.py +353 -0
  34. core_plugins/tasks.py +222 -0
  35. core_plugins/usage.py +137 -0
  36. core_plugins/users/__init__.py +21 -0
  37. core_plugins/users/commands.py +743 -0
  38. core_plugins/users/formatting.py +71 -0
  39. core_plugins/users/lookup.py +56 -0
  40. core_plugins/users/permission_commands.py +261 -0
  41. core_plugins/users/permissions.py +377 -0
  42. core_plugins/users/roles.py +69 -0
  43. core_plugins/users/tracking.py +386 -0
  44. database/__init__.py +1 -0
  45. database/audit.py +255 -0
  46. database/command_usage.py +129 -0
  47. database/idlerpg.py +876 -0
  48. database/locking.py +51 -0
  49. database/manager.py +1040 -0
  50. database/message_cache.py +180 -0
  51. database/migrations/__init__.py +274 -0
  52. database/outbox.py +9 -0
  53. database/rooms.py +129 -0
  54. database/users.py +689 -0
  55. envsbot-2.0.0.dist-info/METADATA +640 -0
  56. envsbot-2.0.0.dist-info/RECORD +184 -0
  57. envsbot-2.0.0.dist-info/WHEEL +5 -0
  58. envsbot-2.0.0.dist-info/entry_points.txt +2 -0
  59. envsbot-2.0.0.dist-info/licenses/LICENSE +674 -0
  60. envsbot-2.0.0.dist-info/top_level.txt +8 -0
  61. envsbot.py +400 -0
  62. plugins/__init__.py +1 -0
  63. plugins/birthday_notify.py +827 -0
  64. plugins/dice.py +245 -0
  65. plugins/ducks.py +1375 -0
  66. plugins/idlerpg/__init__.py +17 -0
  67. plugins/idlerpg/commands.py +1436 -0
  68. plugins/idlerpg/config.py +116 -0
  69. plugins/idlerpg/constants.py +270 -0
  70. plugins/idlerpg/events.py +450 -0
  71. plugins/idlerpg/export.py +1401 -0
  72. plugins/idlerpg/formatting.py +262 -0
  73. plugins/idlerpg/handlers.py +366 -0
  74. plugins/idlerpg/items.py +465 -0
  75. plugins/idlerpg/leveling.py +279 -0
  76. plugins/idlerpg/map.py +241 -0
  77. plugins/idlerpg/quests.py +483 -0
  78. plugins/idlerpg/seasons.py +184 -0
  79. plugins/idlerpg/state.py +833 -0
  80. plugins/idlerpg/tasks.py +447 -0
  81. plugins/info.py +1173 -0
  82. plugins/karma.py +530 -0
  83. plugins/pin.py +1293 -0
  84. plugins/poll.py +1158 -0
  85. plugins/reminder/__init__.py +22 -0
  86. plugins/reminder/commands.py +449 -0
  87. plugins/reminder/config.py +13 -0
  88. plugins/reminder/events.py +113 -0
  89. plugins/reminder/formatting.py +124 -0
  90. plugins/reminder/lifecycle.py +117 -0
  91. plugins/reminder/parsing.py +384 -0
  92. plugins/reminder/runtime.py +13 -0
  93. plugins/reminder/store.py +140 -0
  94. plugins/reminder/tasks.py +308 -0
  95. plugins/rss/__init__.py +21 -0
  96. plugins/rss/command_support.py +458 -0
  97. plugins/rss/commands.py +973 -0
  98. plugins/rss/config.py +143 -0
  99. plugins/rss/fetch.py +506 -0
  100. plugins/rss/formatting.py +566 -0
  101. plugins/rss/lifecycle.py +118 -0
  102. plugins/rss/store.py +415 -0
  103. plugins/rss/subscriptions.py +451 -0
  104. plugins/rss/tasks.py +318 -0
  105. plugins/rss/templates.py +518 -0
  106. plugins/sed.py +726 -0
  107. plugins/tell.py +337 -0
  108. plugins/tools.py +806 -0
  109. plugins/translate.py +916 -0
  110. plugins/urlcheck.py +647 -0
  111. plugins/vcard/__init__.py +26 -0
  112. plugins/vcard/commands.py +511 -0
  113. plugins/vcard/config.py +24 -0
  114. plugins/vcard/fetch.py +151 -0
  115. plugins/vcard/fields.py +105 -0
  116. plugins/vcard/formatting.py +260 -0
  117. plugins/vcard/store.py +4 -0
  118. plugins/vcard/timezone.py +121 -0
  119. plugins/weather.py +499 -0
  120. plugins/xkcd.py +1004 -0
  121. plugins/xmpp.py +1292 -0
  122. utils/__init__.py +1 -0
  123. utils/admin_alerts.py +564 -0
  124. utils/admin_notify.py +56 -0
  125. utils/admin_reports.py +182 -0
  126. utils/audit.py +21 -0
  127. utils/backups.py +1011 -0
  128. utils/bundled/avatar.jpg +0 -0
  129. utils/bundled/init_chat_slang.csv +3358 -0
  130. utils/bundled_assets.py +39 -0
  131. utils/command.py +518 -0
  132. utils/command_context.py +22 -0
  133. utils/command_docs.py +925 -0
  134. utils/command_execution.py +205 -0
  135. utils/command_help.py +108 -0
  136. utils/command_metadata.py +88 -0
  137. utils/command_registry.py +180 -0
  138. utils/config/__init__.py +59 -0
  139. utils/config/defaults.py +58 -0
  140. utils/config/display.py +116 -0
  141. utils/config/errors.py +15 -0
  142. utils/config/loader.py +179 -0
  143. utils/config/logging_setup.py +77 -0
  144. utils/config/runtime.py +677 -0
  145. utils/config/spec.py +400 -0
  146. utils/config/validation.py +399 -0
  147. utils/database_cli.py +177 -0
  148. utils/deploy_systemd_values.py +118 -0
  149. utils/file_security.py +81 -0
  150. utils/formatting.py +171 -0
  151. utils/health.py +380 -0
  152. utils/http_fetch.py +428 -0
  153. utils/http_user_agent.py +30 -0
  154. utils/logging_helpers.py +26 -0
  155. utils/message_cache.py +574 -0
  156. utils/outbox.py +443 -0
  157. utils/performance.py +109 -0
  158. utils/permissions.py +91 -0
  159. utils/plugin_manager.py +1407 -0
  160. utils/plugin_manager_dependencies.py +62 -0
  161. utils/plugin_manager_diagnostics.py +63 -0
  162. utils/plugin_manager_discovery.py +77 -0
  163. utils/plugin_manager_lifecycle.py +48 -0
  164. utils/plugin_metadata.py +84 -0
  165. utils/preflight.py +267 -0
  166. utils/presence_manager.py +148 -0
  167. utils/python_source.py +45 -0
  168. utils/rate_limiter.py +256 -0
  169. utils/redaction.py +21 -0
  170. utils/release_state.py +56 -0
  171. utils/room_features.py +601 -0
  172. utils/runtime_paths.py +56 -0
  173. utils/runtime_watchdog.py +48 -0
  174. utils/systemd_deploy.py +220 -0
  175. utils/task_display.py +100 -0
  176. utils/task_supervisor.py +436 -0
  177. utils/time_utils.py +124 -0
  178. utils/tls_certificate.py +671 -0
  179. utils/updatecheck.py +197 -0
  180. utils/url_safety.py +272 -0
  181. utils/urlcheck_extraction.py +54 -0
  182. utils/version.py +20 -0
  183. utils/xmpp_notify.py +253 -0
  184. vcard_sample.py +30 -0
bot/__init__.py ADDED
File without changes
bot/audit.py ADDED
@@ -0,0 +1,29 @@
1
+ """Audit helper mixin for envsbot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from envs_xmpp_core.security import redact_named, redact_value
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ class AuditMixin:
14
+ """Best-effort audit event writer."""
15
+
16
+ async def audit(self, event: str, *, actor: Any = None, target: Any = None, details: dict[str, Any] | None = None) -> None:
17
+ """Write an audit event if the audit log is available."""
18
+ try:
19
+ audit_log = getattr(getattr(self, "db", None), "audit", None)
20
+ if audit_log is None:
21
+ return
22
+ await audit_log.append(
23
+ event,
24
+ actor=redact_named("actor", str(actor)) if actor is not None else None,
25
+ target=redact_named("target", str(target)) if target is not None else None,
26
+ details=redact_value(details or {}),
27
+ )
28
+ except Exception:
29
+ log.debug("[AUDIT] Failed to write audit event", exc_info=True)
bot/connection.py ADDED
@@ -0,0 +1,82 @@
1
+ """XMPP connection and JID compatibility facade for envsbot."""
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from envs_xmpp_core.xmpp.connection import (
10
+ connect_kwargs_from_mapping,
11
+ maybe_await,
12
+ )
13
+ from envs_xmpp_core.xmpp.connection import (
14
+ connection_target as _core_connection_target,
15
+ )
16
+ from envs_xmpp_core.xmpp.jid import (
17
+ boundjid_domain,
18
+ build_client_jid,
19
+ configured_jid_domain,
20
+ )
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ __all__ = [
25
+ "boundjid_domain",
26
+ "build_client_jid",
27
+ "configured_jid_domain",
28
+ "connect_kwargs",
29
+ "connect_signature_parameters",
30
+ "connect_xmpp",
31
+ "connection_target",
32
+ "get_configured_resource",
33
+ "session_is_ready",
34
+ ]
35
+
36
+
37
+ def get_configured_resource(config: Mapping[str, Any]) -> str | None:
38
+ resource = config.get("resource")
39
+ if resource is None:
40
+ return None
41
+ value = str(resource).strip()
42
+ return value or None
43
+
44
+
45
+ def connect_signature_parameters(connect_method: Any) -> Mapping[str, inspect.Parameter]:
46
+ """Return inspectable connect() parameters, preserving envsbot monkeypatch hooks."""
47
+ try:
48
+ return inspect.signature(connect_method).parameters
49
+ except (TypeError, ValueError):
50
+ return {}
51
+
52
+
53
+ def session_is_ready(xmpp: Any) -> bool:
54
+ marker = getattr(xmpp, "session_ready", None)
55
+ if marker is None:
56
+ return True
57
+ is_set = getattr(marker, "is_set", None)
58
+ return bool(is_set()) if callable(is_set) else True
59
+
60
+
61
+ def connect_kwargs(xmpp: Any, config: Mapping[str, Any]) -> dict[str, Any]:
62
+ return connect_kwargs_from_mapping(
63
+ xmpp,
64
+ config,
65
+ parameters=connect_signature_parameters(xmpp.connect),
66
+ )
67
+
68
+
69
+ def connection_target(kwargs: Mapping[str, Any], config: Mapping[str, Any]) -> tuple[object, object, str]:
70
+ return _core_connection_target(
71
+ kwargs,
72
+ fallback_host=configured_jid_domain(config) or "auto",
73
+ fallback_port="auto",
74
+ direct_tls=bool(config.get("direct_tls", False)),
75
+ )
76
+
77
+
78
+ async def connect_xmpp(xmpp: Any, config: Mapping[str, Any]) -> Any:
79
+ kwargs = connect_kwargs(xmpp, config)
80
+ host, port, mode = connection_target(kwargs, config)
81
+ log.info("[XMPP] event=connect target=%s:%s mode=%s", host, port, mode)
82
+ return await maybe_await(xmpp.connect(**kwargs))
bot/context.py ADDED
@@ -0,0 +1,5 @@
1
+ """Compatibility import for bot command context."""
2
+
3
+ from utils.command_context import CommandContext
4
+
5
+ __all__ = ["CommandContext"]
bot/dispatch.py ADDED
@@ -0,0 +1,235 @@
1
+ """Command dispatch and sender context resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import slixmpp
9
+ from envs_xmpp_core.xmpp.messaging import is_muc_private_message
10
+ from envs_xmpp_core.xmpp.occupants import find_occupant_by_nick
11
+
12
+ from bot.context import CommandContext
13
+ from bot.permissions import role_bypasses_rate_limit
14
+ from utils.command import Role, is_command_group
15
+ from utils.command_execution import CommandExecutionContext
16
+ from utils.permissions import can_execute_command
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ class CommandDispatchMixin:
22
+ """Message context resolution and unified command handling."""
23
+
24
+ presence: Any
25
+ get_user_role: Any
26
+ prefix: str
27
+ reply: Any
28
+ config: dict[str, Any]
29
+ rate_limiter: Any
30
+ command_executor: Any
31
+
32
+ def _joined_room_jids(self) -> set[str]:
33
+ """Return known joined room bare JIDs from runtime room state."""
34
+ rooms: set[str] = set()
35
+ try:
36
+ rooms.update(str(room) for room in getattr(self.presence, "joined_rooms", {}))
37
+ except Exception:
38
+ log.debug("[MUC] Could not inspect presence joined rooms", exc_info=True)
39
+ try:
40
+ from bot.room_state import JOINED_ROOMS
41
+
42
+ rooms.update(str(room) for room in JOINED_ROOMS)
43
+ except Exception:
44
+ log.debug("[MUC] Could not inspect plugin joined rooms", exc_info=True)
45
+ return rooms
46
+
47
+ def _is_muc_private_message(self, msg: Any) -> bool:
48
+ """Return True for a private message sent via a MUC occupant JID."""
49
+ try:
50
+ msg_type = msg.get("type", "chat")
51
+ from_jid = msg["from"]
52
+ room = str(from_jid.bare)
53
+ nick = getattr(from_jid, "resource", None)
54
+ except Exception:
55
+ return False
56
+ return is_muc_private_message(msg_type, room, nick, self._joined_room_jids())
57
+
58
+ def _get_message_room_and_nick(self, msg: Any) -> tuple[str | None, str | None]:
59
+ """Resolve room and nick from a message if possible."""
60
+ room = None
61
+ nick = None
62
+ try:
63
+ from_jid = msg["from"]
64
+ room = from_jid.bare
65
+ nick = msg.get("mucnick") or msg["from"].resource
66
+ except Exception as exc:
67
+ log.debug("[MUC] Could not resolve message room/nick: %s", exc)
68
+ return room, nick
69
+
70
+ def _lookup_muc_occupant_jid(self, room: str | None, nick: str | None) -> Any:
71
+ """Resolve a MUC occupant's real JID from live MUC state."""
72
+ muc = getattr(self, "plugin", {}).get("xep_0045", None)
73
+ if muc:
74
+ try:
75
+ jid = muc.get_jid_property(room, nick, "jid")
76
+ if jid:
77
+ return jid
78
+ except Exception:
79
+ log.debug("[BOT] Error getting JID from XEP-0045 state", exc_info=True)
80
+
81
+ try:
82
+ from bot.room_state import JOINED_ROOMS
83
+
84
+ room_data = JOINED_ROOMS.get(room, {}) or {} if room else {}
85
+ room_occupants = room_data.get("nicks", {}) or {}
86
+ occupant = find_occupant_by_nick(
87
+ room_occupants,
88
+ nick,
89
+ room=room or "",
90
+ )
91
+ if occupant is not None and occupant.jid:
92
+ # Preserve the historical full real-JID return value here;
93
+ # sender authorization still normalizes it to bare JID below.
94
+ source = room_occupants.get(occupant.nick, {}) or {}
95
+ return source.get("jid") or occupant.jid
96
+ except Exception:
97
+ log.debug("[BOT] Error getting JID from joined room state", exc_info=True)
98
+ return None
99
+
100
+ def _bare_jid_value(self, jid_value: Any) -> str:
101
+ """Return a best-effort bare JID string without leaking resources."""
102
+ bare = getattr(jid_value, "bare", None)
103
+ if bare:
104
+ return str(bare)
105
+ return str(jid_value).split("/", 1)[0]
106
+
107
+ def _resolve_sender_jid(self, msg: Any, sender_jid: Any, nick: str | None) -> tuple[str, str | None]:
108
+ """Resolve the real sender JID for room messages, with fallback."""
109
+ jid = None
110
+ room = None
111
+ msg_type = msg.get("type", "chat")
112
+
113
+ if msg_type in ("chat", "normal") and not self._is_muc_private_message(msg):
114
+ return self._bare_jid_value(sender_jid), None
115
+
116
+ try:
117
+ room, nick = self._get_message_room_and_nick(msg)
118
+ jid = self._lookup_muc_occupant_jid(room, nick)
119
+ except Exception:
120
+ log.debug("[BOT] Error getting JID from MUC context", exc_info=True)
121
+
122
+ if jid is None:
123
+ return self._bare_jid_value(sender_jid), room
124
+
125
+ try:
126
+ return str(slixmpp.JID(jid).bare), room
127
+ except Exception as exc:
128
+ log.warning("[BOT] Failed to parse resolved JID: %s", exc)
129
+ return str(sender_jid), room
130
+
131
+ def _command_error_message(self, user_role: Role, cmd_name: str, error: Exception) -> str:
132
+ """Preserve the existing public/internal error wording."""
133
+ if user_role in (Role.OWNER, Role.ADMIN):
134
+ return f"🔴 Command error: {error}"
135
+ return f"🔴 Command '{cmd_name}' failed due to internal error."
136
+
137
+ def _is_loaded_plugin_help_target(self, text: str) -> bool:
138
+ """Return whether *text* exactly names one loaded plugin."""
139
+ tokens = tuple(part.lower() for part in str(text).split() if part)
140
+ if len(tokens) != 1:
141
+ return False
142
+ try:
143
+ plugins = getattr(getattr(self, "bot_plugins", None), "plugins", {})
144
+ return any(str(name).lower() == tokens[0] for name in plugins)
145
+ except Exception:
146
+ log.debug("[COMMAND] Could not inspect loaded plugin names", exc_info=True)
147
+ return False
148
+
149
+ async def build_command_context(
150
+ self,
151
+ body: str,
152
+ sender_jid: Any,
153
+ nick: str | None,
154
+ msg: Any,
155
+ is_room: bool,
156
+ cmd_obj: Any,
157
+ args: list[str],
158
+ ) -> CommandContext:
159
+ """Resolve sender, room and role into a reusable command context."""
160
+ jid, room = self._resolve_sender_jid(msg, sender_jid, nick)
161
+ role = await self.get_user_role(jid, room)
162
+ return CommandContext(
163
+ command_name=cmd_obj.name,
164
+ sender_jid=jid,
165
+ nick=nick,
166
+ room=room,
167
+ is_room=is_room,
168
+ is_muc_pm=self._is_muc_private_message(msg),
169
+ role=role,
170
+ args=tuple(args),
171
+ raw_body=body,
172
+ )
173
+
174
+ async def handle_command(self, body: str | None, sender_jid: Any, nick: str | None, msg: Any, is_room: bool) -> None:
175
+ """Parse and execute a bot command from a message."""
176
+ if not body:
177
+ return
178
+ if not body.startswith(self.prefix):
179
+ return
180
+ if not getattr(self, "accepting_commands", True):
181
+ self.reply(msg, "🔴 Bot is shutting down; please retry shortly.")
182
+ return
183
+
184
+ text = body[len(self.prefix):].strip()
185
+ if not text:
186
+ return
187
+
188
+ import envsbot as app
189
+ cmd_obj, args = app.resolve_command(text)
190
+ if not cmd_obj and (
191
+ is_command_group(text) or self._is_loaded_plugin_help_target(text)
192
+ ):
193
+ cmd_obj, _ = app.resolve_command("help")
194
+ args = text.split()
195
+ if not cmd_obj:
196
+ return
197
+
198
+ context = await self.build_command_context(body, sender_jid, nick, msg, is_room, cmd_obj, args)
199
+
200
+ if self.config.get("command_rate_limit_enabled", True) and not role_bypasses_rate_limit(context.role, self.config):
201
+ allowed, retry_after = await self.rate_limiter.allow(context.sender_jid)
202
+ if not allowed:
203
+ if self.rate_limiter.notify_allowed(context.sender_jid):
204
+ log.info(
205
+ "[COMMAND] event=rate_limited actor=%s room=%s retry_after=%.1fs",
206
+ context.sender_jid,
207
+ context.room,
208
+ retry_after,
209
+ )
210
+ return
211
+
212
+ from utils.permissions import configured_room_invite_admin_rooms
213
+
214
+ invite_admin_rooms = configured_room_invite_admin_rooms(self.config)
215
+
216
+ decision = can_execute_command(
217
+ context,
218
+ cmd_obj,
219
+ room_invite_admin_rooms=invite_admin_rooms,
220
+ permission_checker=app.check_permission,
221
+ )
222
+ if not decision.allowed:
223
+ self.reply(msg, decision.reason)
224
+ return
225
+
226
+ execution_context = CommandExecutionContext(
227
+ command_name=context.command_name,
228
+ sender_jid=context.sender_jid,
229
+ nick=context.nick,
230
+ room=context.room,
231
+ is_room=context.is_room,
232
+ role=context.role,
233
+ args=context.args,
234
+ )
235
+ await self.command_executor.execute(cmd_obj, execution_context, msg)