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

@@ -49,6 +49,10 @@
onclick="location.href='/profile/{{ p.id }}'">
<div class="flex-between">
<div class="flex gap-8">
{% if p.config.avatar %}
<img src="{{ p.config.avatar }}" alt=""
style="width:32px;height:32px;border-radius:50%;object-fit:cover;border:1px solid var(--border);flex-shrink:0;">
{% endif %}
<strong>{{ p.name }}</strong>
<span class="tag {% if p.bot_type == 'user' %}tag-user{% endif %}">{{ p.bot_type }}</span>
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
@@ -98,6 +102,17 @@
<label>Name</label>
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
</div>
<div class="field">
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
<textarea name="bio" rows="2" placeholder="A short description shown on the profile"></textarea>
</div>
<div class="field">
<label>Avatar <span class="muted" style="font-weight:400;">(optional image)</span></label>
<div class="flex gap-8">
<img id="avatar-preview" alt="" style="display:none;width:48px;height:48px;border-radius:50%;object-fit:cover;border:1px solid var(--border);">
<input type="file" name="avatar_file" accept="image/*" onchange="onAvatarChange(this)" style="flex:1;">
</div>
</div>
{% if tab == 'bots' %}
<div class="field">
<label>Bot Type</label>
@@ -111,6 +126,63 @@
<label>Welcome Message</label>
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
</div>
<div id="support-fields" style="display:none;">
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
<p class="muted" style="margin-bottom:12px;">
LLM backend (OpenAI-compatible). Leave the URL blank for a static welcome-only bot.
</p>
</div>
<div class="field">
<label>API Base URL</label>
<input type="text" name="api_base" placeholder="https://api.x.ai/v1 (Ollama: http://localhost:11434/v1)">
</div>
<div class="field">
<label>API Key</label>
<input type="password" name="api_key" placeholder="xai-… (any value for Ollama)">
</div>
<div class="field">
<label>Model</label>
<input type="text" name="model" placeholder="grok-2 (Ollama: llama3.2)">
</div>
<div class="field">
<label>System Prompt</label>
<textarea name="system_prompt" rows="3" placeholder="You are a helpful customer-support assistant…"></textarea>
</div>
</div>
<div id="deadmans-fields" style="display:none;">
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
<p class="muted" style="margin-bottom:12px;">
Fires a message to recipients if no check-in arrives in time. Check in by messaging the bot.
</p>
</div>
<div class="field">
<label>Check-in window (hours)</label>
<input type="number" name="checkin_hours" min="0.1" step="0.1" value="24">
</div>
<div class="field">
<label>Trigger message</label>
<textarea name="dms_message" rows="2" placeholder="If you receive this, I haven't checked in…"></textarea>
</div>
<div class="field">
<label>Recipients <span class="muted" style="font-weight:400;">(comma-separated names; blank = all contacts)</span></label>
<input type="text" name="recipients" placeholder="Alice, Bob">
</div>
<div class="field">
<label>Owner <span class="muted" style="font-weight:400;">(only this contact's messages count as check-in; blank = anyone)</span></label>
<input type="text" name="owner" placeholder="Alice">
</div>
</div>
<div id="directory-fields" style="display:none;">
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
<p class="muted" style="margin-bottom:12px;">
Group owners register by adding this bot to their group. Listings stay pending until a super-user approves.
</p>
</div>
<div class="field">
<label>Super-users <span class="muted" style="font-weight:400;">(comma-separated contact names who can /approve)</span></label>
<input type="text" name="superusers" placeholder="Alice, Bob">
</div>
</div>
{% endif %}
<div class="flex gap-8 mt-16" style="justify-content:flex-end;">
<button type="button" class="btn btn-ghost"
@@ -131,12 +203,43 @@ function updateStatus(id, event) {
} catch(e) {}
}
let avatarDataUri = '';
function openCreate() {
document.getElementById('create-form').reset();
avatarDataUri = '';
const prev = document.getElementById('avatar-preview');
prev.style.display = 'none'; prev.src = '';
{% if tab == 'bots' %}onTypeChange();{% endif %}
document.getElementById('create-dialog').showModal();
}
// Read an image file, downscale it to a small square data URI (avatars are sent
// over the wire to every contact, so keep them tiny). Stores result in avatarDataUri.
function onAvatarChange(input) {
const file = input.files && input.files[0];
if (!file) { avatarDataUri = ''; return; }
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
// center-crop to square
const m = Math.min(img.width, img.height);
const sx = (img.width - m) / 2, sy = (img.height - m) / 2;
ctx.drawImage(img, sx, sy, m, m, 0, 0, size, size);
avatarDataUri = canvas.toDataURL('image/jpeg', 0.85);
const prev = document.getElementById('avatar-preview');
prev.src = avatarDataUri; prev.style.display = 'block';
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function copyAddr(ev, btn, addr) {
ev.stopPropagation();
navigator.clipboard.writeText(addr).then(() => {
@@ -148,8 +251,10 @@ function copyAddr(ev, btn, addr) {
{% if tab == 'bots' %}
function onTypeChange() {
const val = document.getElementById('type-select').value;
const hide = ['echo'].includes(val); // echo has no welcome msg
document.getElementById('welcome-field').style.display = hide ? 'none' : '';
document.getElementById('welcome-field').style.display = (val === 'echo') ? 'none' : '';
document.getElementById('support-fields').style.display = (val === 'support') ? 'block' : 'none';
document.getElementById('deadmans-fields').style.display = (val === 'deadmans') ? 'block' : 'none';
document.getElementById('directory-fields').style.display = (val === 'directory') ? 'block' : 'none';
}
{% endif %}
@@ -164,7 +269,35 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
const config = {};
const welcome = fd.get('welcome_message');
if (welcome) config.welcome_message = welcome;
if (botType === 'support') {
const apiBase = (fd.get('api_base') || '').trim();
if (apiBase) config.api_base = apiBase;
const apiKey = (fd.get('api_key') || '').trim();
if (apiKey) config.api_key = apiKey;
const model = (fd.get('model') || '').trim();
if (model) config.model = model;
const sysPrompt = (fd.get('system_prompt') || '').trim();
if (sysPrompt) config.system_prompt = sysPrompt;
}
if (botType === 'deadmans') {
const hrs = parseFloat(fd.get('checkin_hours'));
if (!isNaN(hrs) && hrs > 0) config.checkin_hours = hrs;
const dmsMsg = (fd.get('dms_message') || '').trim();
if (dmsMsg) config.message = dmsMsg;
const recips = (fd.get('recipients') || '').split(',').map(s => s.trim()).filter(Boolean);
if (recips.length) config.recipients = recips;
const owner = (fd.get('owner') || '').trim();
if (owner) config.owner = owner;
}
if (botType === 'directory') {
const su = (fd.get('superusers') || '').split(',').map(s => s.trim()).filter(Boolean);
if (su.length) config.superusers = su;
}
{% endif %}
// Shared profile fields (users and bots)
const bio = (fd.get('bio') || '').trim();
if (bio) config.bio = bio;
if (avatarDataUri) config.avatar = avatarDataUri;
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
const resp = await fetch('/api/profiles', {
method: 'POST',