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.
- {jevmod-0.2.0 → jevmod-0.2.1}/PKG-INFO +10 -4
- {jevmod-0.2.0 → jevmod-0.2.1}/README.md +4 -2
- jevmod-0.2.1/jevmod/__main__.py +72 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/adapters/discord_bot.py +79 -21
- jevmod-0.2.1/jevmod/api/admin.html +57 -0
- jevmod-0.2.1/jevmod/api/billing.py +235 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/api/demo.py +26 -0
- jevmod-0.2.1/jevmod/api/hosted.py +14 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/categories.json +12 -2
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/cli.py +10 -1
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/core/__init__.py +2 -1
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/core/policy.py +14 -1
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/core/service.py +2 -1
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/core/store.py +111 -4
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/PKG-INFO +10 -4
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/SOURCES.txt +5 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/requires.txt +4 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/pyproject.toml +6 -4
- jevmod-0.2.1/tests/test_billing.py +123 -0
- jevmod-0.2.1/tests/test_discord_actions.py +66 -0
- jevmod-0.2.1/tests/test_offline.py +239 -0
- jevmod-0.2.0/jevmod/__main__.py +0 -50
- jevmod-0.2.0/tests/test_offline.py +0 -97
- {jevmod-0.2.0 → jevmod-0.2.1}/LICENSE +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/__init__.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/adapters/__init__.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/adapters/reddit_bot.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/adapters/telegram_bot.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/api/__init__.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/api/server.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/judge.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/keys.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod/mcp_server.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/dependency_links.txt +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/entry_points.txt +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/jevmod.egg-info/top_level.txt +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/setup.cfg +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_api.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_cli.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_demo.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_examples.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_judge.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_keys.py +0 -0
- {jevmod-0.2.0 → jevmod-0.2.1}/tests/test_mcp.py +0 -0
- {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.
|
|
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://
|
|
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
|

|
|
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 #
|
|
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
|
|
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
|

|
|
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 #
|
|
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
|
|
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
|
|
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
|
|
101
|
+
if d.action == "delete":
|
|
93
102
|
await m.delete()
|
|
94
103
|
note = "deleted"
|
|
95
|
-
|
|
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"
|
|
108
|
+
note = f"timed out {policy.timeout_minutes} min"
|
|
100
109
|
except discord.Forbidden:
|
|
101
110
|
note = "missing permissions to act"
|
|
102
|
-
if
|
|
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}
|
|
106
|
-
f"
|
|
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
|
|
153
|
-
"
|
|
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
|
-
|
|
206
|
-
|
|
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(
|
|
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="
|
|
299
|
-
async def
|
|
300
|
-
|
|
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
|
-
"
|
|
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)
|