wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
# 10 — Raum-Administration & Moderation
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Power Levels vollständig](#power-levels-vollständig)
|
||||
- [Moderation: Kick, Ban, Unban](#moderation-kick-ban-unban)
|
||||
- [Server-Side Room Admin](#server-side-room-admin)
|
||||
- [Raum-Einstellungen komplett](#raum-einstellungen-komplett)
|
||||
- [Raum-Alias verwalten](#raum-alias-verwalten)
|
||||
- [Server ACL (IP-Sperren)](#server-acl-ip-sperren)
|
||||
- [Raum-Audit / State inspizieren](#raum-audit--state-inspizieren)
|
||||
- [Widgets / Bots verwalten](#widgets--bots-verwalten)
|
||||
- [Room Tombstone / Upgrade](#room-tombstone--upgrade)
|
||||
- [Reporting (Melden)](#reporting-melden)
|
||||
|
||||
---
|
||||
|
||||
## Power Levels vollständig
|
||||
|
||||
```dart
|
||||
// Power Level eines Users ändern
|
||||
await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin
|
||||
|
||||
// Alle Power Levels auf einmal setzen
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomPowerLevels,
|
||||
{
|
||||
// Welche Power-Level für Standard-Aktionen benötigt werden:
|
||||
'ban': 50,
|
||||
'kick': 50,
|
||||
'invite': 0,
|
||||
'redact': 50,
|
||||
'events_default': 0, // Nachrichten senden
|
||||
'state_default': 50, // State-Events ändern
|
||||
|
||||
// Spezifische Event-Typen:
|
||||
'events': {
|
||||
'm.room.name': 50,
|
||||
'm.room.topic': 50,
|
||||
'm.room.avatar': 50,
|
||||
'm.room.power_levels': 100,
|
||||
'm.room.canonical_alias': 50,
|
||||
'm.room.history_visibility': 100,
|
||||
'm.room.encryption': 100,
|
||||
'm.space.child': 50,
|
||||
},
|
||||
|
||||
// User-Overrides:
|
||||
'users': {
|
||||
'@admin:server': 100,
|
||||
'@moderator:server': 50,
|
||||
},
|
||||
'users_default': 0, // Alle anderen User
|
||||
'notifications': {
|
||||
'room': 50, // @room mention power level
|
||||
},
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Power Level lesen
|
||||
final plContent = room.getState(EventTypes.RoomPowerLevels)?.content;
|
||||
final usersMap = plContent?.tryGetMap<String, dynamic>('users') ?? {};
|
||||
final adminPl = usersMap['@alice:server'] as int? ?? 0;
|
||||
|
||||
// Berechtigungen prüfen
|
||||
room.ownPowerLevel; // int: eigener PL
|
||||
room.getPowerLevelByUserId('@alice:server'); // int
|
||||
room.canBan; // bool
|
||||
room.canKick; // bool
|
||||
room.canInvite; // bool
|
||||
room.canRedactEvent(event); // bool
|
||||
room.canSendEvent(EventTypes.Message); // bool
|
||||
room.canSendEvent(EventTypes.RoomName); // bool
|
||||
room.canChangeStateEvent(EventTypes.RoomTopic); // bool
|
||||
room.canChangeNameAndTopic; // bool
|
||||
room.canChangeJoinRules; // bool
|
||||
room.canChangeHistoryVisibility; // bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Moderation: Kick, Ban, Unban
|
||||
|
||||
```dart
|
||||
// Kick (User wird entfernt, kann wieder beitreten)
|
||||
await room.kick('@alice:server', reason: 'Verstoß gegen Regeln');
|
||||
|
||||
// Ban (User kann nicht mehr beitreten)
|
||||
await room.ban('@alice:server', reason: 'Dauerhafter Ausschluss');
|
||||
|
||||
// Unban
|
||||
await room.unban('@alice:server');
|
||||
|
||||
// Alle gebannten User anzeigen
|
||||
final bannedMembers = room.getParticipants(
|
||||
membershipFilter: [Membership.ban],
|
||||
);
|
||||
|
||||
// Eigene Berechtigungen prüfen vor Moderation-Aktionen
|
||||
if (room.canKick) {
|
||||
await room.kick(userId);
|
||||
}
|
||||
if (room.canBan) {
|
||||
await room.ban(userId);
|
||||
}
|
||||
|
||||
// User-PL prüfen (kann man diesen User kicken/bannen?)
|
||||
// Man kann nur User mit niedrigerem PL als dem eigenen moderieren.
|
||||
final theirPl = room.getPowerLevelByUserId(userId);
|
||||
final myPl = room.ownPowerLevel;
|
||||
final canModerate = myPl > theirPl;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Room Admin
|
||||
|
||||
> ⚠️ **Pyramid läuft auf Continuwuity** — Synapse Admin API (`/_synapse/admin/`) **existiert nicht!**
|
||||
> Für vollständige Continuwuity-Admin-Dokumentation: **[→ 13-server-admin-continuwuity.md](13-server-admin-continuwuity.md)**
|
||||
|
||||
```dart
|
||||
// Server-Admin-Status prüfen (Matrix-Standard, funktioniert überall)
|
||||
final isAdmin = await client.getIsAdmin();
|
||||
|
||||
// Für Continuwuity: Admin-Aktionen über Standard Matrix Client API
|
||||
// oder über den Admin-Room (#admins:steggi-matrix.work)
|
||||
|
||||
// Power Level in einem Raum als Admin setzen:
|
||||
await client.setRoomStateWithKey(
|
||||
room.id,
|
||||
EventTypes.RoomPowerLevels,
|
||||
'',
|
||||
{
|
||||
'users': {'@admin:server': 100},
|
||||
'users_default': 0,
|
||||
'state_default': 50,
|
||||
'events_default': 0,
|
||||
'ban': 50, 'kick': 50, 'invite': 0, 'redact': 50,
|
||||
},
|
||||
);
|
||||
|
||||
// Raum beitreten (Admin kann invite-only-Räume per API beitreten):
|
||||
await client.joinRoom(room.id);
|
||||
|
||||
// Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
||||
await client.setRoomStateWithKey(
|
||||
spaceId,
|
||||
EventTypes.SpaceChild,
|
||||
channelRoomId,
|
||||
{'via': [client.homeserver.host], 'suggested': false},
|
||||
);
|
||||
```
|
||||
|
||||
```bash
|
||||
# Continuwuity Admin HTTP API
|
||||
# Basis: https://server/_conduwuit/admin/v1/
|
||||
|
||||
# User zu Server-Admin machen:
|
||||
curl -X POST "https://server/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@admin:server"}'
|
||||
|
||||
# Power Level in Raum setzen (Standard Matrix API, mit Admin-Token):
|
||||
curl -X PUT "https://server/_matrix/client/v3/rooms/!roomId:server/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"users": {"@admin:server": 100}, "users_default": 0, "state_default": 50}'
|
||||
|
||||
# Für Synapse (falls Server-Wechsel zu Synapse):
|
||||
# curl .../server/_synapse/admin/v1/rooms/{roomId} ← dann wieder relevant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Einstellungen komplett
|
||||
|
||||
```dart
|
||||
// Name
|
||||
await room.setName('Neuer Raumname');
|
||||
|
||||
// Thema
|
||||
await room.setDescription('Neues Thema');
|
||||
|
||||
// Avatar
|
||||
final mxcUri = await client.uploadContent(imageBytes, filename: 'avatar.png');
|
||||
await room.setAvatar(mxcUri);
|
||||
|
||||
// Join-Rules
|
||||
await room.setJoinRules(JoinRules.invite); // Privat
|
||||
await room.setJoinRules(JoinRules.public); // Öffentlich
|
||||
await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen
|
||||
|
||||
// Restricted (Nur Space-Mitglieder können beitreten)
|
||||
await room.setJoinRules(
|
||||
JoinRules.restricted,
|
||||
allow: [
|
||||
SpaceRoomJoinRulesAllowInner(
|
||||
roomId: '!spaceId:server',
|
||||
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// History-Visibility
|
||||
await room.setHistoryVisibility(HistoryVisibility.shared);
|
||||
// .shared = alle sehen vergangene Nachrichten (nach Join)
|
||||
// .invited = sieht Nachrichten ab dem Einladungs-Zeitpunkt
|
||||
// .joined = sieht Nachrichten nur ab dem Beitritts-Zeitpunkt
|
||||
// .worldReadable = öffentlich lesbar (auch ohne Account)
|
||||
|
||||
// Guest Access
|
||||
await room.setGuestAccess(GuestAccess.canJoin); // Gäste erlaubt
|
||||
await room.setGuestAccess(GuestAccess.forbidden); // Keine Gäste
|
||||
|
||||
// E2EE aktivieren (einmalig, nicht deaktivierbar!)
|
||||
await room.enableEncryption();
|
||||
|
||||
// Notifications für @room: Welcher PL nötig?
|
||||
// → über Power Levels 'notifications.room' setzen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Alias verwalten
|
||||
|
||||
```dart
|
||||
// Alias erstellen
|
||||
await client.setRoomAlias('#neuer-alias:server', room.id);
|
||||
|
||||
// Kanonischen Alias setzen (sichtbarer Hauptalias)
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomCanonicalAlias,
|
||||
{
|
||||
'alias': '#hauptalias:server',
|
||||
'alt_aliases': ['#alternativ:server'],
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Alias auflösen → Raum-ID
|
||||
final resolved = await client.getRoomIdByAlias('#alias:server');
|
||||
print(resolved.roomId); // !roomId:server
|
||||
|
||||
// Alias löschen
|
||||
await client.deleteRoomAlias('#alter-alias:server');
|
||||
|
||||
// Alle Aliases eines Raums
|
||||
final aliases = (room.getState(EventTypes.RoomCanonicalAlias)
|
||||
?.content['alt_aliases'] as List?)
|
||||
?.cast<String>() ?? [];
|
||||
final canonical = room.canonicalAlias;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server ACL (IP-Sperren)
|
||||
|
||||
```dart
|
||||
// Server ACL: Welche Server dürfen am Raum teilnehmen?
|
||||
// Wichtig für öffentliche Räume um Spam-Server zu sperren.
|
||||
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.room.server_acl',
|
||||
{
|
||||
'allow': ['*'], // Alle erlauben
|
||||
'deny': ['spam.example.org', '*.badserver.org'],
|
||||
'allow_ip_literals': false, // IP-Adressen als Server-Namen verboten
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// ACL lesen
|
||||
final acl = room.getState('m.room.server_acl')?.content;
|
||||
final denied = (acl?['deny'] as List?)?.cast<String>() ?? [];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Audit / State inspizieren
|
||||
|
||||
```dart
|
||||
// Alle State-Events lesen
|
||||
final states = room.states;
|
||||
// Map<EventType, Map<StateKey, StrippedStateEvent>>
|
||||
|
||||
// Spezifischer State
|
||||
final nameEvent = room.getState(EventTypes.RoomName);
|
||||
final topicEvent = room.getState(EventTypes.RoomTopic);
|
||||
final memberEvent = room.getState(EventTypes.RoomMember, '@alice:server');
|
||||
final encEvent = room.getState(EventTypes.Encryption);
|
||||
|
||||
// Alle Mitglieder-States
|
||||
final memberStates = states[EventTypes.RoomMember] ?? {};
|
||||
for (final entry in memberStates.entries) {
|
||||
final mxid = entry.key; // stateKey = MXID
|
||||
final membership = entry.value.content['membership'];
|
||||
print('$mxid: $membership');
|
||||
}
|
||||
|
||||
// Raum-History vom Server laden (Admin)
|
||||
final response = await client.request(
|
||||
RequestType.GET,
|
||||
'/rooms/${room.id}/state',
|
||||
);
|
||||
|
||||
// Event-Timeline abrufen (Admin)
|
||||
final timeline = await client.request(
|
||||
RequestType.GET,
|
||||
'/rooms/${room.id}/messages',
|
||||
query: {'limit': '100', 'dir': 'b'},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Widgets / Bots verwalten
|
||||
|
||||
```dart
|
||||
// Widget hinzufügen (z.B. Jitsi, Etherpad)
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.widget',
|
||||
{
|
||||
'type': 'jitsi',
|
||||
'url': 'https://jitsi.example.org/#!roomId',
|
||||
'name': 'Jitsi Call',
|
||||
'data': {'domain': 'jitsi.example.org'},
|
||||
'waitForIframeLoad': true,
|
||||
'creatorUserId': client.userID,
|
||||
'id': 'jitsi-widget',
|
||||
},
|
||||
stateKey: 'jitsi-widget',
|
||||
);
|
||||
|
||||
// Widget entfernen
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.widget',
|
||||
{}, // Leeres Content = entfernen
|
||||
stateKey: 'jitsi-widget',
|
||||
);
|
||||
|
||||
// Alle Widgets lesen
|
||||
final widgets = room.states['m.widget'] ?? {};
|
||||
for (final widget in widgets.values) {
|
||||
print(widget.content['name']);
|
||||
print(widget.content['url']);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Room Tombstone / Upgrade
|
||||
|
||||
```dart
|
||||
// Raum-Upgrade (Nachfolger-Raum erstellen)
|
||||
final newRoomId = await client.upgradeRoom(
|
||||
room.id,
|
||||
newVersion: '11', // Aktuelle empfohlene Version
|
||||
);
|
||||
// SDK sendet m.room.tombstone in alten Raum
|
||||
// Und m.room.create mit predecessor in neuen Raum
|
||||
// Alle Mitglieder werden in neuen Raum eingeladen
|
||||
|
||||
// Tombstone lesen
|
||||
final tombstone = room.getState(EventTypes.RoomTombstone);
|
||||
if (tombstone != null) {
|
||||
final replacementRoomId = tombstone.content['replacement_room'] as String;
|
||||
final serverMessage = tombstone.content['body'] as String;
|
||||
// → Zum neuen Raum navigieren
|
||||
}
|
||||
|
||||
// Predecessor lesen (im neuen Raum)
|
||||
final create = room.getState(EventTypes.RoomCreate);
|
||||
final predecessor = create?.content['predecessor'] as Map?;
|
||||
if (predecessor != null) {
|
||||
final oldRoomId = predecessor['room_id'] as String;
|
||||
final lastEventId = predecessor['event_id'] as String;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reporting (Melden)
|
||||
|
||||
```dart
|
||||
// Event melden (z.B. Spam, illegaler Inhalt)
|
||||
await client.reportContent(
|
||||
room.id,
|
||||
event.eventId,
|
||||
reason: 'Spam/Belästigung',
|
||||
score: -100, // -100 = sehr schlimm, 0 = nicht schlimm
|
||||
);
|
||||
|
||||
// User melden (via MSC4151, wenn vom Server unterstützt)
|
||||
await client.httpClient.post(
|
||||
Uri.parse('${client.homeserver}/_matrix/client/v3/report/${Uri.encodeComponent("@user:server")}'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer ${client.accessToken}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({'reason': 'Belästigung'}),
|
||||
);
|
||||
```
|
||||
Reference in New Issue
Block a user