jevmod 0.2.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.
jevmod/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """jevmod: moderation for communities and apps, powered by Jev.
2
+
3
+ Developer API, three lines:
4
+
5
+ from jevmod import Moderator
6
+ mod = Moderator() # TYPESAFE_API_KEY in the environment
7
+ d = mod.check("FREE NITRO click discord-gifts.ru") # -> Decision(action="flag", category="scam", probability=0.97)
8
+
9
+ `check_many([...])` judges a batch in one request. Thresholds and actions come from a `Policy` you can pass in.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+
16
+ from .core import ACTIONS, Decision, ModerationService, Policy, Store, decide
17
+ from .judge import CATEGORIES, Judge, Message, Verdict
18
+
19
+ __all__ = [
20
+ "ACTIONS",
21
+ "CATEGORIES",
22
+ "Decision",
23
+ "Judge",
24
+ "Message",
25
+ "ModerationService",
26
+ "Moderator",
27
+ "Policy",
28
+ "Store",
29
+ "Verdict",
30
+ "decide",
31
+ ]
32
+
33
+ try:
34
+ from importlib.metadata import version as _v
35
+
36
+ __version__ = _v("jevmod")
37
+ except Exception: # pragma: no cover
38
+ __version__ = "0.0.0+local"
39
+
40
+
41
+ class Moderator:
42
+ """Stateless convenience wrapper for developers: no SQLite, no tenants, just judge + policy."""
43
+
44
+ def __init__(self, policy: Policy | None = None, judge: Judge | None = None) -> None:
45
+ self.policy = policy or Policy()
46
+ self.judge = judge or Judge()
47
+
48
+ def check(self, text: str, *, author: str = "", channel_topic: str = "", author_trusted: bool = False) -> Decision:
49
+ return self.check_many([text], author=author, channel_topic=channel_topic, author_trusted=author_trusted)[0]
50
+
51
+ def check_many(
52
+ self,
53
+ texts: Sequence[str],
54
+ *,
55
+ author: str = "",
56
+ channel_topic: str = "",
57
+ author_trusted: bool = False,
58
+ ids: Sequence[str] | None = None,
59
+ ) -> list[Decision]:
60
+ msgs = [
61
+ Message(
62
+ ids[i] if ids else str(i), t, author=author, channel_topic=channel_topic, author_trusted=author_trusted
63
+ )
64
+ for i, t in enumerate(texts)
65
+ ]
66
+ verdicts = self.judge.judge(msgs, self.policy.enabled_categories(), self.policy.rules)
67
+ return [decide(self.policy, v) for v in verdicts]
jevmod/__main__.py ADDED
@@ -0,0 +1,50 @@
1
+ """`python -m jevmod` / `jevmod`: `check` judges text from the terminal, `init` stores the key, `mcp` serves the
2
+ MCP tools over stdio; `api`, `discord`, `telegram`, `reddit` start that role (default role from JEVMOD_ROLE, then
3
+ `api`)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import sys
9
+
10
+
11
+ def run_role(role: str) -> None:
12
+ role = role.lower()
13
+ if role == "api":
14
+ import uvicorn
15
+
16
+ uvicorn.run("jevmod.api.server:app", host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), log_level="info")
17
+ elif role == "discord":
18
+ from .adapters.discord_bot import main as run
19
+
20
+ run()
21
+ elif role == "telegram":
22
+ from .adapters.telegram_bot import main as run
23
+
24
+ run()
25
+ elif role == "reddit":
26
+ from .adapters.reddit_bot import run
27
+
28
+ run()
29
+ elif role == "demo":
30
+ import uvicorn
31
+
32
+ uvicorn.run("jevmod.api.demo:app", host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), log_level="info")
33
+ elif role == "mcp":
34
+ from .mcp_server import main as run
35
+
36
+ run()
37
+ else:
38
+ raise SystemExit(f"unknown role {role!r}; use check | init | mcp | api | demo | discord | telegram | reddit")
39
+
40
+
41
+ def main() -> None:
42
+ if len(sys.argv) > 1:
43
+ from .cli import main as cli
44
+
45
+ sys.exit(cli(sys.argv[1:]))
46
+ run_role(os.environ.get("JEVMOD_ROLE", "api"))
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
File without changes
@@ -0,0 +1,324 @@
1
+ """Discord adapter on top of ModerationService.
2
+
3
+ Plug and play: invite it, it flags into a private #jevmod-log; tune with /mod.
4
+
5
+ DISCORD_TOKEN=... TYPESAFE_API_KEY=... python -m jevmod.adapters.discord_bot
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import logging
13
+ import os
14
+ from datetime import timedelta
15
+
16
+ import discord
17
+ from discord import app_commands
18
+
19
+ from ..core import FREE_MONTHLY, RULE_THRESHOLD, Batcher, Decision, ModerationService, Store
20
+ from ..judge import CATEGORIES, Message
21
+
22
+ log = logging.getLogger("jevmod.discord")
23
+
24
+ intents = discord.Intents.default()
25
+ intents.message_content = True # the only privileged intent we need
26
+ bot = discord.Client(intents=intents)
27
+ tree = app_commands.CommandTree(bot)
28
+ store = Store(os.environ.get("JEVMOD_DB", "jevmod.sqlite"))
29
+ service = ModerationService(store)
30
+
31
+
32
+ def tenant_of(guild_id: int) -> str:
33
+ return f"discord:{guild_id}"
34
+
35
+
36
+ async def handle_batch(tenant: str, batch: list[discord.Message]) -> None:
37
+ guild = batch[0].guild
38
+ if guild is None:
39
+ return
40
+ meta = store.get_meta(tenant)
41
+ trusted = set(meta.get("trusted_roles", []))
42
+ topics = meta.get("channel_topics", {})
43
+ msgs = [
44
+ Message(
45
+ id=str(m.id),
46
+ text=m.content,
47
+ author=str(m.author.id), # kept only in your local log for erasure requests; never sent to Jev
48
+ channel_topic=topics.get(str(m.channel.id), getattr(m.channel, "topic", "") or "general chat"),
49
+ author_trusted=_trusted(m.author, trusted),
50
+ )
51
+ for m in batch
52
+ ]
53
+ decisions = await asyncio.to_thread(service.moderate, tenant, msgs)
54
+ for m, d in zip(batch, decisions, strict=True):
55
+ if d.reason == "quota":
56
+ await _notify_quota_once(guild, tenant)
57
+ return
58
+ if d.action != "none":
59
+ await act(guild, m, d)
60
+
61
+
62
+ def _trusted(author: discord.User | discord.Member, trusted_roles: set[int]) -> bool:
63
+ if not isinstance(author, discord.Member):
64
+ return False
65
+ return author.guild_permissions.manage_messages or bool(trusted_roles & {r.id for r in author.roles})
66
+
67
+
68
+ batcher = Batcher(2.0, handle_batch)
69
+
70
+
71
+ @bot.event
72
+ async def on_ready() -> None:
73
+ await tree.sync()
74
+ log.info("discord ready as %s in %d guilds", bot.user, len(bot.guilds))
75
+
76
+
77
+ @bot.event
78
+ async def on_message(msg: discord.Message) -> None:
79
+ if msg.author.bot or not msg.guild or not msg.content:
80
+ return
81
+ tenant = tenant_of(msg.guild.id)
82
+ if not service.policy(tenant).active():
83
+ return
84
+ batcher.add(tenant, msg)
85
+
86
+
87
+ async def act(guild: discord.Guild, m: discord.Message, d: Decision) -> None:
88
+ tenant = tenant_of(guild.id)
89
+ policy = service.policy(tenant)
90
+ note = ""
91
+ try:
92
+ if d.action in ("delete", "timeout"):
93
+ await m.delete()
94
+ note = "deleted"
95
+ if d.action == "timeout" and isinstance(m.author, discord.Member):
96
+ await m.author.timeout(
97
+ timedelta(minutes=policy.timeout_minutes), reason=f"jevmod: {d.category} p={d.probability:.2f}"
98
+ )
99
+ note = f"deleted, timed out {policy.timeout_minutes} min"
100
+ except discord.Forbidden:
101
+ note = "missing permissions to act"
102
+ if d.action in ("delete", "timeout") and "missing" not in note:
103
+ with contextlib.suppress(Exception): # DMs closed
104
+ await m.author.send(
105
+ f"Your message in **{guild.name}** #{m.channel} was removed by an automated moderation system "
106
+ f"(reason: {d.category}, confidence {d.probability:.0%}). If you think this was a mistake, contact the "
107
+ "server's moderators; they can review the decision and adjust the rules."
108
+ )
109
+ channel = await log_channel(guild, tenant)
110
+ if channel:
111
+ top = " · ".join(f"{c} {p:.2f}" for c, p in sorted(d.scores.items(), key=lambda kv: -kv[1])[:3])
112
+ embed = discord.Embed(
113
+ title=f"{d.category} p={d.probability:.2f} → {d.action}" + (f" ({note})" if note else ""),
114
+ description=m.content[:500],
115
+ colour=0x2FBF83 if d.action == "flag" else 0xD9A441,
116
+ )
117
+ embed.add_field(name="author", value=m.author.mention, inline=True)
118
+ embed.add_field(name="channel", value=getattr(m.channel, "mention", str(m.channel)), inline=True)
119
+ embed.set_footer(text=f"{top} · ❌ false positive (raises threshold) · ✅ correct (lowers it a notch)")
120
+ sent = await channel.send(embed=embed)
121
+ await sent.add_reaction("❌")
122
+ await sent.add_reaction("✅")
123
+
124
+
125
+ async def log_channel(guild: discord.Guild, tenant: str) -> discord.TextChannel | None:
126
+ meta = store.get_meta(tenant)
127
+ if meta.get("log_channel"):
128
+ ch = guild.get_channel(int(meta["log_channel"]))
129
+ if isinstance(ch, discord.TextChannel):
130
+ return ch
131
+ existing = discord.utils.get(guild.text_channels, name="jevmod-log")
132
+ if existing:
133
+ store.set_meta(tenant, log_channel=existing.id)
134
+ return existing
135
+ try:
136
+ overwrites: dict[discord.Role | discord.Member | discord.Object, discord.PermissionOverwrite] = {
137
+ guild.default_role: discord.PermissionOverwrite(read_messages=False)
138
+ }
139
+ ch = await guild.create_text_channel("jevmod-log", overwrites=overwrites, reason="jevmod decisions log")
140
+ store.set_meta(tenant, log_channel=ch.id)
141
+ return ch
142
+ except discord.Forbidden:
143
+ return None
144
+
145
+
146
+ async def _notify_quota_once(guild: discord.Guild, tenant: str) -> None:
147
+ if not store.note_quota_hit(tenant):
148
+ return
149
+ ch = await log_channel(guild, tenant)
150
+ if ch:
151
+ await ch.send(
152
+ f"jevmod paused for this month: the monthly quota of {FREE_MONTHLY:,} judged messages was reached "
153
+ "(JEVMOD_MONTHLY_QUOTA). Messages are not being judged until next month. Nothing is deleted while paused."
154
+ )
155
+
156
+
157
+ @bot.event
158
+ async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None:
159
+ """❌ = false positive (threshold up), ✅ = confirmed (threshold down a notch, floor 0.5)."""
160
+ emoji = str(payload.emoji)
161
+ if emoji not in ("❌", "✅") or (bot.user and payload.user_id == bot.user.id) or not payload.guild_id:
162
+ return
163
+ guild = bot.get_guild(payload.guild_id)
164
+ if not guild:
165
+ return
166
+ tenant = tenant_of(guild.id)
167
+ meta = store.get_meta(tenant)
168
+ if str(payload.channel_id) != str(meta.get("log_channel")):
169
+ return
170
+ channel = guild.get_channel(payload.channel_id)
171
+ if not isinstance(channel, discord.TextChannel):
172
+ return
173
+ msg = await channel.fetch_message(payload.message_id)
174
+ if not msg.embeds or not msg.embeds[0].title:
175
+ return
176
+ category = msg.embeds[0].title.split()[0]
177
+ policy = service.policy(tenant)
178
+ if category in policy.thresholds or category.startswith("rule:"):
179
+ new = policy.nudge(category, 0.03 if emoji == "❌" else -0.02)
180
+ service.save_policy(tenant, policy)
181
+ await msg.reply(f"noted: threshold for **{category}** is now {new:.2f}", mention_author=False)
182
+
183
+
184
+ @bot.event
185
+ async def on_guild_remove(guild: discord.Guild) -> None:
186
+ """Kicked or left: forget everything about that server."""
187
+ store.delete_tenant(tenant_of(guild.id))
188
+ log.info("left guild %s; data deleted", guild.id)
189
+
190
+
191
+ # ------------------------------------------------------------------ /mod
192
+ mod = app_commands.Group(
193
+ name="mod", description="jevmod settings", default_permissions=discord.Permissions(manage_guild=True)
194
+ )
195
+
196
+
197
+ @mod.command(name="status", description="Settings and this month's usage")
198
+ async def status(itx: discord.Interaction) -> None:
199
+ tenant = tenant_of(itx.guild_id or 0)
200
+ p = service.policy(tenant)
201
+ judged, requests, tokens = store.usage(tenant)
202
+ lines = [f"**{c}**: {p.actions.get(c, 'off')} at p ≥ {p.thresholds.get(c, 0.9):.2f}" for c in CATEGORIES]
203
+ lines += [f'**rule {n}**: {p.rule_actions.get(n, "flag")} · "{r}"' for n, r in p.rules.items()]
204
+ plan = store.plan(tenant)
205
+ quota = (
206
+ f"{judged:,}/{FREE_MONTHLY:,} judged this month (quota)"
207
+ if plan == "free" and FREE_MONTHLY
208
+ else f"{judged:,} judged this month"
209
+ )
210
+ lines.append(f"\n{quota} · {requests} Jev requests · {tokens:,} tokens")
211
+ await itx.response.send_message("\n".join(lines), ephemeral=True)
212
+
213
+
214
+ @mod.command(name="set", description="Action and threshold for a category")
215
+ @app_commands.describe(
216
+ category="spam, scam, harassment, nsfw, offtopic", action="off, flag, delete, timeout", threshold="0.5 to 0.99"
217
+ )
218
+ async def set_cmd(itx: discord.Interaction, category: str, action: str, threshold: float | None = None) -> None:
219
+ tenant = tenant_of(itx.guild_id or 0)
220
+ p = service.policy(tenant)
221
+ try:
222
+ p.set_category(category, action, threshold)
223
+ except ValueError as exc:
224
+ await itx.response.send_message(str(exc), ephemeral=True)
225
+ return
226
+ service.save_policy(tenant, p)
227
+ await itx.response.send_message(f"**{category}** → {action} at p ≥ {p.thresholds[category]:.2f}", ephemeral=True)
228
+
229
+
230
+ @mod.command(name="rule", description="Add or remove a rule in plain language")
231
+ @app_commands.describe(
232
+ name="short name",
233
+ text="the rule as you would tell a member, exceptions included; empty to remove",
234
+ action="flag, delete, timeout",
235
+ threshold="0.5 to 0.99 (default 0.80)",
236
+ )
237
+ async def rule_cmd(
238
+ itx: discord.Interaction, name: str, text: str | None = None, action: str = "flag", threshold: float | None = None
239
+ ) -> None:
240
+ tenant = tenant_of(itx.guild_id or 0)
241
+ p = service.policy(tenant)
242
+ try:
243
+ p.set_rule(name, text, action, threshold)
244
+ except ValueError as exc:
245
+ await itx.response.send_message(str(exc), ephemeral=True)
246
+ return
247
+ service.save_policy(tenant, p)
248
+ key = name.strip().lower().replace(" ", "_")[:30]
249
+ if key in p.rules:
250
+ th = p.rule_thresholds.get(key, RULE_THRESHOLD)
251
+ await itx.response.send_message(
252
+ f'rule **{key}** → {p.rule_actions[key]} at p ≥ {th:.2f}: "{p.rules[key]}"', ephemeral=True
253
+ )
254
+ else:
255
+ await itx.response.send_message(f"rule **{key}** removed", ephemeral=True)
256
+
257
+
258
+ @mod.command(name="trust", description="Toggle a role whose messages are never judged")
259
+ async def trust_cmd(itx: discord.Interaction, role: discord.Role) -> None:
260
+ tenant = tenant_of(itx.guild_id or 0)
261
+ roles = list(store.get_meta(tenant).get("trusted_roles", []))
262
+ if role.id in roles:
263
+ roles.remove(role.id)
264
+ msg = f"{role.mention} is judged again"
265
+ else:
266
+ roles.append(role.id)
267
+ msg = f"{role.mention} is trusted: never judged"
268
+ store.set_meta(tenant, trusted_roles=roles)
269
+ await itx.response.send_message(msg, ephemeral=True)
270
+
271
+
272
+ @mod.command(name="topic", description="What this channel is for (used by the offtopic check)")
273
+ async def topic_cmd(itx: discord.Interaction, topic: str) -> None:
274
+ tenant = tenant_of(itx.guild_id or 0)
275
+ topics = dict(store.get_meta(tenant).get("channel_topics", {}))
276
+ topics[str(itx.channel_id)] = topic.strip()[:200]
277
+ store.set_meta(tenant, channel_topics=topics)
278
+ await itx.response.send_message(f"this channel is about: {topic}", ephemeral=True)
279
+
280
+
281
+ @mod.command(name="log", description="Log decisions in this channel")
282
+ async def log_cmd(itx: discord.Interaction) -> None:
283
+ store.set_meta(tenant_of(itx.guild_id or 0), log_channel=itx.channel_id)
284
+ await itx.response.send_message("decisions will be logged here", ephemeral=True)
285
+
286
+
287
+ @mod.command(name="recent", description="Last decisions with probabilities")
288
+ async def recent_cmd(itx: discord.Interaction) -> None:
289
+ rows = store.recent_decisions(tenant_of(itx.guild_id or 0), 10)
290
+ if not rows:
291
+ await itx.response.send_message("no decisions yet", ephemeral=True)
292
+ return
293
+ await itx.response.send_message(
294
+ "\n".join(f"`{r['category']} {r['p']:.2f} {r['action']}` {r['text'][:80]}" for r in rows), ephemeral=True
295
+ )
296
+
297
+
298
+ @mod.command(name="forget", description="Delete everything jevmod stored about this server (GDPR)")
299
+ async def forget_cmd(itx: discord.Interaction) -> None:
300
+ store.delete_tenant(tenant_of(itx.guild_id or 0))
301
+ await itx.response.send_message(
302
+ "all settings, usage and decision logs for this server were deleted", ephemeral=True
303
+ )
304
+
305
+
306
+ @mod.command(name="forget_user", description="Delete this member's entries from the decision log (erasure request)")
307
+ async def forget_user_cmd(itx: discord.Interaction, member: discord.Member) -> None:
308
+ n = store.delete_user(tenant_of(itx.guild_id or 0), str(member.id))
309
+ await itx.response.send_message(f"deleted {n} log entries for {member.mention}", ephemeral=True)
310
+
311
+
312
+ tree.add_command(mod)
313
+
314
+
315
+ def main() -> None:
316
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
317
+ token = os.environ.get("DISCORD_TOKEN")
318
+ if not token:
319
+ raise SystemExit("set DISCORD_TOKEN (Developer Portal → Bot → Reset Token)")
320
+ bot.run(token, log_handler=None)
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
@@ -0,0 +1,91 @@
1
+ """Reddit adapter over the official API (PRAW). Streams new comments and posts of the subreddits you moderate;
2
+ `flag` reports the item to the mod queue with the probabilities, `delete` removes it. Runs as a moderator account.
3
+
4
+ REDDIT_CLIENT_ID=... REDDIT_CLIENT_SECRET=... REDDIT_USERNAME=... REDDIT_PASSWORD=... REDDIT_SUBREDDITS=sub1,sub2
5
+ TYPESAFE_API_KEY=... python -m jevmod.adapters.reddit_bot
6
+
7
+ Reddit's API rules apply (OAuth, user agent, 100 requests/min for OAuth clients). This adapter reads the stream and
8
+ acts on items only; it never posts content.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import time
16
+
17
+ import praw
18
+
19
+ from ..core import ModerationService, Store
20
+ from ..judge import Message
21
+
22
+ log = logging.getLogger("jevmod.reddit")
23
+ store = Store(os.environ.get("JEVMOD_DB", "jevmod.sqlite"))
24
+ service = ModerationService(store)
25
+ BATCH_WINDOW_S = 5.0
26
+
27
+
28
+ def tenant_of(subreddit: str) -> str:
29
+ return f"reddit:{subreddit.lower()}"
30
+
31
+
32
+ def act(item, d) -> None:
33
+ top = ", ".join(f"{c} {p:.2f}" for c, p in sorted(d.scores.items(), key=lambda kv: -kv[1])[:3])
34
+ reason = f"jevmod: {d.category} p={d.probability:.2f} ({top})"[:100]
35
+ try:
36
+ if d.action == "flag":
37
+ item.report(reason)
38
+ elif d.action in ("delete", "timeout"):
39
+ item.mod.remove(mod_note=reason)
40
+ if d.action == "timeout" and getattr(item, "author", None):
41
+ item.subreddit.banned.add(item.author, duration=1, ban_reason=reason[:100], note="jevmod timeout")
42
+ except Exception as exc:
43
+ log.warning("cannot act on %s: %s", item.id, exc)
44
+
45
+
46
+ def run() -> None:
47
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
48
+ reddit = praw.Reddit(
49
+ client_id=os.environ["REDDIT_CLIENT_ID"],
50
+ client_secret=os.environ["REDDIT_CLIENT_SECRET"],
51
+ username=os.environ["REDDIT_USERNAME"],
52
+ password=os.environ["REDDIT_PASSWORD"],
53
+ user_agent=os.environ.get("REDDIT_USER_AGENT", "jevmod/0.2 moderation bot"),
54
+ )
55
+ subs = [s.strip() for s in os.environ["REDDIT_SUBREDDITS"].split(",") if s.strip()]
56
+ multi = reddit.subreddit("+".join(subs))
57
+ mods = {s: {m.name for m in reddit.subreddit(s).moderator()} for s in subs}
58
+ log.info("streaming %s", subs)
59
+ pending: dict[str, list] = {}
60
+ last_flush = time.time()
61
+ for item in multi.stream.comments(skip_existing=True, pause_after=0):
62
+ now = time.time()
63
+ if item is not None:
64
+ sub = item.subreddit.display_name
65
+ tenant = tenant_of(sub)
66
+ if service.policy(tenant).active():
67
+ pending.setdefault(tenant, []).append(item)
68
+ if now - last_flush >= BATCH_WINDOW_S and pending:
69
+ for tenant, items in pending.items():
70
+ sub = items[0].subreddit.display_name
71
+ msgs = [
72
+ Message(
73
+ id=i.id,
74
+ text=getattr(i, "body", None) or f"{getattr(i, 'title', '')}\n{getattr(i, 'selftext', '')}",
75
+ author=str(i.author) if i.author else "",
76
+ channel_topic=store.get_meta(tenant).get("topic", f"r/{sub}"),
77
+ author_trusted=bool(i.author and i.author.name in mods.get(sub, set())),
78
+ )
79
+ for i in items
80
+ ]
81
+ for i, d in zip(items, service.moderate(tenant, msgs), strict=True):
82
+ if d.action != "none":
83
+ act(i, d)
84
+ pending = {}
85
+ last_flush = now
86
+ if item is None:
87
+ time.sleep(1)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ run()