25ed765a03
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
411 lines
11 KiB
Markdown
411 lines
11 KiB
Markdown
# 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'}),
|
|
);
|
|
```
|