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:
@@ -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 = '…';
|
||||
|
||||
Reference in New Issue
Block a user