205 lines
8.0 KiB
Python
205 lines
8.0 KiB
Python
"""Identity editor and current assistant name, authenticated by Alfred's admin UI.
|
||
|
||
Identity text stays in config/identity.md. All writes are atomic; dated revisions
|
||
are kept separately. Technical names/paths/API identifiers are never renamed.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import tempfile
|
||
import threading
|
||
from datetime import datetime
|
||
|
||
from flask import Blueprint, jsonify, render_template, request
|
||
from services import admin_console
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
CONFIG = ROOT / 'config'
|
||
IDENTITY = CONFIG / 'identity.md'
|
||
TRACKER = CONFIG / 'identity_name_state.json'
|
||
HISTORY = CONFIG / 'identity_history'
|
||
LOCK = threading.RLock()
|
||
MAX_SIZE = 40_000
|
||
ASSISTANT_RE = re.compile(r"(?<![A-Za-z0-9_.-])Alfred(?![A-Za-z0-9_.-])")
|
||
|
||
|
||
def active_name():
|
||
name = (os.getenv('ALFRED_NAME') or 'Alfred').strip()
|
||
return name if 2 <= len(name) <= 30 else 'Alfred'
|
||
|
||
|
||
def identity_path():
|
||
# Avoid an admin Web editor writing outside the identity config directory.
|
||
raw = Path(os.getenv('ALFRED_IDENTITY_FILE', str(IDENTITY))).expanduser()
|
||
resolved = raw.resolve()
|
||
if resolved.parent != CONFIG.resolve() or raw.is_symlink():
|
||
raise ValueError('Le fichier ALFRED_IDENTITY_FILE est externe à config/ : édition Web refusée.')
|
||
return resolved
|
||
|
||
|
||
def sha(text):
|
||
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
||
|
||
|
||
def _read_text():
|
||
path = identity_path()
|
||
return path.read_text(encoding='utf-8') if path.exists() else ''
|
||
|
||
|
||
def _save_atomic(path, content, mode=0o600):
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
fd, temp = tempfile.mkstemp(dir=str(path.parent), prefix='.identity-', suffix='.tmp')
|
||
try:
|
||
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
|
||
fh.write(content)
|
||
fh.flush()
|
||
os.fsync(fh.fileno())
|
||
os.chmod(temp, mode)
|
||
os.replace(temp, path)
|
||
finally:
|
||
if os.path.exists(temp):
|
||
os.unlink(temp)
|
||
|
||
|
||
def _revision(text, label):
|
||
HISTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||
filename = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + '_' + label + '.md'
|
||
dest = HISTORY / filename
|
||
fd = os.open(dest, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600)
|
||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||
f.write(text)
|
||
|
||
|
||
def _old_name(text):
|
||
if TRACKER.exists():
|
||
try:
|
||
data = json.loads(TRACKER.read_text(encoding='utf-8'))
|
||
value = data.get('name', '')
|
||
if isinstance(value, str) and 2 <= len(value) <= 30:
|
||
return value
|
||
except (OSError, ValueError, TypeError):
|
||
pass
|
||
# Existing identity files tend to contain "Tu t'appelles Alfred".
|
||
match = re.search(r"\bTu\s+t['’]appelles\s+([A-Za-zÀ-ÖØ-öø-ÿ-]{2,30})\b", text, flags=re.I)
|
||
if match:
|
||
return match.group(1)
|
||
return 'Alfred'
|
||
|
||
|
||
def _replace_persona_name(text, old, new):
|
||
if old == new:
|
||
return text
|
||
# Replace proper standalone name only; not ALFRED_NAME, alfred.service,
|
||
# MCP_alfred_timers, /opt/alfred etc.
|
||
pattern = re.compile(r'(?<![A-Za-z0-9_.-])' + re.escape(old) + r'(?![A-Za-z0-9_-]|\.[A-Za-z0-9_-])')
|
||
changed = pattern.sub(lambda _m: new, text)
|
||
if old != new and old and old[0].lower() in 'aeiouy' and new and new[0].lower() not in 'aeiouyh':
|
||
changed = changed.replace("d'" + new, 'de ' + new).replace('d’' + new, 'de ' + new)
|
||
if changed == text:
|
||
# No existing self-name found: insert an explicit, visible identity rule.
|
||
changed = f'# Identité actuelle\nTu t’appelles {new}.\n\n' + text
|
||
return changed
|
||
|
||
|
||
def sync_identity_name(name=None):
|
||
"""Called on startup and after a /config rename. Never erases custom prose."""
|
||
new = name or active_name()
|
||
if not isinstance(new, str) or not 2 <= len(new) <= 30 or any(c in new for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
|
||
raise ValueError('Nom d’assistant invalide.')
|
||
with LOCK:
|
||
path = identity_path()
|
||
original = _read_text()
|
||
if not original.strip():
|
||
# No identity installed: don't silently create a new persona.
|
||
raise ValueError('Le fichier identity.md est vide ou absent.')
|
||
old = _old_name(original)
|
||
updated = _replace_persona_name(original, old, new)
|
||
if updated != original:
|
||
_revision(original, 'avant_nom')
|
||
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
|
||
_save_atomic(path, updated, mode)
|
||
# Keep original metadata if the file text was unchanged; preserve editability.
|
||
if not TRACKER.exists() or old != new:
|
||
_save_atomic(TRACKER, json.dumps({'name':new}, ensure_ascii=False) + '\n')
|
||
return {'changed': updated != original, 'name':new}
|
||
|
||
|
||
def identity_for_hermes(text):
|
||
"""Runtime guard: current name wins even if a legacy identity wasn't synced."""
|
||
new = active_name()
|
||
old = _old_name(text)
|
||
content = _replace_persona_name(text, old, new) if text else text
|
||
return ('Règle d’identité prioritaire : ton nom actuel est ' + new +
|
||
'. Si l’on demande ton nom, réponds « Je m’appelle ' + new + ' ».\n\n' + content).strip()
|
||
|
||
|
||
def register_identity_page(app):
|
||
bp = Blueprint('alfred_identity_glass', __name__)
|
||
|
||
@bp.get('/identity')
|
||
@bp.get('/identity/')
|
||
def page():
|
||
# Never embed identity text or secrets in unauthenticated HTML.
|
||
return render_template('alfred_identity_glass.html')
|
||
|
||
@bp.get('/api/alfred/identity/name')
|
||
def name_api():
|
||
res = jsonify(name=active_name(), wake_word=os.getenv('WAKE_WORD', 'alfred'))
|
||
res.headers['Cache-Control'] = 'no-store'
|
||
return res
|
||
|
||
@bp.get('/api/control/identity')
|
||
@admin_console._require_admin
|
||
def get_identity():
|
||
try:
|
||
with LOCK:
|
||
content = _read_text()
|
||
except (OSError, ValueError) as e:
|
||
return jsonify(error=str(e)), 503
|
||
res = jsonify(name=active_name(), text=content, revision=sha(content),
|
||
file='config/identity.md')
|
||
res.headers['Cache-Control'] = 'no-store, private'
|
||
return res
|
||
|
||
@bp.post('/api/control/identity')
|
||
@admin_console._require_admin
|
||
def save_identity():
|
||
data = request.get_json(silent=True)
|
||
if not isinstance(data, dict):
|
||
return jsonify(error='Données JSON invalides.'), 400
|
||
text = data.get('text')
|
||
revision = data.get('revision')
|
||
if not isinstance(text, str) or not isinstance(revision, str):
|
||
return jsonify(error='Texte ou révision manquante.'), 400
|
||
if not text.strip() or len(text.encode('utf-8')) > MAX_SIZE or '\x00' in text:
|
||
return jsonify(error='L’identité doit être non vide et ne pas dépasser 40 Ko.'), 400
|
||
if not re.fullmatch(r'[0-9a-f]{64}', revision):
|
||
return jsonify(error='Révision invalide.'), 400
|
||
try:
|
||
with LOCK:
|
||
path = identity_path()
|
||
old = _read_text()
|
||
if sha(old) != revision:
|
||
return jsonify(error='Le fichier a changé depuis ton chargement. Recharge avant d’enregistrer.'), 409
|
||
if text != old:
|
||
_revision(old, 'avant_edition')
|
||
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
|
||
_save_atomic(path, text, mode)
|
||
res = jsonify(ok=True, revision=sha(text), name=active_name(),
|
||
updated=text != old, file='config/identity.md')
|
||
res.headers['Cache-Control'] = 'no-store, private'
|
||
return res
|
||
except (OSError, ValueError) as e:
|
||
return jsonify(error='Enregistrement impossible : '+str(e)), 500
|
||
|
||
app.register_blueprint(bp)
|
||
# If a renamed assistant was configured before installation, reconcile on startup.
|
||
try:
|
||
sync_identity_name()
|
||
except (OSError, ValueError) as e:
|
||
app.logger.warning('Synchronisation identité différée : %s', e)
|