jevmod 0.2.0__tar.gz → 0.2.1__tar.gz

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 (45) hide show
  1. {jevmod-0.2.0 → jevmod-0.2.1}/PKG-INFO +10 -4
  2. {jevmod-0.2.0 → jevmod-0.2.1}/README.md +4 -2
  3. jevmod-0.2.1/jevmod/__main__.py +72 -0
  4. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/adapters/discord_bot.py +79 -21
  5. jevmod-0.2.1/jevmod/api/admin.html +57 -0
  6. jevmod-0.2.1/jevmod/api/billing.py +235 -0
  7. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/api/demo.py +26 -0
  8. jevmod-0.2.1/jevmod/api/hosted.py +14 -0
  9. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/categories.json +12 -2
  10. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/cli.py +10 -1
  11. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/core/__init__.py +2 -1
  12. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/core/policy.py +14 -1
  13. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/core/service.py +2 -1
  14. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/core/store.py +111 -4
  15. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/PKG-INFO +10 -4
  16. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/SOURCES.txt +5 -0
  17. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/requires.txt +4 -0
  18. {jevmod-0.2.0 → jevmod-0.2.1}/pyproject.toml +6 -4
  19. jevmod-0.2.1/tests/test_billing.py +123 -0
  20. jevmod-0.2.1/tests/test_discord_actions.py +66 -0
  21. jevmod-0.2.1/tests/test_offline.py +239 -0
  22. jevmod-0.2.0/jevmod/__main__.py +0 -50
  23. jevmod-0.2.0/tests/test_offline.py +0 -97
  24. {jevmod-0.2.0 → jevmod-0.2.1}/LICENSE +0 -0
  25. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/__init__.py +0 -0
  26. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/adapters/__init__.py +0 -0
  27. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/adapters/reddit_bot.py +0 -0
  28. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/adapters/telegram_bot.py +0 -0
  29. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/api/__init__.py +0 -0
  30. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/api/server.py +0 -0
  31. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/judge.py +0 -0
  32. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/keys.py +0 -0
  33. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/mcp_server.py +0 -0
  34. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/dependency_links.txt +0 -0
  35. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/entry_points.txt +0 -0
  36. {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/top_level.txt +0 -0
  37. {jevmod-0.2.0 → jevmod-0.2.1}/setup.cfg +0 -0
  38. {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_api.py +0 -0
  39. {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_cli.py +0 -0
  40. {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_demo.py +0 -0
  41. {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_examples.py +0 -0
  42. {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_judge.py +0 -0
  43. {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_keys.py +0 -0
  44. {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_mcp.py +0 -0
  45. {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_redteam.py +0 -0
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jevmod
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Moderation for communities and apps, powered by Jev: spam, scams, harassment, adult, off-topic and your own plain-language rules, with a probability per category and thresholds you own.
5
5
  Author: Omar Hernandez
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/ohernandezdev/jevmod
7
+ Project-URL: Homepage, https://jevmod.dev
8
+ Project-URL: Repository, https://github.com/ohernandezdev/jevmod
8
9
  Keywords: moderation,discord,telegram,reddit,content-moderation,typesafe,jev
9
10
  Classifier: Development Status :: 3 - Alpha
10
11
  Classifier: Programming Language :: Python :: 3
@@ -22,6 +23,8 @@ Provides-Extra: telegram
22
23
  Requires-Dist: python-telegram-bot>=21; extra == "telegram"
23
24
  Provides-Extra: reddit
24
25
  Requires-Dist: praw>=7.7; extra == "reddit"
26
+ Provides-Extra: billing
27
+ Requires-Dist: stripe>=12; extra == "billing"
25
28
  Provides-Extra: mcp
26
29
  Requires-Dist: mcp>=2; extra == "mcp"
27
30
  Provides-Extra: keys
@@ -32,6 +35,7 @@ Requires-Dist: python-telegram-bot>=21; extra == "all"
32
35
  Requires-Dist: praw>=7.7; extra == "all"
33
36
  Requires-Dist: mcp>=2; extra == "all"
34
37
  Requires-Dist: keyring>=25; extra == "all"
38
+ Requires-Dist: stripe>=12; extra == "all"
35
39
  Provides-Extra: examples
36
40
  Requires-Dist: langchain-core>=0.3; extra == "examples"
37
41
  Requires-Dist: httpx; extra == "examples"
@@ -51,6 +55,8 @@ You set the thresholds and the actions. Every decision is logged with its number
51
55
  It runs on [Jev](https://typesafe.ai), TypeSafe's System One model: you ask yes/no questions about a message and
52
56
  get probabilities back, no text generation. About **$0.04 per 1,000 messages** with all categories on.
53
57
 
58
+ Site: **https://jevmod.dev** (community owners) and **https://jevmod.dev/developers/** (packages, API, MCP, benchmark, cost calculator)
59
+
54
60
  ![jevmod check and the HTTP API in a terminal](docs/jevmod.gif)
55
61
 
56
62
  ```
@@ -174,7 +180,7 @@ hold the TypeSafe key. Express middleware in `packages/jevmod-js/examples/`. Det
174
180
  ### HTTP API
175
181
 
176
182
  ```bash
177
- JEVMOD_ADMIN_TOKEN=... jevmod api # or: docker compose up -d
183
+ JEVMOD_ADMIN_TOKEN=... jevmod api # binds 127.0.0.1; JEVMOD_HOST=0.0.0.0 to expose (Docker does)
178
184
  curl -X POST localhost:8080/v1/keys -H "Authorization: Bearer $JEVMOD_ADMIN_TOKEN" \
179
185
  -H "Content-Type: application/json" -d '{"tenant":"my-app"}' # {"api_key":"jm_...", shown once}
180
186
  curl -X POST localhost:8080/v1/moderate -H "Authorization: Bearer jm_..." -H "Content-Type: application/json" \
@@ -190,7 +196,7 @@ curl -X POST localhost:8080/v1/moderate -H "Authorization: Bearer jm_..." -H "Co
190
196
  | `POST /v1/keys` (admin) | mint a tenant key, stored hashed |
191
197
  | `GET /v1/health`, `GET /metrics` | liveness, Prometheus counters |
192
198
 
193
- OpenAPI at `/docs`, a Postman collection in `postman/`. Any chatbot, forum or comment system that can make an HTTP
199
+ OpenAPI at `/docs` on a running server (a static copy at [docs/openapi.json](docs/openapi.json)), a Postman collection at [postman/jevmod.postman_collection.json](postman/jevmod.postman_collection.json). Any chatbot, forum or comment system that can make an HTTP
194
200
  call can use it; the bots are adapters over the same service.
195
201
 
196
202
  ### Examples, one folder per surface
@@ -7,6 +7,8 @@ You set the thresholds and the actions. Every decision is logged with its number
7
7
  It runs on [Jev](https://typesafe.ai), TypeSafe's System One model: you ask yes/no questions about a message and
8
8
  get probabilities back, no text generation. About **$0.04 per 1,000 messages** with all categories on.
9
9
 
10
+ Site: **https://jevmod.dev** (community owners) and **https://jevmod.dev/developers/** (packages, API, MCP, benchmark, cost calculator)
11
+
10
12
  ![jevmod check and the HTTP API in a terminal](docs/jevmod.gif)
11
13
 
12
14
  ```
@@ -130,7 +132,7 @@ hold the TypeSafe key. Express middleware in `packages/jevmod-js/examples/`. Det
130
132
  ### HTTP API
131
133
 
132
134
  ```bash
133
- JEVMOD_ADMIN_TOKEN=... jevmod api # or: docker compose up -d
135
+ JEVMOD_ADMIN_TOKEN=... jevmod api # binds 127.0.0.1; JEVMOD_HOST=0.0.0.0 to expose (Docker does)
134
136
  curl -X POST localhost:8080/v1/keys -H "Authorization: Bearer $JEVMOD_ADMIN_TOKEN" \
135
137
  -H "Content-Type: application/json" -d '{"tenant":"my-app"}' # {"api_key":"jm_...", shown once}
136
138
  curl -X POST localhost:8080/v1/moderate -H "Authorization: Bearer jm_..." -H "Content-Type: application/json" \
@@ -146,7 +148,7 @@ curl -X POST localhost:8080/v1/moderate -H "Authorization: Bearer jm_..." -H "Co
146
148
  | `POST /v1/keys` (admin) | mint a tenant key, stored hashed |
147
149
  | `GET /v1/health`, `GET /metrics` | liveness, Prometheus counters |
148
150
 
149
- OpenAPI at `/docs`, a Postman collection in `postman/`. Any chatbot, forum or comment system that can make an HTTP
151
+ OpenAPI at `/docs` on a running server (a static copy at [docs/openapi.json](docs/openapi.json)), a Postman collection at [postman/jevmod.postman_collection.json](postman/jevmod.postman_collection.json). Any chatbot, forum or comment system that can make an HTTP
150
152
  call can use it; the bots are adapters over the same service.
151
153
 
152
154
  ### Examples, one folder per surface
@@ -0,0 +1,72 @@
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
+ REQUIRED_TOKEN = {"discord": "DISCORD_TOKEN", "telegram": "TELEGRAM_TOKEN", "reddit": "REDDIT_CLIENT_ID"}
11
+ TOKEN_HINT = {
12
+ "DISCORD_TOKEN": "Developer Portal → Bot → Reset Token",
13
+ "TELEGRAM_TOKEN": "@BotFather → /newbot",
14
+ "REDDIT_CLIENT_ID": "https://www.reddit.com/prefs/apps (script app)",
15
+ }
16
+
17
+
18
+ def run_role(role: str) -> None:
19
+ role = role.lower()
20
+ needed = REQUIRED_TOKEN.get(role)
21
+ if needed and not os.environ.get(needed): # before the adapter imports and creates its SQLite file
22
+ raise SystemExit(f"set {needed} ({TOKEN_HINT[needed]})")
23
+ if role == "api":
24
+ import uvicorn
25
+
26
+ uvicorn.run(
27
+ "jevmod.api.server:app",
28
+ host=os.environ.get("JEVMOD_HOST", "127.0.0.1"),
29
+ port=int(os.environ.get("PORT", "8080")),
30
+ log_level="info",
31
+ )
32
+ elif role == "discord":
33
+ from .adapters.discord_bot import main as run
34
+
35
+ run()
36
+ elif role == "telegram":
37
+ from .adapters.telegram_bot import main as run
38
+
39
+ run()
40
+ elif role == "reddit":
41
+ from .adapters.reddit_bot import run
42
+
43
+ run()
44
+ elif role in ("demo", "hosted"):
45
+ import uvicorn
46
+
47
+ uvicorn.run(
48
+ "jevmod.api.demo:app" if role == "demo" else "jevmod.api.hosted:app",
49
+ host=os.environ.get("JEVMOD_HOST", "127.0.0.1"),
50
+ port=int(os.environ.get("PORT", "8080")),
51
+ log_level="info",
52
+ )
53
+ elif role == "mcp":
54
+ from .mcp_server import main as run
55
+
56
+ run()
57
+ else:
58
+ raise SystemExit(
59
+ f"unknown role {role!r}; use check | init | mcp | api | demo | hosted | discord | telegram | reddit"
60
+ )
61
+
62
+
63
+ def main() -> None:
64
+ if len(sys.argv) > 1:
65
+ from .cli import main as cli
66
+
67
+ sys.exit(cli(sys.argv[1:]))
68
+ run_role(os.environ.get("JEVMOD_ROLE", "api"))
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -16,7 +16,7 @@ from datetime import timedelta
16
16
  import discord
17
17
  from discord import app_commands
18
18
 
19
- from ..core import FREE_MONTHLY, RULE_THRESHOLD, Batcher, Decision, ModerationService, Store
19
+ from ..core import RULE_THRESHOLD, Batcher, Decision, ModerationService, Store
20
20
  from ..judge import CATEGORIES, Message
21
21
 
22
22
  log = logging.getLogger("jevmod.discord")
@@ -84,27 +84,37 @@ async def on_message(msg: discord.Message) -> None:
84
84
  batcher.add(tenant, msg)
85
85
 
86
86
 
87
+ @bot.event
88
+ async def on_message_edit(_before: discord.Message, after: discord.Message) -> None:
89
+ """Judge edits too: otherwise a member posts a harmless line and edits it into whatever they wanted."""
90
+ if after.content != _before.content:
91
+ await on_message(after)
92
+
93
+
87
94
  async def act(guild: discord.Guild, m: discord.Message, d: Decision) -> None:
88
95
  tenant = tenant_of(guild.id)
89
96
  policy = service.policy(tenant)
90
97
  note = ""
98
+ # `timeout` times the author out and leaves the message; only `delete` removes it. They used to be the same
99
+ # branch, so a category set to timeout silently deleted as well, which the site does not promise.
91
100
  try:
92
- if d.action in ("delete", "timeout"):
101
+ if d.action == "delete":
93
102
  await m.delete()
94
103
  note = "deleted"
95
- if d.action == "timeout" and isinstance(m.author, discord.Member):
104
+ elif d.action == "timeout" and isinstance(m.author, discord.Member):
96
105
  await m.author.timeout(
97
106
  timedelta(minutes=policy.timeout_minutes), reason=f"jevmod: {d.category} p={d.probability:.2f}"
98
107
  )
99
- note = f"deleted, timed out {policy.timeout_minutes} min"
108
+ note = f"timed out {policy.timeout_minutes} min"
100
109
  except discord.Forbidden:
101
110
  note = "missing permissions to act"
102
- if d.action in ("delete", "timeout") and "missing" not in note:
111
+ if note and "missing" not in note:
112
+ what = "was removed" if d.action == "delete" else f"led to a {policy.timeout_minutes} minute timeout for you"
103
113
  with contextlib.suppress(Exception): # DMs closed
104
114
  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."
115
+ f"Your message in **{guild.name}** #{m.channel} {what} because an automated moderation system "
116
+ f"rated it {d.category} with confidence {d.probability:.0%}. If you think this was a mistake, contact "
117
+ "the server's moderators; they can review the decision and adjust the rules."
108
118
  )
109
119
  channel = await log_channel(guild, tenant)
110
120
  if channel:
@@ -133,9 +143,25 @@ async def log_channel(guild: discord.Guild, tenant: str) -> discord.TextChannel
133
143
  store.set_meta(tenant, log_channel=existing.id)
134
144
  return existing
135
145
  try:
146
+ # Channel overwrites beat guild-level permissions, so the bot needs an explicit one or it cannot read,
147
+ # post or react in the channel it just created, which breaks the log and the feedback reactions.
136
148
  overwrites: dict[discord.Role | discord.Member | discord.Object, discord.PermissionOverwrite] = {
137
- guild.default_role: discord.PermissionOverwrite(read_messages=False)
149
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
150
+ guild.me: discord.PermissionOverwrite(
151
+ read_messages=True,
152
+ send_messages=True,
153
+ embed_links=True,
154
+ add_reactions=True,
155
+ read_message_history=True,
156
+ ),
138
157
  }
158
+ # Denying @everyone leaves the channel visible only to Administrators, so a plain Moderator role could
159
+ # not read the flags or use the reactions. Every role that can already moderate messages gets access.
160
+ for role in guild.roles:
161
+ if role.permissions.manage_messages or role.permissions.manage_guild:
162
+ overwrites[role] = discord.PermissionOverwrite(
163
+ read_messages=True, send_messages=True, add_reactions=True, read_message_history=True
164
+ )
139
165
  ch = await guild.create_text_channel("jevmod-log", overwrites=overwrites, reason="jevmod decisions log")
140
166
  store.set_meta(tenant, log_channel=ch.id)
141
167
  return ch
@@ -149,8 +175,9 @@ async def _notify_quota_once(guild: discord.Guild, tenant: str) -> None:
149
175
  ch = await log_channel(guild, tenant)
150
176
  if ch:
151
177
  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."
178
+ f"jevmod paused for this month: the {store.plan(tenant)} plan covers {store.quota_for(tenant):,} judged "
179
+ "messages. Messages are not being judged until next month. Nothing is deleted while paused."
180
+ + (" `/mod upgrade` lifts the limit." if store.plan(tenant) == "free" and _billing_enabled() else "")
154
181
  )
155
182
 
156
183
 
@@ -202,11 +229,8 @@ async def status(itx: discord.Interaction) -> None:
202
229
  lines = [f"**{c}**: {p.actions.get(c, 'off')} at p ≥ {p.thresholds.get(c, 0.9):.2f}" for c in CATEGORIES]
203
230
  lines += [f'**rule {n}**: {p.rule_actions.get(n, "flag")} · "{r}"' for n, r in p.rules.items()]
204
231
  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
- )
232
+ q = store.quota_for(tenant)
233
+ quota = f"{judged:,}/{q:,} judged this month ({plan})" if q else f"{judged:,} judged this month ({plan}, unlimited)"
210
234
  lines.append(f"\n{quota} · {requests} Jev requests · {tokens:,} tokens")
211
235
  await itx.response.send_message("\n".join(lines), ephemeral=True)
212
236
 
@@ -291,18 +315,52 @@ async def recent_cmd(itx: discord.Interaction) -> None:
291
315
  await itx.response.send_message("no decisions yet", ephemeral=True)
292
316
  return
293
317
  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
318
+ "\n".join(
319
+ f"`{r['category']} {r['p']:.2f} {r['action']}` {r['text'][:80] or 'message ' + str(r['message_id'])}"
320
+ for r in rows
321
+ ),
322
+ ephemeral=True,
295
323
  )
296
324
 
297
325
 
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))
326
+ @mod.command(name="upgrade", description="Payment link for the Pro plan of this server, or the billing portal")
327
+ async def upgrade_cmd(itx: discord.Interaction) -> None:
328
+ tenant = tenant_of(itx.guild_id or 0)
329
+ if not _billing_enabled():
330
+ await itx.response.send_message(
331
+ "This copy of jevmod is self-hosted: there is nothing to pay. Raise JEVMOD_MONTHLY_QUOTA on the server.",
332
+ ephemeral=True,
333
+ )
334
+ return
335
+ from ..api.billing import checkout_url
336
+
337
+ plan = store.plan(tenant)
338
+ what = "manage or cancel the subscription" if plan != "free" else "upgrade this server to Pro"
301
339
  await itx.response.send_message(
302
- "all settings, usage and decision logs for this server were deleted", ephemeral=True
340
+ f"Link to {what} (valid for this server only, opens Stripe): {checkout_url(tenant)}", ephemeral=True
303
341
  )
304
342
 
305
343
 
344
+ def _billing_enabled() -> bool:
345
+ return bool(os.environ.get("STRIPE_PRICE_ID") and os.environ.get("JEVMOD_PUBLIC_URL"))
346
+
347
+
348
+ @mod.command(name="forget", description="Delete everything jevmod stored about this server (GDPR)")
349
+ async def forget_cmd(itx: discord.Interaction) -> None:
350
+ tenant = tenant_of(itx.guild_id or 0)
351
+ # Deleting the local rows does not cancel anything at Stripe, so a paying owner would keep being charged
352
+ # with no record left here to explain it. Say so before deleting, and leave the portal link in reach.
353
+ paying = store.plan(tenant) != "free"
354
+ store.delete_tenant(tenant)
355
+ note = "all settings, usage and decision logs for this server were deleted"
356
+ if paying:
357
+ note += (
358
+ ". This did not cancel your Pro subscription: Stripe will keep charging the card until you cancel "
359
+ "it in Stripe's billing portal."
360
+ )
361
+ await itx.response.send_message(note, ephemeral=True)
362
+
363
+
306
364
  @mod.command(name="forget_user", description="Delete this member's entries from the decision log (erasure request)")
307
365
  async def forget_user_cmd(itx: discord.Interaction, member: discord.Member) -> None:
308
366
  n = store.delete_user(tenant_of(itx.guild_id or 0), str(member.id))
@@ -0,0 +1,57 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>jevmod admin</title>
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <meta name="robots" content="noindex">
8
+ <style>
9
+ body { margin: 0; background: #FAFAFA; color: #0A0A0A; font: 15px/1.5 Inter, system-ui, sans-serif; padding: 32px 16px; }
10
+ .wrap { max-width: 1100px; margin: 0 auto; }
11
+ h1 { font-size: 28px; margin: 0 0 4px; }
12
+ .sub { color: #3F3F46; margin: 0 0 32px; }
13
+ .figs { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0;
14
+ border-top: 1px solid #D4D4D8; border-bottom: 1px solid #D4D4D8; margin-bottom: 32px; }
15
+ .figs div { padding: 20px 16px; border-right: 1px solid #E5E5E5; }
16
+ .figs b { display: block; font-size: 28px; font-weight: 700; }
17
+ .figs span { color: #71717A; font-size: 13px; }
18
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
19
+ th { text-align: left; color: #71717A; font-weight: 400; font-family: "IBM Plex Mono", monospace; font-size: 12px;
20
+ padding: 8px 12px; border-bottom: 1px solid #D4D4D8; }
21
+ td { padding: 10px 12px; border-bottom: 1px solid #E5E5E5; }
22
+ td.m { font-family: "IBM Plex Mono", monospace; font-size: 13px; }
23
+ td.n { font-variant-numeric: tabular-nums; }
24
+ select { border: 1px solid #D4D4D8; background: #fff; padding: 6px 8px; font: inherit; border-radius: 0; }
25
+ .ok { color: #16A34A; } .err { color: #DC2626; }
26
+ </style>
27
+ </head>
28
+ <body>
29
+ <div class="wrap">
30
+ <h1>jevmod admin</h1>
31
+ <p class="sub">$month · plans $plans · quotas: free $free_quota, pro $pro_quota</p>
32
+ <div class="figs">
33
+ <div><b>$active</b><span>tenants with usage this month</span></div>
34
+ <div><b>$judged</b><span>judged messages</span></div>
35
+ <div><b>$$$usd</b><span>Jev spend, list price</span></div>
36
+ <div><b>$$$demo</b><span>demo spend this month</span></div>
37
+ </div>
38
+ <table>
39
+ <thead><tr><th>tenant</th><th>plan</th><th>judged / quota</th><th>tokens</th><th>subscription</th><th>set plan</th></tr></thead>
40
+ <tbody>$rows</tbody>
41
+ </table>
42
+ <p id="msg" class="sub" style="margin-top:16px"></p>
43
+ </div>
44
+ <script>
45
+ const TOKEN = new URLSearchParams(location.search).get("token") || "";
46
+ document.querySelectorAll("select[data-t]").forEach(s => s.addEventListener("change", async () => {
47
+ const r = await fetch("/admin/api/plan?token=" + encodeURIComponent(TOKEN), {
48
+ method: "POST", headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({ tenant: s.dataset.t, plan: s.value })
50
+ });
51
+ const m = document.getElementById("msg");
52
+ m.className = r.ok ? "sub ok" : "sub err";
53
+ m.textContent = r.ok ? `$${s.dataset.t} -> $${s.value}` : `failed: $${r.status}`;
54
+ }));
55
+ </script>
56
+ </body>
57
+ </html>
@@ -0,0 +1,235 @@
1
+ """Hosted plans: Stripe Checkout, webhook and a small admin panel. Mounted by the `hosted` role next to the demo.
2
+
3
+ STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PRICE_ID=price_... JEVMOD_ADMIN_TOKEN=...
4
+ JEVMOD_PUBLIC_URL=https://jevmod.dev jevmod hosted
5
+
6
+ Flow: a server manager types `/mod upgrade` in Discord → the bot answers with
7
+ `{PUBLIC_URL}/billing/checkout?tenant=discord:<guild>&sig=<hmac>` → Stripe Checkout (subscription, one price) →
8
+ `checkout.session.completed` webhook → `Store.set_plan(tenant, "pro")` and the subscription row → the bot sees
9
+ the new quota on its next batch. `customer.subscription.deleted` (or a non-active status on `updated`) puts the
10
+ tenant back on `free`. The checkout link is signed so nobody can start a checkout for a guild they do not manage.
11
+
12
+ Admin: `GET /admin` (HTML, Bearer token or `?token=`) lists tenants, plans, usage, subscriptions, demo spend,
13
+ and lets the operator comp or revoke a plan. Everything the panel does is also JSON under `/admin/api/*`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import hmac
20
+ import html
21
+ import json
22
+ import os
23
+ import time
24
+ from pathlib import Path
25
+ from string import Template
26
+ from typing import Any
27
+
28
+ from fastapi import APIRouter, Header, HTTPException, Query, Request
29
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
30
+
31
+ from ..core import PLAN_QUOTAS, Store
32
+
33
+ PUBLIC_URL = os.environ.get("JEVMOD_PUBLIC_URL", "https://jevmod.dev").rstrip("/")
34
+ PRICE_ID = os.environ.get("STRIPE_PRICE_ID", "")
35
+ JEV_USD_PER_M = 0.042
36
+ PLANS = ("free", "pro", "unlimited")
37
+ ADMIN_TEMPLATE = Template(Path(__file__).with_name("admin.html").read_text(encoding="utf-8"))
38
+
39
+
40
+ def _secret(name: str) -> str:
41
+ return os.environ.get(name, "")
42
+
43
+
44
+ def sign_tenant(tenant: str) -> str:
45
+ """HMAC over the tenant id with the admin token, so `/mod upgrade` links cannot be forged for another guild.
46
+
47
+ There is deliberately no default key: with a constant fallback anyone could compute a valid signature for any
48
+ guild id and open that owner's Stripe portal, which lists their invoices and can cancel their subscription."""
49
+ key = _secret("JEVMOD_ADMIN_TOKEN")
50
+ if not key:
51
+ raise RuntimeError("JEVMOD_ADMIN_TOKEN must be set to sign billing links")
52
+ return hmac.new(key.encode(), tenant.encode(), hashlib.sha256).hexdigest()[:24]
53
+
54
+
55
+ def checkout_url(tenant: str) -> str:
56
+ return f"{PUBLIC_URL}/billing/checkout?tenant={tenant}&sig={sign_tenant(tenant)}"
57
+
58
+
59
+ def make_router(store: Store, demo_spend: Any = None) -> APIRouter:
60
+ r = APIRouter()
61
+
62
+ # ------------------------------------------------------------------ Stripe
63
+ def stripe_client() -> Any:
64
+ import stripe
65
+
66
+ key = _secret("STRIPE_SECRET_KEY")
67
+ if not key:
68
+ raise HTTPException(503, "billing is not configured on this server")
69
+ stripe.api_key = key
70
+ return stripe
71
+
72
+ @r.get("/billing/checkout")
73
+ def checkout(tenant: str = Query(..., min_length=3, max_length=80), sig: str = Query(..., min_length=8)) -> Any:
74
+ if not hmac.compare_digest(sig, sign_tenant(tenant)):
75
+ raise HTTPException(403, "invalid link; ask for a new one with /mod upgrade")
76
+ if not PRICE_ID:
77
+ raise HTTPException(503, "billing is not configured on this server")
78
+ if store.plan(tenant) != "free":
79
+ return RedirectResponse(f"{PUBLIC_URL}/billing/portal?tenant={tenant}&sig={sig}", status_code=303)
80
+ stripe = stripe_client()
81
+ session = stripe.checkout.Session.create(
82
+ mode="subscription",
83
+ line_items=[{"price": PRICE_ID, "quantity": 1}],
84
+ client_reference_id=tenant,
85
+ metadata={"tenant": tenant},
86
+ subscription_data={"metadata": {"tenant": tenant}},
87
+ success_url=f"{PUBLIC_URL}/thanks.html",
88
+ cancel_url=f"{PUBLIC_URL}/cancelled.html",
89
+ allow_promotion_codes=True,
90
+ )
91
+ return RedirectResponse(session.url, status_code=303)
92
+
93
+ @r.get("/billing/portal")
94
+ def portal(tenant: str = Query(...), sig: str = Query(...)) -> Any:
95
+ if not hmac.compare_digest(sig, sign_tenant(tenant)):
96
+ raise HTTPException(403, "invalid link")
97
+ sub = store.subscription(tenant)
98
+ if not sub or not sub.get("customer_id"):
99
+ raise HTTPException(404, "no subscription for this server")
100
+ stripe = stripe_client()
101
+ ps = stripe.billing_portal.Session.create(customer=sub["customer_id"], return_url=f"{PUBLIC_URL}/")
102
+ return RedirectResponse(ps.url, status_code=303)
103
+
104
+ @r.post("/billing/stripe/webhook")
105
+ async def webhook(request: Request, stripe_signature: str = Header(default="", alias="Stripe-Signature")) -> Any:
106
+ secret = _secret("STRIPE_WEBHOOK_SECRET")
107
+ if not secret:
108
+ raise HTTPException(503, "webhook secret not configured")
109
+ payload = await request.body()
110
+ event = verify_stripe_signature(payload, stripe_signature, secret)
111
+ kind = event.get("type", "")
112
+ obj = event.get("data", {}).get("object", {})
113
+ if kind == "checkout.session.completed":
114
+ tenant = obj.get("client_reference_id") or (obj.get("metadata") or {}).get("tenant")
115
+ if tenant:
116
+ store.set_plan(tenant, "pro")
117
+ store.set_subscription(
118
+ tenant,
119
+ customer_id=obj.get("customer"),
120
+ subscription_id=obj.get("subscription"),
121
+ price_id=PRICE_ID or None,
122
+ status="active",
123
+ current_period_end=None,
124
+ )
125
+ elif kind in ("customer.subscription.updated", "customer.subscription.deleted"):
126
+ sub_id = obj.get("id")
127
+ tenant = (obj.get("metadata") or {}).get("tenant") or (
128
+ store.tenant_for_subscription(sub_id) if sub_id else None
129
+ )
130
+ if tenant:
131
+ status = "canceled" if kind.endswith("deleted") else str(obj.get("status", ""))
132
+ # past_due keeps Pro: Stripe is still retrying the card, and Stripe moves the subscription
133
+ # to `unpaid` or `canceled` when it gives up, which is where access actually ends.
134
+ active = status in ("active", "trialing", "past_due")
135
+ store.set_plan(tenant, "pro" if active else "free")
136
+ item = ((obj.get("items") or {}).get("data") or [{}])[0]
137
+ store.set_subscription(
138
+ tenant,
139
+ customer_id=obj.get("customer"),
140
+ subscription_id=sub_id,
141
+ price_id=(item.get("price") or {}).get("id") or PRICE_ID or None,
142
+ status=status,
143
+ current_period_end=obj.get("current_period_end") or item.get("current_period_end"),
144
+ )
145
+ return {"received": True}
146
+
147
+ # ------------------------------------------------------------------ admin
148
+ def admin(authorization: str, token: str) -> None:
149
+ want = _secret("JEVMOD_ADMIN_TOKEN")
150
+ given = authorization[7:].strip() if authorization.startswith("Bearer ") else token
151
+ if not want or not hmac.compare_digest(given, want):
152
+ raise HTTPException(403, "admin token required")
153
+
154
+ @r.get("/admin/api/tenants")
155
+ def api_tenants(authorization: str = Header(default=""), token: str = "") -> list[dict[str, Any]]:
156
+ admin(authorization, token)
157
+ return store.tenants_overview()
158
+
159
+ @r.get("/admin/api/totals")
160
+ def api_totals(authorization: str = Header(default=""), token: str = "") -> dict[str, Any]:
161
+ admin(authorization, token)
162
+ t = store.totals()
163
+ t["usd"] = round(t["tokens"] * JEV_USD_PER_M / 1e6, 4)
164
+ if demo_spend is not None:
165
+ t["demo_spent_usd"] = round(demo_spend(), 4)
166
+ t["quotas"] = {"free": store.monthly_quota, **{k: v for k, v in PLAN_QUOTAS.items() if k != "free"}}
167
+ return t
168
+
169
+ @r.post("/admin/api/plan")
170
+ def api_plan(body: dict[str, str], authorization: str = Header(default=""), token: str = "") -> dict[str, str]:
171
+ admin(authorization, token)
172
+ tenant, plan = body.get("tenant", ""), body.get("plan", "")
173
+ if plan not in PLANS or not tenant:
174
+ raise HTTPException(422, f"plan must be one of {PLANS}")
175
+ store.set_plan(tenant, plan)
176
+ return {"tenant": tenant, "plan": plan}
177
+
178
+ @r.get("/admin", response_class=HTMLResponse)
179
+ def panel(authorization: str = Header(default=""), token: str = "") -> str:
180
+ admin(authorization, token)
181
+ rows = store.tenants_overview()
182
+ totals = store.totals()
183
+ tr = []
184
+ for x in rows:
185
+ quota = "unlimited" if not x["quota"] else f"{x['quota']:,}"
186
+ end = time.strftime("%Y-%m-%d", time.gmtime(x["current_period_end"])) if x["current_period_end"] else ""
187
+ opts = "".join(f'<option value="{p}"{" selected" if p == x["plan"] else ""}>{p}</option>' for p in PLANS)
188
+ t = html.escape(x["tenant"])
189
+ tr.append(
190
+ f"<tr><td class=m>{t}</td><td>{x['plan']}</td><td class=n>{x['judged']:,} / {quota}</td>"
191
+ f"<td class=n>{x['tokens']:,}</td><td>{html.escape(str(x['subscription_status'] or ''))} {end}</td>"
192
+ f'<td><select data-t="{t}">{opts}</select></td></tr>'
193
+ )
194
+ return ADMIN_TEMPLATE.substitute(
195
+ month=totals["month"],
196
+ plans=html.escape(json.dumps(totals["plans"])),
197
+ free_quota=store.monthly_quota or "unlimited",
198
+ pro_quota=PLAN_QUOTAS.get("pro") or "unlimited",
199
+ active=totals["active_tenants"],
200
+ judged=f"{totals['judged']:,}",
201
+ usd=f"{totals['tokens'] * JEV_USD_PER_M / 1e6:.4f}",
202
+ demo=f"{demo_spend():.4f}" if demo_spend is not None else "n/a",
203
+ rows="".join(tr) or '<tr><td colspan=6 style="color:#71717A">no tenants yet</td></tr>',
204
+ )
205
+
206
+ return r
207
+
208
+
209
+ def verify_stripe_signature(payload: bytes, header: str, secret: str, tolerance_s: int = 300) -> dict[str, Any]:
210
+ """Stripe's scheme: header `t=<ts>,v1=<hmac>`; hmac = HMAC-SHA256(secret, f"{ts}.{payload}")."""
211
+ parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
212
+ ts = parts.get("t", "")
213
+ sigs = [v for k, v in (kv.split("=", 1) for kv in header.split(",") if "=" in kv) if k == "v1"]
214
+ if not ts or not sigs:
215
+ raise HTTPException(400, "bad signature header")
216
+ expected = hmac.new(secret.encode(), f"{ts}.{payload.decode()}".encode(), hashlib.sha256).hexdigest()
217
+ if not any(hmac.compare_digest(expected, s) for s in sigs):
218
+ raise HTTPException(400, "signature mismatch")
219
+ if abs(time.time() - int(ts)) > tolerance_s:
220
+ raise HTTPException(400, "timestamp outside tolerance")
221
+ try:
222
+ return json.loads(payload)
223
+ except json.JSONDecodeError as exc:
224
+ raise HTTPException(400, "bad json") from exc
225
+
226
+
227
+ def sign_for_test(payload: bytes, secret: str, ts: int | None = None) -> str:
228
+ """Build a Stripe-Signature header the way Stripe does (used by tests and the local smoke script)."""
229
+ ts = ts or int(time.time())
230
+ sig = hmac.new(secret.encode(), f"{ts}.{payload.decode()}".encode(), hashlib.sha256).hexdigest()
231
+ return f"t={ts},v1={sig}"
232
+
233
+
234
+ def json_error(status: int, detail: str) -> JSONResponse:
235
+ return JSONResponse({"detail": detail}, status_code=status)