Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
188 lines
6.5 KiB
JavaScript
188 lines
6.5 KiB
JavaScript
// ============================================================
|
|
// PES Mini App Controller
|
|
// ============================================================
|
|
// Serves the Telegram Mini App HTML and provides data endpoints
|
|
// These endpoints are public (no JWT) for Telegram WebApp access
|
|
// ============================================================
|
|
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { getStatus, getState } from '../../../services/pes/bridge.js';
|
|
import Database from 'better-sqlite3';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const PES_ROOT = join(__dirname, '../../../..', 'pes-core');
|
|
const MINIAPP_HTML = join(PES_ROOT, 'miniapp', 'index.html');
|
|
const DB_FILE = join(PES_ROOT, 'pes-data', 'bublik.db');
|
|
|
|
let _db = null;
|
|
function getDb() {
|
|
if (_db) return _db;
|
|
if (!existsSync(DB_FILE)) return null;
|
|
try {
|
|
_db = new Database(DB_FILE, { readonly: true, fileMustExist: true });
|
|
_db.pragma('journal_mode = WAL');
|
|
return _db;
|
|
} catch { return null; }
|
|
}
|
|
|
|
export default function registerAppRoutes(router) {
|
|
|
|
// ── Serve Mini App HTML ──
|
|
router.get('/app', (req, res) => {
|
|
try {
|
|
if (!existsSync(MINIAPP_HTML)) {
|
|
return res.status(404).send('Mini App not found');
|
|
}
|
|
const html = readFileSync(MINIAPP_HTML, 'utf8');
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.send(html);
|
|
} catch (err) {
|
|
res.status(500).send('Error loading Mini App');
|
|
}
|
|
});
|
|
|
|
// ── Status (public) ──
|
|
router.get('/app/status', (req, res) => {
|
|
try {
|
|
const status = getStatus();
|
|
res.json(status);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to get status' });
|
|
}
|
|
});
|
|
|
|
// ── Notes ──
|
|
router.get('/app/notes', (req, res) => {
|
|
const db = getDb();
|
|
if (!db) return res.json([]);
|
|
try {
|
|
const notes = db.prepare(
|
|
'SELECT id, text, tags, category, pinned, remind_at, created_at FROM owner_notes WHERE is_deleted = 0 ORDER BY pinned DESC, id DESC LIMIT 50'
|
|
).all();
|
|
res.json(notes);
|
|
} catch { res.json([]); }
|
|
});
|
|
|
|
// ── Reminders (pending only) ──
|
|
router.get('/app/reminders', (req, res) => {
|
|
const db = getDb();
|
|
if (!db) return res.json([]);
|
|
try {
|
|
const reminders = db.prepare(
|
|
'SELECT id, text, remind_at, created_at FROM reminders WHERE delivered = 0 ORDER BY remind_at ASC LIMIT 20'
|
|
).all();
|
|
res.json(reminders);
|
|
} catch { res.json([]); }
|
|
});
|
|
|
|
// ── Unlocks ──
|
|
router.get('/app/unlocks', (req, res) => {
|
|
const db = getDb();
|
|
if (!db) return res.json([]);
|
|
try {
|
|
const unlocks = db.prepare(
|
|
'SELECT feature, level_req, unlocked, unlocked_at, category FROM unlocks ORDER BY level_req ASC'
|
|
).all();
|
|
res.json(unlocks);
|
|
} catch { res.json([]); }
|
|
});
|
|
|
|
// ── Timeline (recent interactions) ──
|
|
router.get('/app/timeline', (req, res) => {
|
|
const db = getDb();
|
|
if (!db) return res.json([]);
|
|
try {
|
|
const items = db.prepare(
|
|
'SELECT id, timestamp, actor, action_type, emotion_before, emotion_after, xp_gained FROM interactions ORDER BY id DESC LIMIT 30'
|
|
).all();
|
|
res.json(items);
|
|
} catch { res.json([]); }
|
|
});
|
|
|
|
// ── Contacts ──
|
|
router.get('/app/contacts', (req, res) => {
|
|
const db = getDb();
|
|
if (!db) return res.json([]);
|
|
try {
|
|
const contacts = db.prepare(
|
|
'SELECT id, name, phone, email, birthday, relationship, notes, created_at FROM owner_contacts WHERE is_deleted = 0 ORDER BY name ASC LIMIT 50'
|
|
).all();
|
|
res.json(contacts);
|
|
} catch { res.json([]); }
|
|
});
|
|
|
|
// ── Files ──
|
|
router.get('/app/files', (req, res) => {
|
|
const db = getDb();
|
|
if (!db) return res.json([]);
|
|
try {
|
|
const files = db.prepare(
|
|
'SELECT id, file_type, file_name, description, tags, created_at FROM owner_files WHERE is_deleted = 0 ORDER BY id DESC LIMIT 50'
|
|
).all();
|
|
res.json(files);
|
|
} catch { res.json([]); }
|
|
});
|
|
|
|
// ── Search (notes + contacts + files) ──
|
|
router.get('/app/search', (req, res) => {
|
|
const db = getDb();
|
|
const q = req.query.q;
|
|
if (!db || !q) return res.json({ notes: [], contacts: [], files: [] });
|
|
try {
|
|
const like = `%${q}%`;
|
|
const notes = db.prepare(
|
|
'SELECT id, text, tags, category, pinned, created_at FROM owner_notes WHERE is_deleted = 0 AND text LIKE ? LIMIT 20'
|
|
).all(like);
|
|
const contacts = db.prepare(
|
|
'SELECT id, name, phone, email, relationship, notes FROM owner_contacts WHERE is_deleted = 0 AND (name LIKE ? OR phone LIKE ? OR notes LIKE ?) LIMIT 20'
|
|
).all(like, like, like);
|
|
const files = db.prepare(
|
|
'SELECT id, file_type, file_name, description FROM owner_files WHERE is_deleted = 0 AND (file_name LIKE ? OR description LIKE ?) LIMIT 20'
|
|
).all(like, like);
|
|
res.json({ notes, contacts, files });
|
|
} catch { res.json({ notes: [], contacts: [], files: [] }); }
|
|
});
|
|
|
|
// ── Action (pet/feed/play/talk) ──
|
|
router.post('/app/action', (req, res) => {
|
|
const { action } = req.body || {};
|
|
if (!action) return res.status(400).json({ error: 'Missing action' });
|
|
|
|
// Write action as CRM event for PES to consume
|
|
try {
|
|
const eventsFile = join(PES_ROOT, 'pes-data', 'crm-events.json');
|
|
let events = [];
|
|
if (existsSync(eventsFile)) {
|
|
events = JSON.parse(readFileSync(eventsFile, 'utf8'));
|
|
}
|
|
|
|
const actionMap = {
|
|
pet: { type: 'miniapp_pet', data: { action: 'pet', source: 'miniapp' } },
|
|
feed: { type: 'miniapp_feed', data: { action: 'feed', source: 'miniapp' } },
|
|
play: { type: 'miniapp_play', data: { action: 'play', source: 'miniapp' } },
|
|
talk: { type: 'miniapp_talk', data: { action: 'talk', source: 'miniapp' } },
|
|
};
|
|
|
|
const mapped = actionMap[action];
|
|
if (!mapped) return res.status(400).json({ error: 'Unknown action' });
|
|
|
|
events.push({
|
|
id: Date.now() + '_' + Math.random().toString(36).slice(2, 8),
|
|
type: mapped.type,
|
|
data: mapped.data,
|
|
timestamp: new Date().toISOString(),
|
|
consumed: false,
|
|
});
|
|
|
|
if (events.length > 100) events = events.slice(-100);
|
|
writeFileSync(eventsFile, JSON.stringify(events, null, 2));
|
|
|
|
res.json({ ok: true, action });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Action failed' });
|
|
}
|
|
});
|
|
}
|