Add chat actions, channels/groups mgmt, directory + deadmans bots, network status

Profiles/bots (profiles.py, main.py):
- Surface real send errors; fix group send and member-count staleness (refresh on view)
- Group/channel actions: join, leave, owner delete; consistent member counts
- Contacts: always-visible Chat, plus Clear chat and Delete contact
- Support bot: OpenAI-compatible LLM backend (Grok/Ollama/OpenAI) per-bot config
- Deadmans bot: check-in window, trigger message, recipients, owner
- Directory bot: add-to-group registration, super-user /approve /reject /list, search,
  publishes listing.json in the website schema (directory.py registry module)
- Profile edit (name/bio/avatar) + avatars on list pages
- Global status + /network page (operators, SMP/XFTP servers) and Settings network info

UI (templates):
- Chat rooms; collapsible left sidebar with notifications + network widget
- Per-directory-bot website generated on creation (name substituted)
- Matrix theme; copy/hyperlink addresses; site footer

Ignore runtime bot state and generated directory sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-03 21:26:16 +01:00
parent ecce417f6d
commit c1bb9cb955
13 changed files with 1446 additions and 29 deletions

View File

@@ -6,8 +6,6 @@
.qr-wrap { text-align: center; padding: 16px; }
.qr-wrap canvas { border-radius: 8px; }
.row-action { opacity: 0; transition: opacity 0.15s; }
tr:hover .row-action { opacity: 1; }
.msg-btn {
padding: 3px 10px; font-size: 12px; border-radius: 6px;
@@ -17,6 +15,8 @@
transition: background 0.15s, color 0.15s;
}
.msg-btn:hover { background: var(--accent); color: var(--btn-light-text); }
.msg-btn-danger { color: var(--red); border-color: var(--red); }
.msg-btn-danger:hover { background: var(--red); color: #fff; }
.addr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
@@ -56,6 +56,34 @@
<div class="grid-2">
<!-- Left column -->
<div>
<!-- Profile -->
<div class="card">
<div class="flex-between" style="margin-bottom:14px;">
<h2 style="margin:0;">Profile</h2>
<button class="btn btn-ghost" style="padding:6px 14px;font-size:13px;" onclick="openEdit()">Edit</button>
</div>
<div class="flex gap-8" style="align-items:flex-start;">
{% if profile.config.avatar %}
<img src="{{ profile.config.avatar }}" alt="avatar"
style="width:64px;height:64px;border-radius:50%;object-fit:cover;border:1px solid var(--border);flex-shrink:0;">
{% else %}
<div style="width:64px;height:64px;border-radius:50%;background:var(--border);flex-shrink:0;
display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:700;color:var(--muted);">
{{ profile.name[0] | upper }}
</div>
{% endif %}
<div style="min-width:0;">
<div style="font-weight:700;font-size:16px;">{{ profile.name }}</div>
{% if profile.config.full_name %}<div class="muted">{{ profile.config.full_name }}</div>{% endif %}
{% if profile.config.bio %}
<div style="margin-top:6px;font-size:14px;white-space:pre-wrap;">{{ profile.config.bio }}</div>
{% else %}
<div class="muted" style="margin-top:6px;">No bio set.</div>
{% endif %}
</div>
</div>
</div>
<!-- Address / QR -->
<div class="card">
<h2>Address</h2>
@@ -78,15 +106,29 @@
{% endif %}
</div>
{% if profile.bot_type == 'directory' %}
{% set safe = profile.name | lower | replace(' ', '_') %}
<!-- Directory website -->
<div class="card">
<h2>Directory website</h2>
<p class="muted" style="margin-bottom:12px;">Auto-generated listing page for this directory bot.</p>
<div class="addr-row">
<button class="btn btn-ghost copy-btn" title="Copy URL"
onclick="copyAddr(this, location.origin + '/directory/{{ safe }}/index.html')">📋</button>
<a class="addr-link" href="/directory/{{ safe }}/index.html" target="_blank" rel="noopener">/directory/{{ safe }}/index.html</a>
</div>
</div>
{% endif %}
<!-- Config -->
<div class="card">
<h2>Config</h2>
<table>
<tr><th>Key</th><th>Value</th></tr>
{% for k, v in profile.config.items() %}
<tr><td>{{ k }}</td><td>{{ v }}</td></tr>
{% for k, v in profile.config.items() if k not in ['avatar', 'bio', 'full_name'] %}
<tr><td>{{ k }}</td><td>{% if k == 'api_key' %}•••••••• (set){% else %}{{ v }}{% endif %}</td></tr>
{% else %}
<tr><td colspan="2" class="muted">No config set.</td></tr>
<tr><td colspan="2" class="muted">No extra config set.</td></tr>
{% endfor %}
</table>
</div>
@@ -104,8 +146,14 @@
<tr>
<td><strong>{{ c.localDisplayName }}</strong></td>
<td>
<a class="msg-btn row-action" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
<div class="flex gap-8" style="justify-content:flex-end;">
<a class="msg-btn" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
<button class="msg-btn" title="Clear conversation"
onclick="clearChat('direct', {{ c.contactId }}, '{{ c.localDisplayName | e }}')">🧹 Clear</button>
<button class="msg-btn msg-btn-danger" title="Delete contact"
onclick="deleteContact({{ c.contactId }}, '{{ c.localDisplayName | e }}')">🗑 Delete</button>
</div>
</td>
</tr>
{% endfor %}
@@ -122,17 +170,31 @@
{% set name = g.groupProfile.displayName %}
{% set gid = g.groupId %}
{% set mcnt = g.groupSummary.currentMembers %}
{% set invited = (g.membership.memberStatus if g.membership else '') == 'invited' %}
{% set is_owner = (g.membership.memberRole if g.membership else '') == 'owner' %}
<tr>
<td>{{ name }}</td>
<td>
{% if invited %}
<span class="tag" title="You were invited but haven't joined yet">⏳ invited</span>
{% else %}
<button class="msg-btn" style="border:none;padding:0;background:none;color:var(--accent);font-weight:600;font-size:13px;cursor:pointer;"
onclick="loadMembers({{ gid }}, '{{ name | e }}')">{{ mcnt }}</button>
{% endif %}
</td>
<td>
<div class="flex gap-8">
<a class="msg-btn row-action" style="text-decoration:none;"
{% if invited %}
<button class="msg-btn" onclick="joinGroup({{ gid }}, this)">Join</button>
{% else %}
<a class="msg-btn" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/group/{{ gid }}">💬 {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
<button class="msg-btn row-action" onclick="getGroupLink({{ gid }}, this)">Link</button>
<button class="msg-btn" onclick="getGroupLink({{ gid }}, this)">Link</button>
<button class="msg-btn msg-btn-danger" onclick="leaveGroup({{ gid }}, '{{ name | e }}', this)">Leave</button>
{% if is_owner %}
<button class="msg-btn msg-btn-danger" onclick="deleteGroup({{ gid }}, '{{ name | e }}', this)">Delete</button>
{% endif %}
{% endif %}
</div>
</td>
</tr>
@@ -227,6 +289,35 @@
</div>
</dialog>
<!-- Edit profile dialog -->
<dialog id="edit-dialog">
<h2 style="margin-bottom:16px;">Edit Profile</h2>
<div class="field">
<label>Full Name <span class="muted" style="font-weight:400;">(optional)</span></label>
<input type="text" id="edit-fullname">
</div>
<div class="field">
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
<textarea id="edit-bio" rows="2"></textarea>
</div>
<div class="field">
<label>Avatar</label>
<div class="flex gap-8">
<img id="edit-avatar-preview" alt="" style="display:none;width:48px;height:48px;border-radius:50%;object-fit:cover;border:1px solid var(--border);">
<input type="file" accept="image/*" onchange="onEditAvatar(this)" style="flex:1;">
</div>
<button type="button" class="btn btn-ghost" style="margin-top:6px;font-size:12px;padding:4px 10px;"
onclick="removeEditAvatar()">Remove avatar</button>
</div>
<div class="flex-between mt-16">
<span id="edit-result" class="muted" style="font-size:13px;"></span>
<div class="flex gap-8">
<button class="btn btn-ghost" onclick="document.getElementById('edit-dialog').close()">Cancel</button>
<button class="btn btn-primary" onclick="saveProfile()">Save</button>
</div>
</div>
</dialog>
<!-- Send message dialog -->
<dialog id="msg-dialog">
<h2 style="margin-bottom:16px;">Message <span id="msg-target-label" style="color:var(--accent);"></span></h2>
@@ -285,6 +376,88 @@ function copyAddr(btn, addr) {
});
}
// ── Contact actions ────────────────────────────────────────────────────────
function _ctoken() { return document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; }
async function clearChat(type, id, name) {
if (!confirm('Clear the conversation with ' + name + '? Messages are removed; the contact stays.')) return;
const r = await fetch(`/api/profiles/{{ profile.id }}/chat/${type}/${id}/clear`, {
method: 'POST', headers: { 'X-Token': _ctoken() },
});
const d = await r.json();
if (d.ok) { alert('Conversation cleared.'); }
else { alert('Failed: ' + (d.detail || 'unknown')); }
}
async function deleteContact(id, name) {
if (!confirm('Delete contact ' + name + '? This removes them and your conversation.')) return;
const r = await fetch(`/api/profiles/{{ profile.id }}/contacts/${id}`, {
method: 'DELETE', headers: { 'X-Token': _ctoken() },
});
const d = await r.json();
if (d.ok) { location.reload(); }
else { alert('Failed: ' + (d.detail || 'unknown')); }
}
// ── Edit profile ───────────────────────────────────────────────────────────
// editAvatar: null = unchanged, '' = remove, dataURI = replace
let editAvatar = null;
function openEdit() {
editAvatar = null;
document.getElementById('edit-fullname').value = {{ (profile.config.full_name or '') | tojson }};
document.getElementById('edit-bio').value = {{ (profile.config.bio or '') | tojson }};
const prev = document.getElementById('edit-avatar-preview');
const cur = {{ (profile.config.avatar or '') | tojson }};
if (cur) { prev.src = cur; prev.style.display = 'block'; } else { prev.style.display = 'none'; prev.src = ''; }
document.getElementById('edit-result').textContent = '';
document.getElementById('edit-dialog').showModal();
}
function onEditAvatar(input) {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const size = 256, c = document.createElement('canvas');
c.width = size; c.height = size;
const ctx = c.getContext('2d');
const m = Math.min(img.width, img.height);
ctx.drawImage(img, (img.width - m) / 2, (img.height - m) / 2, m, m, 0, 0, size, size);
editAvatar = c.toDataURL('image/jpeg', 0.85);
const prev = document.getElementById('edit-avatar-preview');
prev.src = editAvatar; prev.style.display = 'block';
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function removeEditAvatar() {
editAvatar = '';
const prev = document.getElementById('edit-avatar-preview');
prev.style.display = 'none'; prev.src = '';
}
async function saveProfile() {
const body = {
full_name: document.getElementById('edit-fullname').value,
bio: document.getElementById('edit-bio').value,
};
if (editAvatar !== null) body.avatar = editAvatar; // only send if changed
document.getElementById('edit-result').textContent = 'Saving…';
const resp = await fetch('/api/profiles/{{ profile.id }}/profile', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { document.getElementById('edit-result').textContent = '✗ ' + (data.detail || 'Failed'); }
}
// ── Groups & Channels ──────────────────────────────────────────────────────
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
let _createKind = 'group';
@@ -367,6 +540,38 @@ async function loadMembers(groupId, groupName) {
</table>`;
}
async function joinGroup(groupId, btn) {
btn.textContent = 'Joining…'; btn.disabled = true;
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/join`, {
method: 'POST', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.textContent = 'Join'; btn.disabled = false; alert('Failed to join: ' + (data.detail || 'unknown')); }
}
async function deleteGroup(groupId, name, btn) {
if (!confirm('Delete "' + name + '" for everyone? This removes the group/channel and notifies members.')) return;
btn.disabled = true; btn.textContent = 'Deleting…';
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}`, {
method: 'DELETE', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = 'Delete'; alert('Failed to delete: ' + (data.detail || 'unknown')); }
}
async function leaveGroup(groupId, name, btn) {
if (!confirm('Leave "' + name + '"? You will stop receiving its messages.')) return;
btn.disabled = true; btn.textContent = 'Leaving…';
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/leave`, {
method: 'POST', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = 'Leave'; alert('Failed to leave: ' + (data.detail || 'unknown')); }
}
async function getGroupLink(groupId, btn) {
const orig = btn.textContent;
btn.textContent = '…';