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 +67 -0
- jevmod/__main__.py +50 -0
- jevmod/adapters/__init__.py +0 -0
- jevmod/adapters/discord_bot.py +324 -0
- jevmod/adapters/reddit_bot.py +91 -0
- jevmod/adapters/telegram_bot.py +206 -0
- jevmod/api/__init__.py +0 -0
- jevmod/api/demo.py +199 -0
- jevmod/api/server.py +185 -0
- jevmod/categories.json +69 -0
- jevmod/cli.py +97 -0
- jevmod/core/__init__.py +17 -0
- jevmod/core/policy.py +155 -0
- jevmod/core/service.py +117 -0
- jevmod/core/store.py +215 -0
- jevmod/judge.py +189 -0
- jevmod/keys.py +146 -0
- jevmod/mcp_server.py +76 -0
- jevmod-0.2.0.dist-info/METADATA +293 -0
- jevmod-0.2.0.dist-info/RECORD +24 -0
- jevmod-0.2.0.dist-info/WHEEL +5 -0
- jevmod-0.2.0.dist-info/entry_points.txt +2 -0
- jevmod-0.2.0.dist-info/licenses/LICENSE +21 -0
- jevmod-0.2.0.dist-info/top_level.txt +1 -0
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()
|