nonebot-plugin-onebot-luckperms 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. nonebot_plugin_onebot_luckperms/__init__.py +127 -0
  2. nonebot_plugin_onebot_luckperms/adapter/__init__.py +17 -0
  3. nonebot_plugin_onebot_luckperms/adapter/context.py +31 -0
  4. nonebot_plugin_onebot_luckperms/adapter/identity.py +64 -0
  5. nonebot_plugin_onebot_luckperms/adapter/permission.py +157 -0
  6. nonebot_plugin_onebot_luckperms/commands/__init__.py +3 -0
  7. nonebot_plugin_onebot_luckperms/commands/admin.py +857 -0
  8. nonebot_plugin_onebot_luckperms/config.py +20 -0
  9. nonebot_plugin_onebot_luckperms/core/__init__.py +20 -0
  10. nonebot_plugin_onebot_luckperms/core/cache.py +44 -0
  11. nonebot_plugin_onebot_luckperms/core/context_provider.py +74 -0
  12. nonebot_plugin_onebot_luckperms/core/engine.py +162 -0
  13. nonebot_plugin_onebot_luckperms/core/exceptions.py +6 -0
  14. nonebot_plugin_onebot_luckperms/core/models.py +123 -0
  15. nonebot_plugin_onebot_luckperms/core/registry.py +72 -0
  16. nonebot_plugin_onebot_luckperms/message.py +126 -0
  17. nonebot_plugin_onebot_luckperms/py.typed +0 -0
  18. nonebot_plugin_onebot_luckperms/storage/__init__.py +29 -0
  19. nonebot_plugin_onebot_luckperms/storage/memory.py +46 -0
  20. nonebot_plugin_onebot_luckperms/storage/protocol.py +21 -0
  21. nonebot_plugin_onebot_luckperms/storage/redis.py +162 -0
  22. nonebot_plugin_onebot_luckperms/storage/sqlite.py +189 -0
  23. nonebot_plugin_onebot_luckperms/webeditor/__init__.py +9 -0
  24. nonebot_plugin_onebot_luckperms/webeditor/bytebin.py +71 -0
  25. nonebot_plugin_onebot_luckperms/webeditor/manager.py +228 -0
  26. nonebot_plugin_onebot_luckperms/webeditor/session.py +109 -0
  27. nonebot_plugin_onebot_luckperms/webeditor/websocket.py +180 -0
  28. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/METADATA +23 -0
  29. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/RECORD +32 -0
  30. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/WHEEL +5 -0
  31. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/licenses/LICENSE +21 -0
  32. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,857 @@
