Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
197 lines
6.8 KiB
JavaScript
197 lines
6.8 KiB
JavaScript
// ============================================================
|
|
// PES CRM Client — Bidirectional Bridge to God CRM API
|
|
// ============================================================
|
|
// Makes HTTP calls to God CRM on behalf of the pet.
|
|
// Level-gated: crm_read (L8), crm_write (L10), analytics (L12), crm_tasks (L15).
|
|
// ============================================================
|
|
|
|
import { sign } from 'node:crypto';
|
|
import { createHmac } from 'node:crypto';
|
|
|
|
const DEFAULT_BASE = 'http://127.0.0.1:5000';
|
|
|
|
// Generate a JWT token for PES service account
|
|
function makeServiceToken(secret) {
|
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
const payload = Buffer.from(JSON.stringify({
|
|
userId: 'pes-service',
|
|
role: 'admin',
|
|
iat: Math.floor(Date.now() / 1000),
|
|
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
|
|
})).toString('base64url');
|
|
const signature = createHmac('sha256', secret)
|
|
.update(`${header}.${payload}`)
|
|
.digest('base64url');
|
|
return `${header}.${payload}.${signature}`;
|
|
}
|
|
|
|
export class CrmClient {
|
|
/**
|
|
* @param {object} opts
|
|
* @param {string} opts.baseUrl — CRM API base (default: http://127.0.0.1:5000)
|
|
* @param {string} opts.jwtSecret — JWT_SECRET from .env
|
|
* @param {number} opts.spaceId — Default space ID (11 = Development)
|
|
*/
|
|
constructor(opts = {}) {
|
|
this.baseUrl = (opts.baseUrl || DEFAULT_BASE).replace(/\/$/, '');
|
|
this.jwtSecret = opts.jwtSecret || '';
|
|
this.spaceId = opts.spaceId || 11;
|
|
this._token = null;
|
|
this._tokenExp = 0;
|
|
this._tableCache = null; // cached table list for space
|
|
this._tableCacheExp = 0;
|
|
}
|
|
|
|
// ── Auth ─────────────────────────────────────────────────
|
|
|
|
_getToken() {
|
|
if (this._token && Date.now() < this._tokenExp) return this._token;
|
|
this._token = makeServiceToken(this.jwtSecret);
|
|
this._tokenExp = Date.now() + 50 * 60 * 1000; // refresh in 50 min
|
|
return this._token;
|
|
}
|
|
|
|
// ── HTTP ─────────────────────────────────────────────────
|
|
|
|
async _fetch(path, opts = {}) {
|
|
const url = `${this.baseUrl}/api/v3${path}`;
|
|
const headers = {
|
|
'Authorization': `Bearer ${this._getToken()}`,
|
|
'Content-Type': 'application/json',
|
|
...opts.headers,
|
|
};
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: opts.method || 'GET',
|
|
headers,
|
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
console.error(`[CRM] ${opts.method || 'GET'} ${path} → ${res.status}: ${text.slice(0, 200)}`);
|
|
return null;
|
|
}
|
|
return await res.json();
|
|
} catch (err) {
|
|
console.error(`[CRM] ${opts.method || 'GET'} ${path} error:`, err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Table Discovery ──────────────────────────────────────
|
|
|
|
async getTables(spaceId) {
|
|
const sid = spaceId || this.spaceId;
|
|
if (this._tableCache && Date.now() < this._tableCacheExp) return this._tableCache;
|
|
const res = await this._fetch(`/spaces/${sid}/tables`);
|
|
if (res?.data) {
|
|
this._tableCache = res.data;
|
|
this._tableCacheExp = Date.now() + 5 * 60 * 1000;
|
|
}
|
|
return res?.data || [];
|
|
}
|
|
|
|
async findTable(nameQuery) {
|
|
const tables = await this.getTables();
|
|
if (!tables.length) return null;
|
|
const q = nameQuery.toLowerCase();
|
|
return tables.find(t =>
|
|
t.name?.toLowerCase().includes(q) ||
|
|
t.display_name?.toLowerCase()?.includes(q)
|
|
) || null;
|
|
}
|
|
|
|
// ── Table Schema ─────────────────────────────────────────
|
|
|
|
async getColumns(tableId) {
|
|
const res = await this._fetch(`/tables/${tableId}/columns`);
|
|
return res?.data || res || [];
|
|
}
|
|
|
|
// ── Row CRUD ─────────────────────────────────────────────
|
|
|
|
async getRows(tableId, { limit = 20, offset = 0, search = '', filters = {} } = {}) {
|
|
let path = `/tables/${tableId}/rows?limit=${limit}&offset=${offset}`;
|
|
if (search) path += `&search=${encodeURIComponent(search)}`;
|
|
const res = await this._fetch(path);
|
|
return res?.data || res?.rows || [];
|
|
}
|
|
|
|
async getRow(tableId, rowId) {
|
|
const res = await this._fetch(`/tables/${tableId}/rows/${rowId}`);
|
|
return res?.data || res || null;
|
|
}
|
|
|
|
async createRow(tableId, data) {
|
|
const res = await this._fetch(`/tables/${tableId}/rows`, {
|
|
method: 'POST',
|
|
body: { data },
|
|
});
|
|
return res?.data || res || null;
|
|
}
|
|
|
|
async updateRow(tableId, rowId, data) {
|
|
const res = await this._fetch(`/tables/${tableId}/rows/${rowId}`, {
|
|
method: 'PUT',
|
|
body: { data },
|
|
});
|
|
return res?.data || res || null;
|
|
}
|
|
|
|
// ── High-Level Queries ───────────────────────────────────
|
|
|
|
/**
|
|
* Search across all tables in the space for a text query.
|
|
* Returns: [{ table, rows }]
|
|
*/
|
|
async searchAll(query, { maxTables = 5, maxRows = 5 } = {}) {
|
|
const tables = await this.getTables();
|
|
if (!tables.length) return [];
|
|
const results = [];
|
|
for (const table of tables.slice(0, maxTables)) {
|
|
const rows = await this.getRows(table.id, { search: query, limit: maxRows });
|
|
if (rows.length) {
|
|
results.push({ table: table.name || table.display_name, tableId: table.id, rows });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get a summary of all tables (name, row count).
|
|
*/
|
|
async getSpaceSummary() {
|
|
const tables = await this.getTables();
|
|
const summary = [];
|
|
for (const t of tables) {
|
|
const rows = await this.getRows(t.id, { limit: 1 });
|
|
summary.push({
|
|
name: t.name || t.display_name,
|
|
id: t.id,
|
|
rowCount: rows.length > 0 ? '1+' : '0',
|
|
});
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
/**
|
|
* Format rows for display in chat (compact text).
|
|
*/
|
|
formatRows(rows, maxFields = 5) {
|
|
if (!rows || !rows.length) return 'Ничего не найдено.';
|
|
return rows.slice(0, 10).map((row, i) => {
|
|
const data = row.data || row;
|
|
const fields = Object.entries(data)
|
|
.filter(([k, v]) => v != null && v !== '' && !k.startsWith('_') && k !== 'id')
|
|
.slice(0, maxFields)
|
|
.map(([k, v]) => {
|
|
const val = typeof v === 'object' ? JSON.stringify(v).slice(0, 50) : String(v).slice(0, 80);
|
|
return ` ${k}: ${val}`;
|
|
});
|
|
return `#${i + 1}\n${fields.join('\n')}`;
|
|
}).join('\n\n');
|
|
}
|
|
}
|
|
|
|
export default CrmClient;
|