1
+ import logging
2
+ import time
3
+ from typing import Optional
4
+
5
+ from nonebot import on_command
6
+ from nonebot.adapters import Bot, Event
7
+ from nonebot.params import CommandArg
8
+ from nonebot.matcher import Matcher
9
+
10
+ from ..core.models import ContextSet, PermissionNode, User, Group, QueryOptions
11
+ from ..core.registry import NodeRegistry
12
+ from ..core.engine import PermissionEngine
13
+ from ..core.cache import PermissionCache
14
+ from ..core.context_provider import get_context_providers
15
+ from ..storage import get_store
16
+ from ..adapter.identity import get_resolver, Identity
17
+ from ..adapter.context import LPContext, set_context
18
+ from ..config import oblp_config
19
+ from ..message import msg
20
+
21
+ logger = logging.getLogger("oblp")
22
+
23
+
24
+ def _parse_context(args: list) -> ContextSet:
25
+ ctx = {}
26
+ for a in args:
27
+ if "=" in a:
28
+ k, v = a.split("=", 1)
29
+ ctx[k] = v
30
+ return ContextSet.from_dict(ctx)
31
+
32
+
33
+ def _parse_duration(dur_str: str) -> Optional[int]:
34
+ if not dur_str:
35
+ return None
36
+ dur_str = dur_str.lower()
37
+ multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
38
+ suffix = dur_str[-1]
39
+ if suffix in multipliers:
40
+ try:
41
+ return int(dur_str[:-1]) * multipliers[suffix]
42
+ except ValueError:
43
+ return None
44
+ try:
45
+ return int(dur_str)
46
+ except ValueError:
47
+ return None
48
+
49
+
50
+ async def _check_lp(bot: Bot, event: Event, matcher: Matcher, node_key: str) -> bool:
51
+ """Check luckperms node + fallback luckperms.*, send deny message on failure."""
52
+ resolver = get_resolver()
53
+ store = get_store()
54
+ if resolver is None or store is None:
55
+ return False
56
+
57
+ try:
58
+ identity = await resolver.resolve(bot, event)
59
+ except Exception:
60
+ return False
61
+
62
+ ctx = ContextSet.from_dict({
63
+ "platform": identity.platform,
64
+ "user_id": identity.user_id,
65
+ "role": identity.role,
66
+ })
67
+ if identity.group_id:
68
+ ctx = ctx.with_context("group_id", identity.group_id)
69
+
70
+ for provider in get_context_providers():
71
+ try:
72
+ extra = await provider(bot, event, ctx)
73
+ if extra:
74
+ for k, v in extra.items():
75
+ ctx = ctx.with_context(k, v)
76
+ except Exception:
77
+ logger.exception(f"ContextProvider error")
78
+
79
+ opts = QueryOptions(mode="contextual", contexts=ctx, flags={"include_inherited", "resolve_inheritance"})
80
+
81
+ user_obj = await store.get_user(identity.user_id)
82
+ if user_obj is None:
83
+ user_obj = User(user_id=identity.user_id)
84
+
85
+ resolved = await user_obj.get_effective_nodes(store, opts, ctx)
86
+
87
+ if PermissionEngine.check(resolved, node_key):
88
+ return True
89
+ if node_key != "luckperms.*" and PermissionEngine.check(resolved, "luckperms.*"):
90
+ return True
91
+
92
+ try:
93
+ await matcher.send(msg("deny"))
94
+ await matcher.send(msg("deny_hint"))
95
+ except Exception:
96
+ pass
97
+ return False
98
+
99
+
100
+ CLP = None
101
+
102
+
103
+ def register_admin_commands():
104
+ global CLP
105
+ CLP = on_command("lp", aliases={"luckperms"}, block=True)
106
+ CLP.handle()(_handler)
107
+
108
+
109
+ async def _handler(bot: Bot, event: Event, matcher: Matcher, arg=CommandArg()):
110
+ store = get_store()
111
+ if store is None:
112
+ return
113
+
114
+ raw = arg.extract_plain_text().strip()
115
+ parts = raw.split()
116
+ if not parts:
117
+ if await _check_lp(bot, event, matcher, "luckperms.help"):
118
+ await matcher.send(
119
+ "LuckPerms commands:\n"
120
+ " sync / info / editor / applyedits <code>\n"
121
+ " creategroup / deletegroup / listgroups\n"
122
+ " createtrack / deletetrack / listtracks\n"
123
+ " check <user> <node> [ctx...]\n"
124
+ " tree [scope] [player]\n"
125
+ " user <user> <action> ...\n"
126
+ " group <group> <action> ...\n"
127
+ " track <track> <action> ..."
128
+ )
129
+ return
130
+
131
+ cmd = parts[0].lower()
132
+
133
+ try:
134
+ # ── General commands ────────────────────────────────
135
+
136
+ if cmd in ("help", "?"):
137
+ if await _check_lp(bot, event, matcher, "luckperms.help"):
138
+ await matcher.send(
139
+ "LuckPerms commands:\n"
140
+ " sync / info / editor / applyedits <code>\n"
141
+ " creategroup / deletegroup / listgroups\n"
142
+ " createtrack / deletetrack / listtracks\n"
143
+ " check <user> <node> [ctx...]\n"
144
+ " tree [scope] [player]\n"
145
+ " user <user> <action> ...\n"
146
+ " group <group> <action> ...\n"
147
+ " track <track> <action> ..."
148
+ )
149
+
150
+ elif cmd == "sync":
151
+ if not await _check_lp(bot, event, matcher, "luckperms.sync"):
152
+ return
153
+ await store.load_all()
154
+ PermissionCache.invalidate_all()
155
+ await matcher.send(msg("data_reloaded"))
156
+
157
+ elif cmd == "info":
158
+ if not await _check_lp(bot, event, matcher, "luckperms.info"):
159
+ return
160
+ groups = await store.list_groups()
161
+ users = await store.list_users()
162
+ await matcher.send(f"OBLP v0.1.0\nUsers: {len(users)}\nGroups: {len(groups)}\nStore: {type(store).__name__}")
163
+
164
+ elif cmd == "editor":
165
+ if not await _check_lp(bot, event, matcher, "luckperms.editor"):
166
+ return
167
+ await _cmd_editor(bot, event, matcher, store, parts[1:])
168
+
169
+ elif cmd == "applyedits":
170
+ if not await _check_lp(bot, event, matcher, "luckperms.applyedits"):
171
+ return
172
+ if len(parts) < 2:
173
+ await matcher.finish("Usage: /lp applyedits <code>")
174
+ await _apply_edits(matcher, parts[1])
175
+
176
+ elif cmd == "creategroup":
177
+ if not await _check_lp(bot, event, matcher, "luckperms.group.create"):
178
+ return
179
+ name = parts[1] if len(parts) > 1 else None
180
+ if not name:
181
+ await matcher.finish("Usage: /lp creategroup <name> [weight]")
182
+ weight = int(parts[2]) if len(parts) > 2 else 0
183
+ existing = await store.get_group(name)
184
+ if existing:
185
+ await matcher.finish(f"Group {name} already exists")
186
+ await store.save_group(Group(name=name, weight=weight))
187
+ await matcher.send(f"Group {name} created (weight={weight})")
188
+
189
+ elif cmd == "deletegroup":
190
+ if not await _check_lp(bot, event, matcher, "luckperms.group.delete"):
191
+ return
192
+ if len(parts) < 2:
193
+ await matcher.finish("Usage: /lp deletegroup <name>")
194
+ await store.delete_group(parts[1])
195
+ await matcher.send(f"Group {parts[1]} deleted")
196
+
197
+ elif cmd == "listgroups":
198
+ if not await _check_lp(bot, event, matcher, "luckperms.group.list"):
199
+ return
200
+ groups = await store.list_groups()
201
+ if not groups:
202
+ await matcher.send("No groups")
203
+ else:
204
+ lines = ["Groups:"]
205
+ for gname in groups:
206
+ g = await store.get_group(gname)
207
+ if g:
208
+ lines.append(f" {g.name} (weight={g.weight}, parents={g.parents})")
209
+ await matcher.send("\n".join(lines))
210
+
211
+ elif cmd == "check":
212
+ if not await _check_lp(bot, event, matcher, "luckperms.check"):
213
+ return
214
+ if len(parts) < 3:
215
+ await matcher.finish("Usage: /lp check <user> <node> [ctx...]")
216
+ await _cmd_check(matcher, store, parts[1], parts[2], parts[3:])
217
+
218
+ elif cmd == "tree":
219
+ if not await _check_lp(bot, event, matcher, "luckperms.tree"):
220
+ return
221
+ await _cmd_tree(matcher, store, parts[1:])
222
+
223
+ elif cmd == "user":
224
+ if len(parts) < 2:
225
+ await matcher.finish("Usage: /lp user <user> info|permission|parent|meta|editor|promote|demote|showtracks|clear|clone")
226
+ await _cmd_user(bot, event, matcher, store, parts[1], parts[2:])
227
+
228
+ elif cmd == "group":
229
+ if len(parts) < 2:
230
+ await matcher.finish("Usage: /lp group <group> info|permission|parent|meta|editor|setweight|setdisplayname|showtracks|clear|rename|clone|listmembers")
231
+ await _cmd_group(bot, event, matcher, store, parts[1], parts[2:])
232
+
233
+ elif cmd == "track":
234
+ if len(parts) < 2:
235
+ await matcher.finish("Usage: /lp track <track> info|editor|append|insert|remove|clear|rename|clone")
236
+ await _cmd_track(matcher, store, parts[1], parts[2:])
237
+
238
+ else:
239
+ if await _check_lp(bot, event, matcher, "luckperms.help"):
240
+ await matcher.send(f"Unknown command: {cmd}. Use /lp help")
241
+
242
+ except Exception as e:
243
+ logger.exception("Command error")
244
+ await matcher.finish(f"Error: {e}")
245
+
246
+
247
+ # ── User ──────────────────────────────────────────────────────────
248
+
249
+ async def _cmd_user(bot: Bot, event: Event, matcher: Matcher, store, user_id: str, args):
250
+ if not args:
251
+ await matcher.finish("Usage: /lp user <user> info|permission|parent|meta|editor|promote|demote|showtracks|clear|clone")
252
+
253
+ action = args[0].lower()
254
+
255
+ if action == "info":
256
+ if not await _check_lp(bot, event, matcher, "luckperms.user.info"):
257
+ return
258
+ user = await store.get_user(user_id)
259
+ if user is None:
260
+ await matcher.finish(f"User {user_id} not found")
261
+ lines = [
262
+ f"User: {user.user_id}",
263
+ f"Username: {user.username or 'N/A'}",
264
+ f"Primary Group: {user.primary_group or 'N/A'}",
265
+ f"Groups: {', '.join(user.groups) if user.groups else 'None'}",
266
+ f"Nodes ({len(user.nodes)}):",
267
+ ]
268
+ for n in user.nodes:
269
+ ctx_str = n.contexts.to_dict()
270
+ ctx_info = f" ctx={ctx_str}" if ctx_str else ""
271
+ expiry_info = f" expires={n.expiry}" if n.expiry else ""
272
+ lines.append(f" {n.key}={'grant' if n.value else 'deny'}{ctx_info}{expiry_info}")
273
+ await matcher.send("\n".join(lines))
274
+
275
+ elif action == "permission":
276
+ if not await _check_lp(bot, event, matcher, "luckperms.user.permission"):
277
+ return
278
+ await _cmd_permission(matcher, store, user_id, is_user=True, args=args[1:])
279
+
280
+ elif action == "parent":
281
+ if not await _check_lp(bot, event, matcher, "luckperms.user.parent"):
282
+ return
283
+ await _cmd_parent(matcher, store, user_id, is_user=True, args=args[1:])
284
+
285
+ elif action == "meta":
286
+ if not await _check_lp(bot, event, matcher, "luckperms.user.permission"):
287
+ return
288
+ await _cmd_meta(matcher, store, user_id, is_user=True, args=args[1:])
289
+
290
+ elif action == "editor":
291
+ if not await _check_lp(bot, event, matcher, "luckperms.editor"):
292
+ return
293
+ await _cmd_editor(bot, event, matcher, store, [])
294
+
295
+ elif action == "promote":
296
+ if not await _check_lp(bot, event, matcher, "luckperms.user.promote"):
297
+ return
298
+ if len(args) < 2:
299
+ await matcher.finish("Usage: /lp user <user> promote <track> [ctx...]")
300
+ track_name = args[1]
301
+ user = await store.get_user(user_id)
302
+ if user is None:
303
+ user = User(user_id=user_id)
304
+ if track_name not in user.groups:
305
+ user.groups.append(track_name)
306
+ await store.save_user(user)
307
+ await matcher.send(f"User {user_id} promoted to {track_name}")
308
+
309
+ elif action == "demote":
310
+ if not await _check_lp(bot, event, matcher, "luckperms.user.demote"):
311
+ return
312
+ if len(args) < 2:
313
+ await matcher.finish("Usage: /lp user <user> demote <track> [ctx...]")
314
+ track_name = args[1]
315
+ user = await store.get_user(user_id)
316
+ if user is None:
317
+ await matcher.finish(f"User {user_id} not found")
318
+ user.groups = [g for g in user.groups if g != track_name]
319
+ await store.save_user(user)
320
+ await matcher.send(f"User {user_id} demoted from {track_name}")
321
+
322
+ elif action == "showtracks":
323
+ if not await _check_lp(bot, event, matcher, "luckperms.user.info"):
324
+ return
325
+ user = await store.get_user(user_id)
326
+ if user is None:
327
+ await matcher.finish(f"User {user_id} not found")
328
+ await matcher.send(f"Groups for {user_id}: {', '.join(user.groups) if user.groups else 'None'}")
329
+
330
+ elif action == "clear":
331
+ if not await _check_lp(bot, event, matcher, "luckperms.user.clear"):
332
+ return
333
+ ctx = _parse_context(args[1:])
334
+ user = await store.get_user(user_id)
335
+ if user:
336
+ if ctx.is_empty():
337
+ user.nodes = []
338
+ else:
339
+ user.nodes = [n for n in user.nodes if not n.applies_in(ctx)]
340
+ await store.save_user(user)
341
+ await matcher.send(f"User {user_id} permissions cleared")
342
+
343
+ elif action == "clone":
344
+ if not await _check_lp(bot, event, matcher, "luckperms.user.clone"):
345
+ return
346
+ if len(args) < 2:
347
+ await matcher.finish("Usage: /lp user <user> clone <target_user>")
348
+ target = args[1]
349
+ src = await store.get_user(user_id)
350
+ if src is None:
351
+ await matcher.finish(f"User {user_id} not found")
352
+ dst = User(user_id=target, username=src.username, primary_group=src.primary_group,
353
+ groups=list(src.groups), nodes=[PermissionNode(**n.__dict__) for n in src.nodes])
354
+ await store.save_user(dst)
355
+ await matcher.send(f"User {user_id} cloned to {target}")
356
+
357
+ else:
358
+ await matcher.finish(f"Unknown user action: {action}")
359
+
360
+
361
+ # ── Group ─────────────────────────────────────────────────────────
362
+
363
+ async def _cmd_group(bot: Bot, event: Event, matcher: Matcher, store, name: str, args):
364
+ if not args:
365
+ await matcher.finish("Usage: /lp group <group> info|permission|parent|meta|editor|setweight|setdisplayname|showtracks|clear|rename|clone|listmembers")
366
+
367
+ action = args[0].lower()
368
+
369
+ if action == "info":
370
+ if not await _check_lp(bot, event, matcher, "luckperms.group.info"):
371
+ return
372
+ group = await store.get_group(name)
373
+ if group is None:
374
+ await matcher.finish(f"Group {name} not found")
375
+ lines = [
376
+ f"Group: {group.name}",
377
+ f"Display Name: {group.display_name or 'N/A'}",
378
+ f"Weight: {group.weight}",
379
+ f"Parents: {', '.join(group.parents) if group.parents else 'None'}",
380
+ f"Nodes ({len(group.nodes)}):",
381
+ ]
382
+ for n in group.nodes:
383
+ ctx_str = n.contexts.to_dict()
384
+ ctx_info = f" ctx={ctx_str}" if ctx_str else ""
385
+ lines.append(f" {n.key}={'grant' if n.value else 'deny'}{ctx_info}")
386
+ await matcher.send("\n".join(lines))
387
+
388
+ elif action == "permission":
389
+ if not await _check_lp(bot, event, matcher, "luckperms.group.permission"):
390
+ return
391
+ await _cmd_permission(matcher, store, name, is_user=False, args=args[1:])
392
+
393
+ elif action == "parent":
394
+ if not await _check_lp(bot, event, matcher, "luckperms.group.parent"):
395
+ return
396
+ await _cmd_parent(matcher, store, name, is_user=False, args=args[1:])
397
+
398
+ elif action == "meta":
399
+ if not await _check_lp(bot, event, matcher, "luckperms.group.permission"):
400
+ return
401
+ await _cmd_meta(matcher, store, name, is_user=False, args=args[1:])
402
+
403
+ elif action == "editor":
404
+ if not await _check_lp(bot, event, matcher, "luckperms.editor"):
405
+ return
406
+ await _cmd_editor(bot, event, matcher, store, [])
407
+
408
+ elif action == "setweight":
409
+ if not await _check_lp(bot, event, matcher, "luckperms.group.setweight"):
410
+ return
411
+ if len(args) < 2:
412
+ await matcher.finish("Usage: /lp group <group> setweight <weight>")
413
+ group = await store.get_group(name)
414
+ if group is None:
415
+ await matcher.finish(f"Group {name} not found")
416
+ group.weight = int(args[1])
417
+ await store.save_group(group)
418
+ await matcher.send(f"Group {name} weight set to {args[1]}")
419
+
420
+ elif action == "setdisplayname":
421
+ if not await _check_lp(bot, event, matcher, "luckperms.group.setdisplayname"):
422
+ return
423
+ if len(args) < 2:
424
+ await matcher.finish("Usage: /lp group <group> setdisplayname <name>")
425
+ group = await store.get_group(name)
426
+ if group is None:
427
+ await matcher.finish(f"Group {name} not found")
428
+ group.display_name = args[1]
429
+ await store.save_group(group)
430
+ await matcher.send(f"Group {name} display name set to {args[1]}")
431
+
432
+ elif action == "showtracks":
433
+ if not await _check_lp(bot, event, matcher, "luckperms.group.info"):
434
+ return
435
+ group = await store.get_group(name)
436
+ if group is None:
437
+ await matcher.finish(f"Group {name} not found")
438
+ await matcher.send(f"Parents of {name}: {', '.join(group.parents) if group.parents else 'None'}")
439
+
440
+ elif action == "clear":
441
+ if not await _check_lp(bot, event, matcher, "luckperms.group.clear"):
442
+ return
443
+ ctx = _parse_context(args[1:])
444
+ group = await store.get_group(name)
445
+ if group:
446
+ if ctx.is_empty():
447
+ group.nodes = []
448
+ else:
449
+ group.nodes = [n for n in group.nodes if not n.applies_in(ctx)]
450
+ await store.save_group(group)
451
+ await matcher.send(f"Group {name} permissions cleared")
452
+
453
+ elif action == "rename":
454
+ if not await _check_lp(bot, event, matcher, "luckperms.group.rename"):
455
+ return
456
+ if len(args) < 2:
457
+ await matcher.finish("Usage: /lp group <group> rename <new_name>")
458
+ group = await store.get_group(name)
459
+ if group is None:
460
+ await matcher.finish(f"Group {name} not found")
461
+ await store.delete_group(name)
462
+ group.name = args[1]
463
+ await store.save_group(group)
464
+ await matcher.send(f"Group {name} renamed to {args[1]}")
465
+
466
+ elif action == "clone":
467
+ if not await _check_lp(bot, event, matcher, "luckperms.group.clone"):
468
+ return
469
+ if len(args) < 2:
470
+ await matcher.finish("Usage: /lp group <group> clone <new_name>")
471
+ group = await store.get_group(name)
472
+ if group is None:
473
+ await matcher.finish(f"Group {name} not found")
474
+ new_name = args[1]
475
+ new_group = Group(name=new_name, display_name=group.display_name, weight=group.weight,
476
+ nodes=[PermissionNode(**n.__dict__) for n in group.nodes],
477
+ parents=list(group.parents))
478
+ await store.save_group(new_group)
479
+ await matcher.send(f"Group {name} cloned to {new_name}")
480
+
481
+ elif action == "listmembers":
482
+ if not await _check_lp(bot, event, matcher, "luckperms.group.listmembers"):
483
+ return
484
+ users = await store.list_users()
485
+ members = []
486
+ for uid in users:
487
+ u = await store.get_user(uid)
488
+ if u and name in u.groups:
489
+ members.append(uid)
490
+ if not members:
491
+ await matcher.send(f"No members in group {name}")
492
+ else:
493
+ await matcher.send(f"Members of {name}:\n" + "\n".join(f" {m}" for m in members))
494
+
495
+ else:
496
+ await matcher.finish(f"Unknown group action: {action}")
497
+
498
+
499
+ # ── Track ─────────────────────────────────────────────────────────
500
+
501
+ async def _cmd_track(matcher: Matcher, store, track_name: str, args):
502
+ if not args:
503
+ await matcher.finish("Usage: /lp track <track> info|editor|append|insert|remove|clear|rename|clone")
504
+ action = args[0].lower()
505
+ await matcher.send(f"Track commands not yet implemented (stub)")
506
+
507
+
508
+ # ── Permission subcommand ─────────────────────────────────────────
509
+
510
+ async def _cmd_permission(matcher: Matcher, store, entity_id: str, is_user: bool, args):
511
+ if not args:
512
+ await matcher.finish("Usage: permission info|set|unset|settemp|unsettemp|check|clear")
513
+
514
+ sub = args[0].lower()
515
+ entity = await (store.get_user(entity_id) if is_user else store.get_group(entity_id))
516
+ if entity is None:
517
+ entity = User(user_id=entity_id) if is_user else Group(name=entity_id)
518
+
519
+ async def _save():
520
+ if is_user:
521
+ await store.save_user(entity)
522
+ else:
523
+ await store.save_group(entity)
524
+
525
+ if sub == "info":
526
+ if not entity.nodes:
527
+ await matcher.send("No permission nodes")
528
+ else:
529
+ lines = [f"Permissions for {entity_id}:"]
530
+ for n in entity.nodes:
531
+ ctx_str = n.contexts.to_dict()
532
+ ctx_info = f" ctx={ctx_str}" if ctx_str else ""
533
+ expiry_info = f" expires={n.expiry}" if n.expiry else ""
534
+ lines.append(f" {n.key}={'grant' if n.value else 'deny'}{ctx_info}{expiry_info}")
535
+ await matcher.send("\n".join(lines))
536
+ return
537
+
538
+ elif sub == "set":
539
+ if len(args) < 2:
540
+ await matcher.finish("Usage: permission set <node> <true|false> [ctx...]")
541
+ node_key = args[1]
542
+ value = True
543
+ idx = 2
544
+ if len(args) > 2 and args[2].lower() in ("true", "false", "grant", "deny"):
545
+ value = args[2].lower() in ("true", "grant")
546
+ idx = 3
547
+ ctx = _parse_context(args[idx:])
548
+ node = PermissionNode(key=node_key, value=value, contexts=ctx)
549
+ entity.nodes = [n for n in entity.nodes if not (n.key == node.key and n.contexts == node.contexts)]
550
+ entity.nodes.append(node)
551
+ await _save()
552
+ info = f"{'grant' if value else 'deny'} {node_key}"
553
+ if not ctx.is_empty():
554
+ info += f" (ctx: {ctx.to_dict()})"
555
+ await matcher.send(f"{entity_id}: {info}")
556
+
557
+ elif sub == "unset":
558
+ if len(args) < 2:
559
+ await matcher.finish("Usage: permission unset <node> [ctx...]")
560
+ node_key = args[1]
561
+ ctx = _parse_context(args[2:])
562
+ if ctx.is_empty():
563
+ entity.nodes = [n for n in entity.nodes if n.key != node_key]
564
+ else:
565
+ entity.nodes = [n for n in entity.nodes if not (n.key == node_key and n.contexts == ctx)]
566
+ await _save()
567
+ await matcher.send(f"{entity_id}: removed {node_key}")
568
+
569
+ elif sub == "settemp":
570
+ if len(args) < 4:
571
+ await matcher.finish("Usage: permission settemp <node> <true|false> <duration> [ctx...]")
572
+ node_key = args[1]
573
+ value = args[2].lower() in ("true", "grant")
574
+ duration = _parse_duration(args[3])
575
+ if duration is None:
576
+ await matcher.finish("Invalid duration. Use e.g. 30s, 5m, 2h, 1d")
577
+ ctx = _parse_context(args[4:])
578
+ expiry = int(time.time()) + duration
579
+ node = PermissionNode(key=node_key, value=value, expiry=expiry, contexts=ctx)
580
+ entity.nodes = [n for n in entity.nodes if not (n.key == node.key and n.contexts == node.contexts)]
581
+ entity.nodes.append(node)
582
+ await _save()
583
+ await matcher.send(f"{entity_id}: temp {'grant' if value else 'deny'} {node_key} ({args[3]})")
584
+
585
+ elif sub == "unsettemp":
586
+ if len(args) < 2:
587
+ await matcher.finish("Usage: permission unsettemp <node> [duration] [ctx...]")
588
+ node_key = args[1]
589
+ entity.nodes = [n for n in entity.nodes if n.key != node_key]
590
+ await _save()
591
+ await matcher.send(f"{entity_id}: removed temp {node_key}")
592
+
593
+ elif sub == "check":
594
+ if len(args) < 2:
595
+ await matcher.finish("Usage: permission check <node>")
596
+ node_key = args[1]
597
+ ctx = _parse_context(args[2:])
598
+ if is_user:
599
+ opts = type("Opts", (), {
600
+ "mode": "contextual",
601
+ "contexts": ctx,
602
+ "flags": {"include_inherited", "resolve_inheritance"},
603
+ })()
604
+ resolved = await entity.get_effective_nodes(store, opts) if isinstance(entity, User) else {}
605
+ else:
606
+ resolved = {}
607
+ for n in entity.nodes:
608
+ if not n.is_expired() and n.applies_in(ctx):
609
+ resolved[n.key] = n.value
610
+ result = PermissionEngine.check(resolved, node_key)
611
+ await matcher.send(f"{entity_id} check {node_key}: {'ALLOW' if result else 'DENY'}")
612
+
613
+ elif sub == "clear":
614
+ ctx = _parse_context(args[1:])
615
+ if ctx.is_empty():
616
+ entity.nodes = []
617
+ else:
618
+ entity.nodes = [n for n in entity.nodes if not n.applies_in(ctx)]
619
+ await _save()
620
+ await matcher.send(f"{entity_id}: permissions cleared")
621
+
622
+ else:
623
+ await matcher.finish(f"Unknown permission action: {sub}")
624
+
625
+
626
+ # ── Parent subcommand ─────────────────────────────────────────────
627
+
628
+ async def _cmd_parent(matcher: Matcher, store, entity_id: str, is_user: bool, args):
629
+ if not args:
630
+ await matcher.finish("Usage: parent info|set|add|remove|settrack|addtemp|removetemp|clear|cleartrack|switchprimarygroup")
631
+
632
+ sub = args[0].lower()
633
+ entity = await (store.get_user(entity_id) if is_user else store.get_group(entity_id))
634
+ if entity is None:
635
+ entity = User(user_id=entity_id) if is_user else Group(name=entity_id)
636
+
637
+ def _get_parents():
638
+ return entity.groups if is_user else entity.parents
639
+
640
+ async def _save():
641
+ if is_user:
642
+ await store.save_user(entity)
643
+ else:
644
+ await store.save_group(entity)
645
+
646
+ async def _add_parent(gname: str):
647
+ current = _get_parents()
648
+ if gname not in current:
649
+ if is_user:
650
+ entity.groups.append(gname)
651
+ else:
652
+ entity.parents.append(gname)
653
+ await _save()
654
+
655
+ async def _remove_parent(gname: str):
656
+ if is_user:
657
+ entity.groups = [g for g in entity.groups if g != gname]
658
+ else:
659
+ entity.parents = [p for p in entity.parents if p != gname]
660
+ await _save()
661
+
662
+ if sub == "info":
663
+ parents = _get_parents()
664
+ if not parents:
665
+ await matcher.send(f"{entity_id} has no parents")
666
+ else:
667
+ await matcher.send(f"Parents of {entity_id}:\n" + "\n".join(f" {p}" for p in parents))
668
+
669
+ elif sub == "set":
670
+ if len(args) < 2:
671
+ await matcher.finish("Usage: parent set <group> [ctx...]")
672
+ ctx = _parse_context(args[2:])
673
+ if is_user:
674
+ entity.groups = []
675
+ else:
676
+ entity.parents = []
677
+ await _add_parent(args[1])
678
+
679
+ elif sub == "add":
680
+ if len(args) < 2:
681
+ await matcher.finish("Usage: parent add <group> [ctx...]")
682
+ await _add_parent(args[1])
683
+ await matcher.send(f"Added parent {args[1]} to {entity_id}")
684
+
685
+ elif sub == "remove":
686
+ if len(args) < 2:
687
+ await matcher.finish("Usage: parent remove <group> [ctx...]")
688
+ await _remove_parent(args[1])
689
+ await matcher.send(f"Removed parent {args[1]} from {entity_id}")
690
+
691
+ elif sub in ("settrack", "addtemp", "removetemp", "cleartrack"):
692
+ await matcher.send(f"'{sub}' not yet implemented")
693
+
694
+ elif sub == "clear":
695
+ ctx = _parse_context(args[1:])
696
+ if is_user:
697
+ entity.groups = []
698
+ else:
699
+ entity.parents = []
700
+ await _save()
701
+ await matcher.send(f"{entity_id}: parents cleared")
702
+
703
+ elif sub == "switchprimarygroup":
704
+ if len(args) < 2:
705
+ await matcher.finish("Usage: parent switchprimarygroup <group>")
706
+ if is_user:
707
+ entity.primary_group = args[1]
708
+ await _save()
709
+ await matcher.send(f"User {entity_id} primary group set to {args[1]}")
710
+ else:
711
+ await matcher.finish("switchprimarygroup only applies to users")
712
+
713
+ else:
714
+ await matcher.finish(f"Unknown parent action: {sub}")
715
+
716
+
717
+ # ── Meta subcommand (stub) ────────────────────────────────────────
718
+
719
+ async def _cmd_meta(matcher: Matcher, store, entity_id: str, is_user: bool, args):
720
+ if not args:
721
+ await matcher.finish("Usage: meta info|set|unset|settemp|unsettemp|addprefix|addsuffix|setprefix|setsuffix|removeprefix|removesuffix|clear")
722
+ await matcher.send("Meta commands not yet implemented")
723
+
724
+
725
+ # ── Check ─────────────────────────────────────────────────────────
726
+
727
+ async def _cmd_check(matcher: Matcher, store, user_id: str, node_key: str, ctx_args):
728
+ ctx = _parse_context(ctx_args)
729
+ opts = type("Opts", (), {
730
+ "mode": "contextual",
731
+ "contexts": ctx,
732
+ "flags": {"include_inherited", "resolve_inheritance"},
733
+ })()
734
+ user = await store.get_user(user_id)
735
+ if user is None:
736
+ user = User(user_id=user_id)
737
+ resolved = await user.get_effective_nodes(store, opts)
738
+ result = PermissionEngine.check(resolved, node_key)
739
+ lines = [
740
+ f"Check: user={user_id}, node={node_key}",
741
+ f"Context: {ctx.to_dict() or '(empty)'}",
742
+ f"Result: {'ALLOW' if result else 'DENY'}",
743
+ f"Resolved nodes ({len(resolved)}):",
744
+ ]
745
+ for k, v in sorted(resolved.items()):
746
+ lines.append(f" {k}={'grant' if v else 'deny'}")
747
+ await matcher.send("\n".join(lines))
748
+
749
+
750
+ # ── Tree ──────────────────────────────────────────────────────────
751
+
752
+ async def _cmd_tree(matcher: Matcher, store, args):
753
+ scope = args[0] if args else None
754
+ user = await store.get_user(scope) if scope else None
755
+ group = await store.get_group(scope) if scope else None
756
+
757
+ if user:
758
+ lines = [f"User: {user.user_id}"]
759
+ for gname in user.groups:
760
+ g = await store.get_group(gname)
761
+ if g:
762
+ lines.append(f" └── Group: {g.name} (weight={g.weight})")
763
+ for n in g.nodes:
764
+ lines.append(f" ├── {n.key}={'grant' if n.value else 'deny'}")
765
+ await matcher.send("\n".join(lines))
766
+ elif group:
767
+ lines = [f"Group: {group.name} (weight={group.weight})"]
768
+ for n in group.nodes:
769
+ lines.append(f" ├── {n.key}={'grant' if n.value else 'deny'}")
770
+ await matcher.send("\n".join(lines))
771
+ else:
772
+ groups = await store.list_groups()
773
+ if not groups:
774
+ await matcher.send("No groups")
775
+ else:
776
+ lines = ["Groups:"]
777
+ for gname in groups:
778
+ g = await store.get_group(gname)
779
+ if g:
780
+ lines.append(f" {g.name} (weight={g.weight})")
781
+ for n in g.nodes:
782
+ lines.append(f" ├── {n.key}={'grant' if n.value else 'deny'}")
783
+ await matcher.send("\n".join(lines))
784
+
785
+
786
+ # ── Editor ────────────────────────────────────────────────────────
787
+
788
+ async def _cmd_editor(bot: Bot, event: Event, matcher: Matcher, store, args):
789
+ from ..webeditor.manager import to_webeditor_payload, apply_webeditor_changes
790
+ from ..webeditor.session import WebEditorSession
791
+ from ..adapter.identity import get_resolver
792
+
793
+ resolver = get_resolver()
794
+ group_members: list[str] | None = None
795
+ extra_users: list[str] = []
796
+ chat_type = "unknown"
797
+
798
+ try:
799
+ identity = await resolver.resolve(bot, event) if resolver else None
800
+ except Exception:
801
+ identity = None
802
+
803
+ if identity:
804
+ chat_type = identity.platform
805
+ if identity.group_id:
806
+ try:
807
+ from nonebot.adapters.onebot.v11 import Bot as V11Bot
808
+ if isinstance(bot, V11Bot):
809
+ member_list = await bot.get_group_member_list(group_id=int(identity.group_id))
810
+ group_members = [str(m["user_id"]) for m in member_list]
811
+ extra_users = group_members
812
+ except Exception:
813
+ pass
814
+ chat_type = f"group({identity.group_id})"
815
+ else:
816
+ extra_users = [identity.user_id]
817
+
818
+ try:
819
+ payload = await to_webeditor_payload(extra_user_ids=extra_users)
820
+ except RuntimeError as e:
821
+ await matcher.finish(f"Editor error: {e}")
822
+
823
+ payload["metadata"]["context"] = {
824
+ "chat_type": chat_type,
825
+ "group_members": len(group_members) if group_members else 0,
826
+ }
827
+
828
+ session = WebEditorSession(
829
+ get_payload=lambda: payload,
830
+ apply_changes=lambda p: None,
831
+ )
832
+
833
+ async def _apply(p):
834
+ await apply_webeditor_changes(p)
835
+ session.apply_changes = _apply
836
+
837
+ try:
838
+ url = await session.open()
839
+ await matcher.send(msg("editor_opened", url=url))
840
+ except Exception as e:
841
+ await matcher.finish(msg("editor_failed", error=str(e)))
842
+
843
+
844
+ async def _apply_edits(matcher: Matcher, code: str):
845
+ from ..webeditor.manager import apply_webeditor_changes
846
+ from ..webeditor.bytebin import BytebinClient
847
+
848
+ try:
849
+ client = BytebinClient()
850
+ payload = await client.download(code)
851
+ await apply_webeditor_changes(payload)
852
+ store = get_store()
853
+ if store:
854
+ await store.save_all()
855
+ await matcher.send(msg("editor_apply_ok", code=code))
856
+ except Exception as e:
857
+ await matcher.finish(f"Apply failed: {e}")